fix: complete caller analytics reliability remediation

This commit is contained in:
hectorzhao
2026-09-01 10:03:29 +08:00
parent a116926867
commit 7f8825cf3a
25 changed files with 850 additions and 202 deletions
+64 -18
View File
@@ -1,29 +1,37 @@
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' | 'DURATION' | 'END' | 'RECONCILE' | 'UNKNOWN';
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;
coverageGap?: boolean; durationAuthority?: number; talkStartedAt?: number | null;
durationComplete?: boolean; terminationReason?: string;
}
export interface AnalyticsCall {
start: AnalyticsEvent; legs: Record<string, AnalyticsLeg>;
closedAt?: number | null; closeEvidence?: string; closeExpected?: 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;
durationUnknownCalls: number; provisionalAnsweredCalls: 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 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 {
@@ -40,7 +48,7 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
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');
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 = {
@@ -49,7 +57,9 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
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')
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');
@@ -57,32 +67,59 @@ export function parseAnalyticsEvent(fields: string[]): AnalyticsEvent {
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<string, unknown>;
const mapped: Record<string, string> = {};
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'
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;
if (event.source === 'verified-dialog-final' && leg.acceptedAt !== null && event.talkMs === null && !leg.durationFinal) {
leg.talkMs = Math.max(0, event.at - leg.acceptedAt); leg.durationAt = event.at; leg.durationFinal = true; leg.durationAuthority = 1;
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 === 'ACCEPTED' && leg.endedAt !== null && !leg.durationFinal) {
leg.talkMs = Math.max(0, leg.endedAt - event.at); leg.durationAt = leg.endedAt; leg.durationFinal = true; leg.durationAuthority = 1;
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';
@@ -90,6 +127,7 @@ export function foldAnalytics(call: AnalyticsCall | null, event: AnalyticsEvent)
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;
@@ -104,19 +142,25 @@ export interface AnalyticsProjection extends AnalyticsCounts {
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 };
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 active = !legs.length || counts.some(c => c.activeCalls > 0);
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 && !active),
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)
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,
@@ -128,6 +172,8 @@ 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) };
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; }