import { createHash } from 'node:crypto'; export const ANALYTICS_VERSION = 'caller-analytics-v1.1'; export const ANALYTICS_REVISION = 'caller-analytics-r2'; export const ANALYTICS_STREAM = 'stream:caller_analytics'; export const ANALYTICS_GROUP = 'caller-analytics-workers'; export type AnalyticsKind = 'START' | 'ATTEMPT' | 'PROGRESS' | 'ACCEPTED' | 'TALK_START' | 'DURATION' | 'END' | 'CLOSE' | '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; schemaVersion?: number; nodeId?: string; bootId?: string; receivedAt?: number; method?: string; cseq?: string; branch?: string; evidenceVersion?: 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; coverageGap?: boolean; durationAuthority?: number; talkStartedAt?: number | null; durationComplete?: boolean; terminationReason?: string; } export interface AnalyticsCall { start: AnalyticsEvent; legs: Record; closedAt?: number | null; closeEvidence?: string; closeExpected?: boolean; } export interface AnalyticsCounts { totalCalls: number; connectedCalls: number; answeredCalls: number; failedCalls: number; pendingCalls: number; unknownCalls: number; activeCalls: number; talkMs: number; durationUnknownCalls: number; provisionalAnsweredCalls: number; } export const COUNT_KEYS = ['totalCalls', 'connectedCalls', 'answeredCalls', 'failedCalls', 'pendingCalls', 'unknownCalls', 'activeCalls', 'talkMs', 'durationUnknownCalls', 'provisionalAnsweredCalls'] as const; export const emptyCounts = (): AnalyticsCounts => ({ totalCalls: 0, connectedCalls: 0, answeredCalls: 0, failedCalls: 0, pendingCalls: 0, unknownCalls: 0, activeCalls: 0, talkMs: 0, durationUnknownCalls: 0, provisionalAnsweredCalls: 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 = {}; 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 (!['1', '2'].includes(f.version) || !['START', 'ATTEMPT', 'PROGRESS', 'ACCEPTED', 'TALK_START', 'DURATION', 'END', 'CLOSE', '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'), schemaVersion: Number(f.version), nodeId: text('nodeId', 64), bootId: text('bootId', 80), receivedAt: num('receivedAt', at), method: text('method', 24), cseq: text('cseq', 64), branch: text('branch', 128), evidenceVersion: num('evidenceVersion', 1) }; 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; } export function validateAnalyticsEvent(input: unknown): AnalyticsEvent { if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid analytics event'); const raw = input as Record; const mapped: Record = {}; for (const [key, value] of Object.entries(raw)) { if (value !== null && value !== undefined) mapped[key] = String(value); } mapped.version = String(raw.schemaVersion ?? raw.version ?? 1); if (raw.talkMs === null || raw.talkMs === undefined) mapped.talkMs = '-1'; return parseAnalyticsEvent(Object.entries(mapped).flat()); } 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.schemaVersion ?? 1) >= 2) result.closeExpected = true; if (event.kind === 'START') return result; if (event.kind === 'CLOSE') { if (result.closedAt === null || result.closedAt === undefined || event.at >= result.closedAt) { result.closedAt = event.at; result.closeEvidence = event.source; } 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', talkStartedAt: null, durationComplete: false }; if (event.kind === 'ATTEMPT') { leg.event = event; if (!leg.coverageGap) 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 === 'TALK_START') leg.talkStartedAt = earliest(leg.talkStartedAt ?? null, event.at); if (event.kind === 'UNKNOWN') { leg.unknown = true; leg.coverageGap = 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 === 'platform-final' && !event.attemptId && !leg.coverageGap) leg.unknown = false; leg.terminationReason = event.source; // A terminal non-2xx response proves that no answered dialog existed, so // the final talk duration is exactly zero even if provisional 180/183 was seen. if ((event.code < 200 || event.code >= 300) && leg.acceptedAt === null && leg.talkStartedAt === null) { leg.talkMs = 0; leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true; } if (event.source === 'verified-dialog-final' && leg.talkStartedAt != null && event.talkMs === null && !leg.durationFinal) { leg.talkMs = Math.max(0, event.at - leg.talkStartedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true; } } if (event.kind === 'TALK_START' && leg.endedAt !== null && !leg.durationFinal) { leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1; leg.durationComplete = true; } if (event.talkMs !== null && ['DURATION', 'END', 'RECONCILE'].includes(event.kind)) { const final = event.kind !== 'DURATION'; const authority = event.kind === 'RECONCILE' ? 3 : final ? 2 : 0; const priorAuthority = leg.durationAuthority ?? (leg.durationFinal ? 1 : 0); if (authority > priorAuthority || (authority === priorAuthority && event.at >= leg.durationAt)) { leg.talkMs = event.talkMs; leg.durationAt = event.at; leg.durationFinal = final; leg.durationAuthority = authority; if (final) leg.durationComplete = true; } } 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; const answered = leg.talkMs > 0; return { totalCalls: 1, connectedCalls: +connected, answeredCalls: +answered, failedCalls: +(!connected && !unknown && leg.endedAt !== null), pendingCalls: +(!connected && !unknown && leg.endedAt === null), unknownCalls: +unknown, activeCalls: +(leg.endedAt === null), talkMs: leg.talkMs, durationUnknownCalls: +(connected && leg.endedAt !== null && !leg.durationComplete), provisionalAnsweredCalls: +(answered && !leg.durationFinal) }; } 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 legacyClosed = resultIsLegacy(call) && legs.length > 0 && counts.every(c => c.activeCalls === 0); const active = resultIsLegacy(call) ? (!legs.length || counts.some(c => c.activeCalls > 0)) : call.closedAt == null; 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 && (legacyClosed || !active)), pendingCalls: +(!connected && !unknown && active), unknownCalls: +unknown, activeCalls: +active, talkMs: counts.reduce((sum, c) => sum + c.talkMs, 0), durationUnknownCalls: +(connected && !counts.some(c => c.answeredCalls > 0) && counts.some(c => c.durationUnknownCalls > 0)), provisionalAnsweredCalls: +(counts.some(c => c.provisionalAnsweredCalls > 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: counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.totalCalls), connectedAnswerRate: counts.unknownCalls || counts.durationUnknownCalls ? null : ratio(counts.answeredCalls, counts.connectedCalls) }; } function resultIsLegacy(call: AnalyticsCall): boolean { return call.closeExpected !== true; }