feat: add caller connection and answer rate analytics
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const ANALYTICS_VERSION = 'caller-analytics-v1.1';
|
||||
export const ANALYTICS_STREAM = 'stream:caller_analytics';
|
||||
export const ANALYTICS_GROUP = 'caller-analytics-workers';
|
||||
export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'DURATION' | 'END' | 'RECONCILE' | 'UNKNOWN';
|
||||
export interface AnalyticsEvent {
|
||||
eventId: string; callUid: string; callId: string; attemptId: string;
|
||||
kind: AnalyticsKind; at: number; startedAt: number;
|
||||
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
|
||||
caller: string; landingCaller: string; callee: string; city: string; carrier: string;
|
||||
code: number; talkMs: number | null; source: string; sequence: number;
|
||||
}
|
||||
export interface AnalyticsLeg {
|
||||
event: AnalyticsEvent; first180At: number | null; first183At: number | null;
|
||||
acceptedAt: number | null; endedAt: number | null; finalCode: number;
|
||||
talkMs: number; durationAt: number; durationFinal: boolean; unknown: boolean;
|
||||
}
|
||||
export interface AnalyticsCall { start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>; }
|
||||
export interface AnalyticsCounts {
|
||||
totalCalls: number; connectedCalls: number; answeredCalls: number;
|
||||
failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number;
|
||||
}
|
||||
export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs'] as const;
|
||||
export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 0 });
|
||||
export function analyticsHash(value: unknown): string { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
||||
|
||||
export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
|
||||
const f: Record<string, string> = {};
|
||||
for (let i = 0; i < fields.length; i += 2) f[fields[i]] = fields[i + 1];
|
||||
const text = (key: string, max = 255): string => {
|
||||
const value = f[key] ?? '';
|
||||
if (value.length > max || [...value].some(c => c.charCodeAt(0) < 32)) throw new Error(`Invalid ${key}`);
|
||||
return value === 'none' ? '' : value;
|
||||
};
|
||||
const num = (key: string, fallback = 0): number => {
|
||||
const value = f[key] === undefined ? fallback : Number(f[key]);
|
||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid ${key}`);
|
||||
return value;
|
||||
};
|
||||
const kind = text('kind') as AnalyticsKind;
|
||||
if (f.version !== '1' || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'DURATION', 'END', 'RECONCILE', 'UNKNOWN'].includes(kind)) throw new Error('Invalid analytics schema');
|
||||
const at = num('at'); const startedAt = num('startedAt');
|
||||
if (at < 1_700_000_000_000 || at > Date.now() + 60_000 || startedAt > at || startedAt < 1_700_000_000_000) throw new Error('Invalid event time');
|
||||
const event: AnalyticsEvent = {
|
||||
eventId: text('eventId', 255), callUid: text('callUid', 255), callId: text('callId'), attemptId: text('attemptId', 80),
|
||||
kind, at, startedAt, customerId: text('customerId', 32), customerGatewayId: text('customerGatewayId', 32),
|
||||
vendorId: text('vendorId', 32), vendorGatewayId: text('vendorGatewayId', 32),
|
||||
caller: text('caller', 64), landingCaller: text('landingCaller', 64), callee: text('callee', 64),
|
||||
city: text('city', 12), carrier: text('carrier', 20), code: num('code'),
|
||||
talkMs: f.talkMs === undefined || f.talkMs === '-1' ? null : num('talkMs'), source: text('source', 64), sequence: num('sequence')
|
||||
};
|
||||
if (!event.eventId || !event.callUid || !event.callId || !event.customerId || !event.caller) throw new Error('Missing call identity');
|
||||
if (event.code > 699 || (kind === 'PROGRESS' && ![100, 180, 181, 182, 183].includes(event.code))) throw new Error('Invalid response code');
|
||||
if (kind === 'RECONCILE' && event.source !== 'verified-dialog-final') throw new Error('Unverified CDR cannot reconcile');
|
||||
return event;
|
||||
}
|
||||
|
||||
const earliest = (a: number | null, b: number): number => a === null ? b : Math.min(a, b);
|
||||
export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent): AnalyticsCall {
|
||||
const result: AnalyticsCall = call ? structuredClone(call) : { start: event, legs: {} };
|
||||
if (result.start.customerId !== event.customerId || result.start.caller !== event.caller) throw new Error('Conflicting call identity');
|
||||
if (event.startedAt < result.start.startedAt || (event.kind === 'START' && event.startedAt === result.start.startedAt)) result.start = event;
|
||||
if (event.kind === 'START') return result;
|
||||
const key = event.attemptId || 'platform';
|
||||
const leg: AnalyticsLeg = result.legs[key] ?? {
|
||||
event, first180At: null, first183At: null, acceptedAt: null, endedAt: null,
|
||||
finalCode: 0, talkMs: 0, durationAt: 0, durationFinal: false, unknown: event.kind !== 'ATTEMPT'
|
||||
};
|
||||
if (event.kind === 'ATTEMPT') { leg.event = event; leg.unknown = false; }
|
||||
if (event.kind === 'PROGRESS' && event.code === 180) leg.first180At = earliest(leg.first180At, event.at);
|
||||
if (event.kind === 'PROGRESS' && event.code === 183) leg.first183At = earliest(leg.first183At, event.at);
|
||||
if (event.kind === 'ACCEPTED' && event.code >= 200 && event.code < 300) leg.acceptedAt = earliest(leg.acceptedAt, event.at);
|
||||
if (event.kind === 'UNKNOWN') leg.unknown = true;
|
||||
if (event.kind === 'END' || event.kind === 'RECONCILE') {
|
||||
if (leg.endedAt === null || event.at >= leg.endedAt) { leg.endedAt = event.at; leg.finalCode = event.code; }
|
||||
// END carries the producer's accumulated phase flags through separate replayable events.
|
||||
if (event.source === 'verified-dialog-final' || event.source === 'platform-final') leg.unknown = false;
|
||||
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null) {
|
||||
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true;
|
||||
}
|
||||
}
|
||||
if (event.kind === 'ACCEPTED' && leg.endedAt !== null && !leg.durationFinal) {
|
||||
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true;
|
||||
}
|
||||
if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) {
|
||||
const final = event.kind !== 'DURATION';
|
||||
if ((!leg.durationFinal || final) && event.at >= leg.durationAt) {
|
||||
leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final;
|
||||
}
|
||||
}
|
||||
result.legs[key] = leg;
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface AnalyticsProjection extends AnalyticsCounts {
|
||||
id: string; callKey: string; view: 'original' | 'landing'; startedAt: number;
|
||||
customerId: string; customerGatewayId: string; vendorId: string; vendorGatewayId: string;
|
||||
caller: string; callee: string; city: string; carrier: string; payload: AnalyticsCall | AnalyticsLeg;
|
||||
}
|
||||
function legCounts(leg: AnalyticsLeg): AnalyticsCounts {
|
||||
const connected = leg.first180At !== null || leg.first183At !== null || leg.acceptedAt !== null || leg.talkMs > 0;
|
||||
const unknown = !connected && leg.unknown;
|
||||
return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +(leg.talkMs > 0), failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs };
|
||||
}
|
||||
export function projectAnalytics(call: AnalyticsCall): AnalyticsProjection[] {
|
||||
const e = call.start; const key = analyticsHash(e.callUid);
|
||||
const legs = Object.values(call.legs); const counts = legs.map(legCounts);
|
||||
const connected = counts.some(c => c.connectedCalls > 0); const active = !legs.length || counts.some(c => c.activeCalls > 0);
|
||||
const unknown = !connected && counts.some(c => c.unknownCalls > 0);
|
||||
const base = { customerId: e.customerId, customerGatewayId: e.customerGatewayId, callee: e.callee, city: e.city, carrier: e.carrier, callKey: key };
|
||||
return [{ ...base, id: analyticsHash([key, 'original']), view: 'original', startedAt: e.startedAt, caller: e.caller,
|
||||
vendorId: '', vendorGatewayId: '', payload: call, totalCalls: 1, connectedCalls: +connected,
|
||||
answeredCalls: +(counts.some(c => c.answeredCalls > 0)), failedCalls: +(!connected && !unknown && !active),
|
||||
pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active,
|
||||
talkMs: counts.reduce((sum, c) => sum + c.talkMs, 0)
|
||||
}, ...legs.filter(l => l.event.attemptId && l.event.vendorGatewayId).map(leg => ({
|
||||
...base, ...legCounts(leg), id: analyticsHash([key, leg.event.attemptId]), view: 'landing' as const,
|
||||
startedAt: leg.event.startedAt, caller: leg.event.landingCaller || leg.event.caller,
|
||||
vendorId: leg.event.vendorId, vendorGatewayId: leg.event.vendorGatewayId, payload: leg
|
||||
}))];
|
||||
}
|
||||
|
||||
export function analyticsRates(counts: AnalyticsCounts) {
|
||||
const ratio = (n: number, d: number) => d ? Number((n / d * 100).toFixed(4)) : null;
|
||||
return { ...counts, notConnectedCalls: counts.failedCalls + counts.pendingCalls,
|
||||
connectionRate: counts.unknownCalls ? null : ratio(counts.connectedCalls, counts.totalCalls),
|
||||
overallAnswerRate: ratio(counts.answeredCalls, counts.totalCalls),
|
||||
connectedAnswerRate: counts.unknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) };
|
||||
}
|
||||
Reference in New Issue
Block a user