fix: complete caller analytics reliability remediation
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -43,7 +43,7 @@ export class CallerAnalyticsStore {
|
||||
const minute = new Date(Math.floor(row.startedAt / 60000) * 60000);
|
||||
const id = analyticsHash([minute.toISOString(), row.view, row.customerId, row.customerGatewayId, row.vendorId, row.vendorGatewayId, row.caller, row.city, row.carrier]);
|
||||
const delta = JSON.stringify(Object.fromEntries(COUNT_KEYS.map(k => [k, sign * row[k]])));
|
||||
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED) + ${sign * row[k]}`]));
|
||||
const additions = Prisma.join(COUNT_KEYS.flatMap(k => [Prisma.sql`${k}`, Prisma.sql`COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(counts, ${`$.${k}`})) AS SIGNED),0) + ${sign * row[k]}`]));
|
||||
await tx.$executeRaw(Prisma.sql`INSERT INTO caller_analysis_minute_buckets
|
||||
(id, started_at, view, customer_id, customer_gateway_id, vendor_id, vendor_gateway_id, caller, city, carrier, counts)
|
||||
VALUES (${id}, ${minute}, ${row.view}, ${row.customerId}, ${row.customerGatewayId}, ${row.vendorId}, ${row.vendorGatewayId}, ${row.caller}, ${row.city}, ${row.carrier}, ${delta})
|
||||
|
||||
@@ -6,7 +6,7 @@ function run(events: AnalyticsEvent[]) { return projectAnalytics(events.reduce<A
|
||||
describe('caller analytics business state', () => {
|
||||
it.each([180,183])('counts %i as connected even if final call fails', code => {
|
||||
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 487, source: 'verified-dialog-final' })]);
|
||||
expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0 });
|
||||
expect(analyticsRates(r)).toMatchObject({ totalCalls: 1, connectedCalls: 1, answeredCalls: 0, failedCalls: 0, notConnectedCalls: 0, connectionRate: 100, overallAnswerRate: 0, connectedAnswerRate: 0, durationUnknownCalls:0 });
|
||||
});
|
||||
it.each([100,181,182])('does not treat %i as connected', code => {
|
||||
const [r] = run([event('ATTEMPT'), event('PROGRESS', { code }), event('END', { code: 486, source: 'verified-dialog-final' })]);
|
||||
@@ -15,8 +15,8 @@ describe('caller analytics business state', () => {
|
||||
it('counts positive sub-second duration, not just 200', () => {
|
||||
const [zero] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 })]);
|
||||
expect(zero.answeredCalls).toBe(0);
|
||||
const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]);
|
||||
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 500 });
|
||||
const [positive] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('TALK_START', { at:start+1100, source:'dialog-acked' }), event('END', { at: start + 1500, source: 'verified-dialog-final' })]);
|
||||
expect(positive).toMatchObject({ connectedCalls: 1, answeredCalls: 1, talkMs: 400 });
|
||||
});
|
||||
it('preserves zero actual duration', () => {
|
||||
const [r] = run([event('ATTEMPT'), event('ACCEPTED', { code: 200 }), event('END', { talkMs: 0, source: 'verified-dialog-final' })]);
|
||||
@@ -44,7 +44,7 @@ describe('caller analytics business state', () => {
|
||||
expect(() => run([event('ATTEMPT'), event('PROGRESS', { customerId: 'other', code: 180 })])).toThrow('Conflicting');
|
||||
});
|
||||
it('calculates the three different business rates', () => {
|
||||
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
|
||||
expect(analyticsRates({ totalCalls:100, connectedCalls:60, answeredCalls:30, failedCalls:25, pendingCalls:15, unknownCalls:0, activeCalls:15, talkMs:0, durationUnknownCalls:0, provisionalAnsweredCalls:0 })).toMatchObject({ notConnectedCalls:40, connectionRate:60, overallAnswerRate:30, connectedAnswerRate:50 });
|
||||
});
|
||||
it('does not erase missing progress evidence with an end event', () => {
|
||||
expect(run([event('END',{code:487,source:'verified-dialog-final'})])[0]).toMatchObject({unknownCalls:1,failedCalls:0});
|
||||
@@ -59,4 +59,13 @@ describe('caller analytics business state', () => {
|
||||
expect(run([event('ATTEMPT'),event('ACCEPTED',{code:200}),...sequence,event('END',{at:start+5000,source:'verified-dialog-final'})])[0]).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0});
|
||||
}
|
||||
});
|
||||
it('does not infer answer duration from 2xx without ACK evidence',()=>{
|
||||
const[r]=run([event('ATTEMPT'),event('ACCEPTED',{code:200}),event('END',{at:start+5000,source:'verified-dialog-final'})]);
|
||||
expect(analyticsRates(r)).toMatchObject({connectedCalls:1,answeredCalls:0,talkMs:0,durationUnknownCalls:1,overallAnswerRate:null,connectedAnswerRate:null});
|
||||
});
|
||||
it('keeps a version 2 original call pending until explicit close',()=>{
|
||||
const base={schemaVersion:2};
|
||||
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'})])[0]).toMatchObject({activeCalls:1,pendingCalls:1,failedCalls:0});
|
||||
expect(run([event('START',base),event('ATTEMPT',base),event('END',{...base,code:486,source:'verified-dialog-final'}),event('CLOSE',{...base,source:'attempts-exhausted'})])[0]).toMatchObject({activeCalls:0,pendingCalls:0,failedCalls:1});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user