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>
132 lines
4.3 KiB
JavaScript
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;
|
|
}
|