collabSupport/services/callReport/voicePathAttribution.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

132 lines
4.3 KiB
JavaScript

// services/callReport/voicePathAttribution.js
//
// Phase 2 helpers: annotate a call's WAN quality with the *likely*
// path_type and/or waninterface when Prisma DPI is broken out.
//
// Confidence levels:
// - site — only site-wide appAudio (legacy siteLevelApprox)
// - path_type — byPathType uniquely implicates one path_type
// - path — byPath uniquely implicates one waninterface id
// - endpoint — reserved; requires flow/session join keys
// (not available on this tenant yet — see
// services/voiceDiag/README.md § Voice path attribution)
//
// Never claims "confirmed" without flow records.
const PATH_TYPE_ORDER = ['VPN', 'PrivateWAN', 'PrivateVPN', 'DirectInternet', 'ServiceLink'];
/**
* Score a path-type / path summary for "how bad during this call".
* Higher = worse. Uses loss max and jitter max when present.
*/
function badnessScore(summary) {
if (!summary) return null;
const loss = summary.loss?.max ?? summary.loss?.avg;
const jitter = summary.jitter?.max ?? summary.jitter?.avg;
const mos = summary.mos?.min ?? summary.mos?.avg;
let score = 0;
let parts = 0;
if (typeof loss === 'number') { score += loss; parts += 1; }
if (typeof jitter === 'number') { score += jitter / 10; parts += 1; }
if (typeof mos === 'number') { score += Math.max(0, 5 - mos) * 5; parts += 1; }
return parts > 0 ? score : null;
}
/**
* Pick the uniquely-worst entry from a map of summaries.
* Returns null when tie / empty / insufficient data.
*
* @param {Record<string, object>} map
* @returns {{ key: string, score: number, margin: number }|null}
*/
export function pickUniqueWorst(map) {
if (!map || typeof map !== 'object') return null;
const scored = Object.entries(map)
.map(([key, summary]) => ({ key, score: badnessScore(summary) }))
.filter((e) => e.score != null)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) return null;
if (scored.length === 1) {
return { key: scored[0].key, score: scored[0].score, margin: scored[0].score };
}
const margin = scored[0].score - scored[1].score;
// Require a meaningful gap so we don't flip-flop on noise.
if (margin < 0.5) return null;
return { key: scored[0].key, score: scored[0].score, margin };
}
/**
* Annotate a site-level wan bucket with likely path attribution.
*
* @param {object|null} wanBucket from worstPrismaBucketForCall
* @param {object|null} appAudio collectSdwanForStore().appAudio
* @returns {object|null}
*/
export function annotateWanWithPath(wanBucket, appAudio) {
if (!wanBucket) return null;
const base = {
...wanBucket,
siteLevelApprox: wanBucket.siteLevelApprox !== false,
confidence: 'site',
pathType: null,
pathId: null,
attributionNote: null,
};
// Prefer per-circuit when present (Phase 2 path filter).
const byPath = appAudio?.byPath;
if (byPath && Object.keys(byPath).length > 0) {
const pick = pickUniqueWorst(byPath);
if (pick) {
return {
...base,
siteLevelApprox: false,
confidence: 'path',
pathId: pick.key,
pathType: null,
attributionNote:
`Likely on WAN circuit ${pick.key} ` +
`(worst path DPI during call window; confidence=path).`,
};
}
}
// Fall back to path_type breakout (Phase 1).
const byPt = appAudio?.byPathType;
if (byPt && Object.keys(byPt).length > 0) {
// Prefer known order for stable ties that somehow pass margin.
const ordered = {};
for (const k of PATH_TYPE_ORDER) {
if (byPt[k]) ordered[k] = byPt[k];
}
for (const k of Object.keys(byPt)) {
if (!ordered[k]) ordered[k] = byPt[k];
}
const pick = pickUniqueWorst(ordered);
if (pick) {
return {
...base,
siteLevelApprox: true,
confidence: 'path_type',
pathType: pick.key,
pathId: null,
attributionNote:
`Likely path type ${pick.key} ` +
`(worst path_type DPI in window; confidence=path_type, still site-level).`,
};
}
}
return base;
}
/**
* Phase 2c stub: per-handset / per-extension attribution.
* Returns null until Prisma exposes a join key (client IP / flow).
*
* @returns {null}
*/
export function attributeVoicePathToEndpoint(/* call, inventory */) {
return null;
}