collabSupport/services/enrichment/sdwanEnrichment.js
jmcqueen 7dc326c404 Split voice commands into status vs diag and redesign MPP phone output.
Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:55:41 -04:00

1079 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/services/enrichment/sdwanEnrichment.js
//
// One-stop composer for Prisma SD-WAN data used by /wanstatus and
// WAN follow-up and /voicediag's WAN check bucket. Same "fire the
// fetches in parallel + normalise into a stable shape + preserve
// per-metric failures in errors[]" pattern used by the Meraki
// enrichment layer.
//
// Contract (what `collectSdwanForStore(storeNum)` returns):
//
// {
// storeNum,
// site: { id, name, storeNum, description } | null,
// elements: [{ id, name, model, serial_number, connected }],
// healthscore: { value: 0-100, breakdown: {...} } | null,
// links: [
// {
// interfaceId, // Prisma waninterface id
// interfaceName, // human label
// elementId, // from runtime status when available
// networkName, // wan_network_name when present
// transportType, // "primary" | "secondary" | "backup" | ... (from used_for)
// up: boolean | null, // prefers operationalUp, then adminUp, then LQM samples
// adminUp, operationalUp,
// latencyMs: number | null,
// jitterMs: number | null,
// lossPct: number | null,
// mos: number | null,
// },
// ...
// ],
// tunnels: [{ id, peerLabel, state, up, relatedInterfaceId, recentAlarm }],
// alarms: {
// last1h: { critical: N, major: N, minor: N },
// samples: [{ code, message, severity, ts, vpnLinkIds }]
// },
// appAudio: {
// ...,
// byPathType: { VPN: { loss, jitter, bandwidth }, ... },
// byPath: { [wanInterfaceId]: { loss, jitter } } | null, // gated
// },
// errors: [{ scope, message }], // per-metric failure lines
// fetchedAt,
// }
//
// Design rules:
// - Never throws. A caller must be able to render "we tried, here's
// what we got, here's what failed" without a top-level try/catch.
// - `site: null` short-circuits everything else — the composer
// returns immediately with `errors: []` and empty collections.
// This is the "not a Prisma-managed store" happy path.
// - Each metric fetch is independent (`Promise.allSettled`). A
// failure adds an entry to `errors[]` and leaves the
// corresponding field null / empty; it does NOT propagate.
// - Response-shape assumptions are minimal and defensive: we
// read `res.metrics || res.data || res.items || []` and pick
// out the fields we recognise, tolerating extras/missing.
// - No caching here — each caller gets a fresh pull. Caching
// lives one layer down (sites + elements are cached; live
// metrics are always fresh).
import { logger } from '../../utils/logger.js';
import {
findSdwanSiteForStore,
getElementsForSite,
getWanInterfacesForSite,
getWanInterfaceStatusesForSite,
getAllSites,
} from '../../integrations/paloalto/sites.js';
import {
getHealthscore,
getLqmMetric,
getAppMetric,
getAppMetricsByPathType,
getAlarms,
VOICE_PATH_TYPE_SUBSET,
} from '../../integrations/paloalto/metrics.js';
import { getVpnLinksForSite, extractVpnLinkIdsFromAlarmInfo } from '../../integrations/paloalto/topology.js';
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
import { coerceAlarmTsMs } from './alarmSemantics.js';
/**
* Compose everything /voicediag + /wanstatus care about for a
* store's Prisma SD-WAN posture. Never throws — always returns the
* shape documented at the top of this file.
*
* @param {string|number} storeNum
* @param {object} [opts]
* @param {number} [opts.windowMinutes] Look-back window for healthscore + LQM.
* Also used as the alarm window unless `alarmWindowMinutes` is set.
* Defaults to `WAN_STANDARD_WINDOW_MINUTES` env var or 10080 (7d).
* Widened from 24h because per-app DPI metrics only get datapoints
* when calls actually happen — sporadic stores need a wider window
* for worst-window stats to be meaningful.
* @param {number} [opts.alarmWindowMinutes] Override the alarm window
* independently (defaults to `max(60, windowMinutes)` since alarms
* at < 1h are usually too noisy to be actionable).
* @returns {Promise<object>} See file header for shape. Includes
* `window` reporting the effective look-back so callers/renderers
* can surface it.
*/
export async function collectSdwanForStore(storeNum, opts = {}) {
const startedAt = Date.now();
const emptyLinks = [];
const errors = [];
const windowMinutes = normalizeWindowMinutes(opts.windowMinutes);
const alarmWindowMinutes = normalizeWindowMinutes(
opts.alarmWindowMinutes ?? Math.max(60, windowMinutes),
);
const site = await findSdwanSiteForStore(storeNum).catch((err) => {
errors.push({ scope: 'sites', message: err.message });
return null;
});
if (!site) {
logger('sdwan:enrich', `No Prisma site for store ${storeNum} (not a Prisma-managed site)`, 'debug');
return {
storeNum: String(storeNum),
site: null,
elements: [],
healthscore: null,
links: emptyLinks,
tunnels: [],
alarms: emptyAlarms(),
appAudio: null,
errors,
fetchedAt: new Date().toISOString(),
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
};
}
// Fetch config (elements + waninterfaces) in parallel — both are
// 4h-cached at the integration layer so this is a hot path on
// steady state.
const [elementsRes, wanInterfacesRes] = await Promise.allSettled([
getElementsForSite(site.id),
getWanInterfacesForSite(site.id),
]);
recordFailure(errors, 'elements', elementsRes);
recordFailure(errors, 'waninterfaces', wanInterfacesRes);
const elements = valueOf(elementsRes) || [];
const wanInterfaces = valueOf(wanInterfacesRes) || [];
const waninterfaceIds = wanInterfaces.map((w) => w.id).filter(Boolean);
// Runtime circuit status + overlay tunnels in parallel with metrics.
// Both are best-effort — failures land in errors[] without aborting.
const canFetchLqm = waninterfaceIds.length > 0;
const voiceAppCfg = resolveVoiceAppConfig();
const voiceAppId = voiceAppCfg.appId;
const voiceAppName = voiceAppCfg.appName;
const canFetchAppMetrics = Boolean(voiceAppId);
const pathAttributionEnabled = isPathAttributionEnabled();
const [
healthscoreRes,
latencyRes,
jitterRes,
lossRes,
mosRes,
alarmsRes,
appMosRes,
appLossRes,
appJitterRes,
appBwRes,
statusMapRes,
tunnelsRes,
] = await Promise.allSettled([
getHealthscore(site.id, windowMinutes),
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'latency', windowMinutes) : Promise.resolve(null),
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'jitter', windowMinutes) : Promise.resolve(null),
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'loss', windowMinutes) : Promise.resolve(null),
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'mos', windowMinutes) : Promise.resolve(null),
getAlarms(site.id, alarmWindowMinutes),
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'mos', windowMinutes) : Promise.resolve(null),
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'loss', windowMinutes) : Promise.resolve(null),
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'jitter', windowMinutes) : Promise.resolve(null),
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'bandwidth', windowMinutes) : Promise.resolve(null),
canFetchLqm
? getWanInterfaceStatusesForSite(site.id, wanInterfaces)
: Promise.resolve(new Map()),
collectTunnelsForSite(site.id),
]);
recordFailure(errors, 'healthscore', healthscoreRes);
recordFailure(errors, 'lqm.latency', latencyRes);
recordFailure(errors, 'lqm.jitter', jitterRes);
recordFailure(errors, 'lqm.loss', lossRes);
recordFailure(errors, 'lqm.mos', mosRes);
recordFailure(errors, 'alarms', alarmsRes);
recordFailure(errors, 'waninterfaces.status', statusMapRes);
recordFailure(errors, 'tunnels', tunnelsRes);
if (canFetchAppMetrics) {
recordFailure(errors, 'app.voice.mos', appMosRes);
recordFailure(errors, 'app.voice.loss', appLossRes);
recordFailure(errors, 'app.voice.jitter', appJitterRes);
recordFailure(errors, 'app.voice.bandwidth', appBwRes);
}
// Path-type breakout runs AFTER the core batch so it doesn't compete
// with LQM/app aggregate calls for the PRISMA_MAX_INFLIGHT=3 budget
// (parallel fan-out previously caused cascading 429s). Only loss for
// VPN + DirectInternet by default — enough to spot overlay vs DIA.
let appLossByPt = {};
let appJitterByPt = {};
let appBwByPt = {};
if (canFetchAppMetrics && isPathTypeBreakoutEnabled()) {
try {
appLossByPt = await getAppMetricsByPathType(
site.id, voiceAppId, 'loss', windowMinutes, ['VPN', 'DirectInternet'],
);
} catch (err) {
errors.push({ scope: 'app.voice.loss.byPathType', message: err.message });
}
}
// Healthscore response covers the whole tenant (see filter shape
// note in metrics.js). Match the requested site locally.
const healthscore = parseHealthscore(valueOf(healthscoreRes), site.id);
const statusByInterfaceId = valueOf(statusMapRes) instanceof Map
? valueOf(statusMapRes)
: new Map();
const links = buildLinkRows({
wanInterfaces,
statusByInterfaceId,
metricResponses: {
latency: valueOf(latencyRes),
jitter: valueOf(jitterRes),
loss: valueOf(lossRes),
mos: valueOf(mosRes),
},
});
// Second-line defense: if events/query silently ignored our
// query.site filter, parseAlarms filters locally by site_id.
const alarms = parseAlarms(valueOf(alarmsRes), site.id);
const tunnels = annotateTunnelsWithAlarms(
Array.isArray(valueOf(tunnelsRes)) ? valueOf(tunnelsRes) : [],
alarms,
);
// Phase 2 optional: per-waninterface app DPI (filter.path). Gated
// because it multiplies request count. Failures stay in errors[].
let appAudioByPath = null;
if (canFetchAppMetrics && pathAttributionEnabled && waninterfaceIds.length > 0) {
appAudioByPath = await collectAppAudioByPath(
site.id,
voiceAppId,
waninterfaceIds,
windowMinutes,
errors,
);
}
// Debug shape probes: on high error counts these give us the
// exact top-level keys Prisma returned, which is the fastest way
// to spot schema drift. Kept at `warn` level while the response
// shape is still under active reverse-engineering.
const hsRaw = valueOf(healthscoreRes);
if (hsRaw && healthscore?.value == null) {
// v2.6 metrics response is expected to shape as:
// metrics[0].sites[].healthscore (or .data.score, etc.)
const m0 = hsRaw.metrics?.[0];
const s0 = m0?.sites?.[0];
logger('sdwan:enrich',
`healthscore returned but parseHealthscore extracted no value — ` +
`top-level keys: [${Object.keys(hsRaw).join(',')}], ` +
`metrics[0] keys: [${m0 ? Object.keys(m0).join(',') : 'n/a'}], ` +
`sites count: ${m0?.sites?.length ?? 'n/a'}, ` +
`sites[0] keys: ${s0 ? '[' + Object.keys(s0).join(',') + ']' : 'n/a'}, ` +
`sites[0] preview: ${JSON.stringify(s0 || null).slice(0, 200)}`,
'warn');
}
const linksWithSamples = links.filter((l) => l.latencyMs != null || l.jitterMs != null || l.lossPct != null || l.mos != null);
if (waninterfaceIds.length > 0 && linksWithSamples.length === 0) {
const anyLqmRaw = valueOf(latencyRes) || valueOf(jitterRes) || valueOf(lossRes) || valueOf(mosRes);
if (anyLqmRaw) {
// Live shape: metrics[0].sites[0].paths[N].data.<key>
const m0 = anyLqmRaw.metrics?.[0];
const s0 = m0?.sites?.[0];
const p0 = s0?.paths?.[0];
logger('sdwan:enrich',
`LQM responses returned but 0 links have samples — ` +
`top-level keys: [${Object.keys(anyLqmRaw).join(',')}], ` +
`metrics[0] keys: [${m0 ? Object.keys(m0).join(',') : 'n/a'}], ` +
`sites count: ${m0?.sites?.length ?? 'n/a'}, ` +
`paths[0]: ${JSON.stringify(p0 || null).slice(0, 200)}`,
'warn');
}
}
// Parse app-metric time series into {avg, min, max, samples} tuples
// per metric. Only present when the voice-app env is configured
// (PRISMA_APP_ID_VOICE, with PRISMA_APP_ID_RTP_BASE honored for
// backwards compatibility).
//
// `detailsUrl` is the SCM UI deep-link into "Application Path
// Details" for this exact (site, app) pair — computed here so both
// renderers (WAN diag + voice diag) share the same URL without
// duplicating the pattern.
const appAudio = canFetchAppMetrics
? {
appId: voiceAppId,
appName: voiceAppName,
detailsUrl: buildAppDetailsUrl(site.id, voiceAppId),
mos: summarizeAppSeries(valueOf(appMosRes), 'AppAudioMos'),
loss: summarizeAppSeries(valueOf(appLossRes), 'AppPerfUDPAudioPacketLoss'),
jitter: summarizeAppSeries(valueOf(appJitterRes), 'AppPerfUDPAudioJitter'),
bandwidth: summarizeAppSeries(valueOf(appBwRes), 'AppPerfUDPAudioBandwidth'),
byPathType: buildAppAudioByPathType({
lossByType: appLossByPt,
jitterByType: appJitterByPt,
bandwidthByType: appBwByPt,
}),
byPath: appAudioByPath,
}
: null;
const elapsed = Date.now() - startedAt;
const alarmDiag = alarms?._diag
? ` alarms=${alarms._diag.rawEventCount}raw→${alarms._diag.rawEventCount - alarms._diag.droppedForSiteMismatch}scoped`
: '';
const appAudioDiag = appAudio
? ` app=${appAudio.appName}(${appAudio.appId}) app.mos=[${appAudio.mos?.samples || 0}pts,min=${appAudio.mos?.min ?? 'n/a'}]`
: '';
const tunnelDiag = ` tunnels=${tunnels.length}`;
logger(
'sdwan:enrich',
`store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` +
`${links.length} links,${tunnelDiag} health=${healthscore?.value ?? 'n/a'},${alarmDiag}${appAudioDiag} ` +
`errors=${errors.length}, window=${windowMinutes}m/alarms=${alarmWindowMinutes}m, ${elapsed}ms`,
);
return {
storeNum: String(storeNum),
site: {
id: site.id,
name: site.name,
storeNum: String(storeNum),
description: site.description || '',
},
elements: elements.map((e) => ({
id: e.id,
name: e.name,
model: e.model,
serial_number: e.serial_number,
connected: e.connected,
})),
healthscore,
links,
tunnels,
alarms,
// Per-application "Application Path Details" style metrics — real
// voice-quality signal from DPI on actual RTP traffic. Null when
// PRISMA_APP_ID_RTP_BASE is not configured (feature-gated).
appAudio,
errors,
fetchedAt: new Date().toISOString(),
// Report the effective look-back so renderers can surface it and
// callers can verify the request they intended was honored.
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
};
}
// ─── App-metric time-series aggregator ──────────────────────────────
//
// App metrics come back as a real time series (usually 24h × 5min =
// 288 datapoints) rather than a single aggregated number like LQM.
// For voice quality, the worst-window matters more than the average:
// a store that averages 4.0 MOS over 24h but dipped to 1.85 for
// several 5-min windows had unusable calls in those windows even
// though the average looks fine.
//
// This helper extracts {avg, min, max, p95, samples} from the
// standard v2.6 response shape:
// metrics[0].series[0].data[0].datapoints[{time, value}]
//
// Returns null for missing / malformed responses. All-null datapoints
// (e.g. TCPFlowCount for a UDP-only app in the HAR) return
// { samples: N, avg/min/max: null } so downstream callers can
// distinguish "we asked but Prisma had no data" from "we didn't ask".
/**
* @param {object} raw v2.6 monitor/metrics response body
* @param {string} expectedName metric name we expect at metrics[].series[].name
* @returns {{avg, min, max, p95, samples, expectedName}|null}
*/
export function summarizeAppSeries(raw, expectedName) {
if (!raw || typeof raw !== 'object') return null;
const series = raw.metrics?.[0]?.series?.[0];
if (!series) return null;
// Sanity check: match by name so a mis-wired call can't silently
// land a jitter response into the loss bucket.
if (expectedName && series.name && series.name !== expectedName) {
logger('sdwan:enrich',
`summarizeAppSeries: expected metric name "${expectedName}", got "${series.name}"`, 'warn');
}
const datapoints = series.data?.[0]?.datapoints;
if (!Array.isArray(datapoints)) return null;
const timedPoints = datapoints.map((p) => ({
time: p?.time || null,
value: (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null),
}));
const values = timedPoints
.map((p) => p.value)
.filter((v) => v !== null);
const summary = {
expectedName,
unit: series.unit || null,
interval: series.interval || null,
samples: datapoints.length,
validSamples: values.length,
avg: null, min: null, max: null, p95: null,
// Keep the raw values around so a check can compute additional
// stats (percent-of-samples-above-threshold, etc.) without having
// to re-parse the raw response. Small (<= 288 numbers per metric)
// so no memory concern.
values,
// Timestamp-aligned series for per-call WAN overlap (callreport).
timedPoints,
};
if (values.length === 0) return summary;
let sum = 0, min = values[0], max = values[0];
for (const v of values) { sum += v; if (v < min) min = v; if (v > max) max = v; }
const sorted = [...values].sort((a, b) => a - b);
const p95Idx = Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length));
summary.avg = round2(sum / values.length);
summary.min = round2(min);
summary.max = round2(max);
summary.p95 = round2(sorted[p95Idx]);
return summary;
}
// ─── Voice-application (DPI) env resolution ─────────────────────────
//
// The per-app DPI feature is generic — different tenants may target
// different voice apps (rtp-base, Webex_Calling_RTP, MS Teams RTP,
// Zoom, etc.). Rather than hardcode a specific app, resolve id +
// display name from env at request time.
//
// Precedence:
// 1. PRISMA_APP_ID_VOICE — canonical, generic env var
// 2. PRISMA_APP_ID_RTP_BASE — backwards-compat with the
// original release, logged as
// deprecated on use
//
// Display name defaults to the app-key hint ('rtp-base' if only the
// legacy env is set, 'voice' otherwise); PRISMA_APP_NAME_VOICE
// overrides for the operator's preferred UI label.
//
// Exported so tests can exercise the resolution logic without
// spinning up the full enrichment path.
let _rtpBaseDeprecationLogged = false;
export function resolveVoiceAppConfig() {
const genericId = process.env.PRISMA_APP_ID_VOICE;
const legacyId = process.env.PRISMA_APP_ID_RTP_BASE;
const nameOverride = process.env.PRISMA_APP_NAME_VOICE;
let appId = null;
let defaultName = 'voice';
if (genericId && genericId.trim()) {
appId = genericId.trim();
} else if (legacyId && legacyId.trim()) {
appId = legacyId.trim();
defaultName = 'rtp-base';
if (!_rtpBaseDeprecationLogged) {
logger('sdwan:enrich',
'PRISMA_APP_ID_RTP_BASE is deprecated — rename to PRISMA_APP_ID_VOICE ' +
'(and optionally set PRISMA_APP_NAME_VOICE for a friendly display label). ' +
'The old name will continue to work but will be removed in a future release.',
'warn');
_rtpBaseDeprecationLogged = true;
}
}
const appName = (nameOverride && nameOverride.trim())
? nameOverride.trim()
: defaultName;
return { appId, appName };
}
/** @internal Test-only: reset the once-per-process deprecation flag. */
export function _resetVoiceAppDeprecationFlag() {
_rtpBaseDeprecationLogged = false;
}
function round2(n) {
if (!Number.isFinite(n)) return null;
return Math.round(n * 100) / 100;
}
// Default WAN look-back window (7 days in minutes). Widened from 24h
// because per-app DPI metrics (Webex_Calling_RTP etc.) only get
// datapoints WHEN CALLS HAPPEN — a store that only takes 3-4 Webex
// calls per day gives us ~30 samples in 24h, well below the point
// where "worst-window" stats mean anything. Seven days consistently
// yields 150-300+ per-app samples across stores of any size and
// still keeps the LQM/healthscore signal meaningful. The trade-off
// is that a fresh outage is diluted in a 7-day average, so:
// - The app-metric checks grade against WORST-window (already do),
// not average, so this doesn't blunt their signal.
// - Operators triaging a live incident should pass `--window 1h`
// or `--window 24h` (or set WAN_STANDARD_WINDOW_MINUTES) to
// narrow the view. The env override still works exactly the same.
const DEFAULT_WINDOW_MINUTES = 10080; // 7 × 24 × 60
const MAX_WINDOW_MINUTES = 10080; // hard cap — see pickInterval
/**
* Snap an arbitrary window request to a reasonable, sane value.
* Falls back to the env default (WAN_STANDARD_WINDOW_MINUTES) or
* 10080 (7d). Prisma's own upper bound sits at ~30 days for aiops
* queries, but per-app DPI series past 7d hit interval-collapse
* (Prisma downsamples to '1day', which loses the transient windows
* these checks are designed to catch). We hard-cap at 7d to keep
* the guarantees consistent — set MAX_WINDOW_MINUTES higher if you
* ever need to push past it, and re-verify pickAppMetricInterval /
* pickInterval interact cleanly with the new upper bound.
*/
function normalizeWindowMinutes(m) {
const raw = Number.isFinite(Number(m)) && Number(m) > 0
? Number(m)
: Number(process.env.WAN_STANDARD_WINDOW_MINUTES) || DEFAULT_WINDOW_MINUTES;
return Math.max(1, Math.min(MAX_WINDOW_MINUTES, Math.floor(raw)));
}
// ─── Response normalisers ─────────────────────────────────────────
function emptyAlarms() {
return { last1h: { critical: 0, major: 0, minor: 0 }, samples: [] };
}
function valueOf(settledResult) {
if (!settledResult) return null;
return settledResult.status === 'fulfilled' ? settledResult.value : null;
}
function recordFailure(errors, scope, settledResult) {
if (settledResult?.status === 'rejected') {
errors.push({ scope, message: settledResult.reason?.message || String(settledResult.reason) });
}
}
/**
* Prisma's aiops/health response carries at least one
* `metrics[]` array with per-time-bucket values. We collapse to
* "the most recent site-level score" plus an optional breakdown
* of contributing sub-scores when present. Defensive on shape:
* an unrecognised payload just returns null.
*/
// The three parse* helpers below are exported for direct unit
// testing (tests/sdwanEnrichment.test.js). They're not part of the
// public composer surface — callers should use `collectSdwanForStore`.
/**
* Extract the last healthscore value from a v2.6 monitor/metrics
* response. Verified live on this tenant on 2026-07-09:
*
* metrics[0].series[0].data[0].datapoints[N].value
*
* The `data[0]` is a WRAPPER (`{statistics: 'max', datapoints: [...]}`)
* whose `datapoints` array holds the actual time-series. We take
* the most recent datapoint. That's what "healthscore right now"
* means from a user's perspective.
*
* Response is already server-side scoped to our site (via
* filter.site request), so we don't need to filter by siteId here —
* but we keep the parameter for backwards-compat with the other
* shapes we still tolerate as fallbacks.
*
* Shapes tolerated (in order of preference):
* 1. v2.6 metrics wrapper: metrics[].series[].data[].datapoints[].value ← LIVE WINNER
* 2. v2.6 per-site rollup: metrics[].sites[].{healthscore | score | data.score}
* 3. Legacy pan.dev: metrics[].series[].data[].value
*/
export function parseHealthscore(resp, siteId = null) {
if (!resp || typeof resp !== 'object') return null;
const metrics = resp.metrics || resp.data || [];
if (!Array.isArray(metrics) || metrics.length === 0) return null;
// ── Shape 1 (LIVE): metrics[].series[].data[].datapoints[].value ──
for (const m of metrics) {
const series0 = Array.isArray(m?.series) ? m.series[0] : null;
if (!series0) continue;
const dataWrapper0 = Array.isArray(series0.data) ? series0.data[0] : null;
if (!dataWrapper0 || !Array.isArray(dataWrapper0.datapoints)) continue;
const lastDp = [...dataWrapper0.datapoints].reverse().find(
(p) => Number.isFinite(Number(p?.value)),
);
if (lastDp) {
return { value: Math.round(Number(lastDp.value)), breakdown: {} };
}
}
// ── Shape 2 (defensive): metrics[].sites[].healthscore / .score ──
for (const m of metrics) {
if (!/health/i.test(m?.name || '') && m !== metrics[0]) continue;
const sites = m?.sites;
if (Array.isArray(sites)) {
const match = sites.find((s) => {
const sid = s?.site_id || s?.id || s?.site;
return sid && String(sid) === String(siteId);
}) || sites[0];
const v = extractHealthscoreValue(match);
if (v != null) return { value: Math.round(v), breakdown: {} };
}
}
// ── Shape 3 (legacy pan.dev): metrics[].series[].data[].value ──
const primary = metrics.find((m) => /health/i.test(m?.name || '')) || metrics[0];
const allSeries = primary?.series || primary?.view?.series || [];
let candidates = Array.isArray(allSeries) ? allSeries : [];
if (siteId) {
const scoped = candidates.filter((s) => {
const v = s?.view?.site || s?.view?.site_id || s?.site_id;
return v && String(v) === String(siteId);
});
if (scoped.length > 0) candidates = scoped;
}
const points = Array.isArray(candidates[0]?.data)
? candidates[0].data
: (Array.isArray(candidates) ? candidates : []);
const lastPoint = [...points].reverse().find((p) => Number.isFinite(Number(p?.value)));
if (lastPoint) {
const value = Math.round(Number(lastPoint.value));
const breakdown = {};
for (const m of metrics) {
if (m === primary) continue;
const s = m?.series || m?.view?.series || [];
const pts = Array.isArray(s[0]?.data) ? s[0].data : (Array.isArray(s) ? s : []);
const last = [...pts].reverse().find((p) => Number.isFinite(Number(p?.value)));
if (last && m?.name) {
breakdown[m.name] = Math.round(Number(last.value));
}
}
return { value, breakdown };
}
return null;
}
/**
* Extract a healthscore value from an unknown-shape object by
* looking for a numeric field with a healthscore-like key. Handles
* both the top-level case (obj.score) and one-level-of-nesting
* (obj.data.score). Falls through to the first plausible numeric
* field within the 0-100 range if no known key matches.
*/
function extractHealthscoreValue(obj) {
if (!obj || typeof obj !== 'object') return null;
const knownKeys = ['healthscore', 'health_score', 'score', 'value', 'health'];
const candidates = [obj, obj.data, obj.metrics, obj.summary].filter(Boolean);
for (const cand of candidates) {
for (const k of knownKeys) {
const v = cand[k];
if (typeof v === 'number' && Number.isFinite(v)) return v;
}
}
// Last-resort: first 0-100 numeric field.
for (const cand of candidates) {
for (const [k, v] of Object.entries(cand)) {
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (k === 'sample_completeness') continue;
if (v >= 0 && v <= 100) return v;
}
}
return null;
}
/**
/**
* Per-metric field-name lookup inside the `data` object of the
* observed live response shape:
*
* metrics[].sites[].paths[].data = {
* sample_completeness: 100,
* rtt_latency: 22.0121 // ← metric-specific value key
* }
*
* Loss and MOS are directional — Prisma returns them as separate
* uplink/downlink keys. Voice quality degrades with loss in EITHER
* direction and MOS reflects the WORSE endpoint's experience, so
* DIRECTIONAL_LQM_KEYS below folds each pair via the appropriate
* combiner (max for loss, min for MOS since low-MOS is worse).
*
* Live shapes verified 2026-07-09 via prismaProbe try-shapes:
* latency → data.rtt_latency (RTT scalar)
* jitter → data.rtt_jitter (assumed via fallback) (RTT scalar)
* loss → data.{downlink,uplink}_pkt_loss_avg (directional)
* mos → data.{downlink,uplink}_mos_{avg,min,max} (directional × 3 stats)
*
* The `_fallback` scan below catches drift by picking the first
* numeric non-completeness key in the data object.
*/
const LQM_DATA_KEYS = {
latency: ['rtt_latency', 'latency', 'latency_ms'],
jitter: ['rtt_jitter', 'jitter', 'jitter_ms', 'one_way_jitter'],
// Loss & MOS: direct-hit keys only. Directional handling folds
// uplink/downlink pairs BEFORE this list is consulted.
loss: ['pkt_loss_pct', 'packet_loss', 'pkt_loss', 'loss', 'loss_pct'],
mos: ['mos', 'mos_score', 'mean_opinion_score'],
};
// Directional metric keys — Prisma reports these as two separate
// numbers (uplink/downlink). extractLqmValue folds them into a
// single value per the `combine` strategy: worst-case in each
// metric's own semantic (max for loss/latency-like where higher is
// worse; min for MOS where lower is worse).
const DIRECTIONAL_LQM_KEYS = {
loss: {
keys: ['downlink_pkt_loss_avg', 'uplink_pkt_loss_avg'],
combine: Math.max, // loss anywhere hurts voice quality
},
mos: {
// Average per direction. `_min` and `_max` variants also exist in
// the response (e.g. downlink_mos_min) — we pick avg because
// it's the typical experience over the window, not a transient
// dip. If we picked _min we'd get spuriously bad readings from
// single-sample glitches.
keys: ['downlink_mos_avg', 'uplink_mos_avg'],
combine: Math.min, // low MOS = worse audio, so take the worse direction
},
};
function extractLqmValue(dataObj, metricKey) {
if (!dataObj || typeof dataObj !== 'object') return null;
// Directional handling FIRST — if a metric has known directional
// split keys, fold them (e.g. loss = max(downlink, uplink)) so
// asymmetric loss patterns don't silently disappear when we pick
// whichever direction was 0.
const dir = DIRECTIONAL_LQM_KEYS[metricKey];
if (dir) {
const nums = dir.keys
.map((k) => dataObj[k])
.filter((v) => typeof v === 'number' && Number.isFinite(v));
if (nums.length > 0) return dir.combine(...nums);
}
const knownKeys = LQM_DATA_KEYS[metricKey] || [];
for (const k of knownKeys) {
if (typeof dataObj[k] === 'number' && Number.isFinite(dataObj[k])) {
return dataObj[k];
}
}
// Defensive fallback: first numeric key that isn't the
// sample-quality marker. This catches Prisma renaming a key
// between tenant versions without silently going to null.
for (const [k, v] of Object.entries(dataObj)) {
if (k === 'sample_completeness') continue;
if (typeof v === 'number' && Number.isFinite(v)) return v;
}
return null;
}
/**
* Build per-path (per-waninterface) rows from the site's waninterface
* config + the four LQM metric responses.
*
* The waninterface config is the SOURCE OF TRUTH for the link list —
* every configured WAN interface at the site becomes a row here, even
* if Prisma has no recent LQM samples for it. LQM metric responses
* are layered in as overlays: last non-null sample per interface for
* each of the four metrics. Missing samples stay `null`.
*
* Prisma's LQM response shape on this tenant (verified live via
* /scripts/prismaProbe.js on 2026-07-09) — NOT the pan.dev shape:
*
* {
* metrics: [{
* name: 'LqmLatencyPointMetric',
* unit: 'milliseconds',
* sites: [{
* site_id: '<site-id>',
* paths: [{
* path_id: '<waninterface-id>',
* remote_site_id: '0',
* data: {
* sample_completeness: 100,
* rtt_latency: 22.0121 // ← key varies by metric
* }
* }, ...]
* }]
* }]
* }
*
* Note: `data` is a SINGLE OBJECT, NOT an array of time-series
* points. Whatever `interval` you request, this shape gives you
* one point per path (the aggregate for the window). The pan.dev
* docs describing series[].data[].value are for a different
* response variant.
*
* `up` prefers runtime operational status when available, then
* waninterface config `admin_up`, then "has LQM samples" as a last
* resort for unknown admin state.
*/
export function buildLinkRows({ wanInterfaces, metricResponses, statusByInterfaceId = null }) {
const statusMap = statusByInterfaceId instanceof Map ? statusByInterfaceId : null;
const rows = new Map();
for (const w of Array.isArray(wanInterfaces) ? wanInterfaces : []) {
const status = statusMap?.get(String(w.id)) || null;
const adminUp = typeof status?.adminUp === 'boolean' ? status.adminUp : w.adminUp;
const operationalUp = typeof status?.operationalUp === 'boolean'
? status.operationalUp
: null;
// Prefer operational; fall back to admin; leave null if both unknown.
let up = operationalUp;
if (up == null) up = adminUp;
rows.set(w.id, {
interfaceId: w.id,
interfaceName: w.name || w.id,
elementId: status?.elementId || null,
networkName: w.wanNetworkName || null,
// usedFor is 'primary' / 'secondary' / 'lte-backup' etc.
transportType: w.usedFor || null,
adminUp,
operationalUp,
up,
latencyMs: null,
jitterMs: null,
lossPct: null,
mos: null,
sampleCompleteness: null,
});
}
const stampFromResponse = (resp, metricKey, apply) => {
if (!resp?.metrics) return;
for (const metric of resp.metrics) {
// Preferred shape (verified live on this tenant):
// metrics[].sites[].paths[].data.<metricKey>
if (Array.isArray(metric?.sites)) {
for (const site of metric.sites) {
for (const p of site?.paths || []) {
const pathId = p?.path_id;
if (!pathId || !rows.has(pathId)) continue;
const value = extractLqmValue(p?.data, metricKey);
if (value != null) apply(rows.get(pathId), value);
if (typeof p?.data?.sample_completeness === 'number') {
rows.get(pathId).sampleCompleteness = p.data.sample_completeness;
}
}
}
continue;
}
// Fallback shape (pan.dev-documented, may exist on other
// tenants or API versions): metrics[].series[].data[].value
// keyed by view.path / view.waninterface.
const series = metric?.series || metric?.view?.series || [];
for (const s of series) {
const wiId = s?.view?.path
|| s?.view?.waninterface
|| s?.view?.wan_interface_id
|| s?.view?.interface_id;
if (!wiId || !rows.has(wiId)) continue;
const points = Array.isArray(s?.data) ? s.data : [];
const last = [...points].reverse().find((p) => p?.value !== null && p?.value !== undefined);
if (!last) continue;
apply(rows.get(wiId), Number(last.value));
}
}
};
stampFromResponse(metricResponses?.latency, 'latency', (row, v) => { row.latencyMs = round(v, 1); });
stampFromResponse(metricResponses?.jitter, 'jitter', (row, v) => { row.jitterMs = round(v, 1); });
stampFromResponse(metricResponses?.loss, 'loss', (row, v) => { row.lossPct = round(v, 2); });
stampFromResponse(metricResponses?.mos, 'mos', (row, v) => { row.mos = round(v, 2); });
// Derive up-state when admin_up is not exposed by the waninterface
// config API. If we have ANY LQM sample for a path, the path is
// provably carrying traffic — that's a stronger signal than admin
// state anyway. We only upgrade unknown (null) rows here; explicit
// adminUp=false (admin-disabled) stays down.
for (const row of rows.values()) {
if (row.up === null || row.up === undefined) {
const hasSample = row.latencyMs != null
|| row.jitterMs != null
|| row.lossPct != null
|| row.mos != null;
if (hasSample) row.up = true;
}
}
return [...rows.values()];
}
export function parseAlarms(resp, siteId = null) {
if (!resp || typeof resp !== 'object') return emptyAlarms();
const items = resp.items || resp.data || resp.alarms || [];
if (!Array.isArray(items)) return emptyAlarms();
const counts = { critical: 0, major: 0, minor: 0 };
const samples = [];
let droppedForSiteMismatch = 0;
for (const a of items) {
// Events/query returns both open AND cleared alarms in the same
// response — filter out already-resolved ones so "N alarms in
// the last hour" reflects still-active issues.
if (a?.cleared === true) continue;
// Defensive site scoping: if the tenant's events/query silently
// ignores our `query.site` filter (schema quirk we hit before),
// we'd otherwise report tenant-wide alarms as this-site alarms
// — a badly misleading number. Match locally by site_id when a
// siteId is provided AND the event carries one. Events with NO
// site_id are kept as-is (e.g. tenant-scoped events that legitimately
// don't belong to a specific site).
if (siteId) {
const eventSiteId = a?.site_id || a?.entity_ref;
if (eventSiteId && String(eventSiteId) !== String(siteId)) {
droppedForSiteMismatch += 1;
continue;
}
}
const sev = String(a?.severity || '').toLowerCase();
if (sev === 'critical' || sev === 'major' || sev === 'minor') {
counts[sev] += 1;
}
// `info` on Prisma events is often a NESTED OBJECT (e.g. `{ vpn_link_id: '...' }`),
// not a human-readable string — flatten defensively so the
// renderer doesn't dump `[object Object]` into chat.
const infoText = typeof a?.info === 'string'
? a.info
: (a?.info ? JSON.stringify(a.info).slice(0, 200) : '');
const vpnLinkIds = extractVpnLinkIdsFromAlarmInfo(a?.info);
samples.push({
code: a?.code || a?.category || a?.type || 'UNKNOWN',
message: infoText || a?.description || a?.message || '',
severity: sev || 'unknown',
ts: a?.time || a?.timestamp || a?._created_on_utc || null,
vpnLinkIds,
});
}
samples.sort((a, b) => {
const ta = coerceAlarmTsMs(a.ts) ?? 0;
const tb = coerceAlarmTsMs(b.ts) ?? 0;
return tb - ta;
});
return {
last1h: counts,
// Keep enough samples for accurate code rollups; the renderer
// shows the newest 5 individually with timestamps.
samples: samples.slice(0, 50),
// Diagnostic surface for the caller — lets sdwanEnrichment log
// "raw N events → K after site filter" so we can see whether
// Prisma's server-side site filter is working or being ignored.
_diag: {
rawEventCount: items.length,
droppedForSiteMismatch,
},
};
}
function round(n, decimals) {
if (!Number.isFinite(n)) return null;
const p = Math.pow(10, decimals);
return Math.round(n * p) / p;
}
function isPathAttributionEnabled() {
const raw = String(process.env.PRISMA_VOICE_PATH_ATTRIBUTION || '').toLowerCase().trim();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
/** Path-type DPI breakout — off by default (extra Prisma calls / 429 risk). */
function isPathTypeBreakoutEnabled() {
const raw = String(process.env.PRISMA_VOICE_PATH_TYPE_BREAKOUT || '').toLowerCase().trim();
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
}
async function collectTunnelsForSite(siteId) {
let siteNames = null;
try {
const sites = await getAllSites();
siteNames = new Map(
(sites || []).filter((s) => s?.id).map((s) => [String(s.id), s.name || String(s.id)]),
);
} catch {
siteNames = null;
}
return getVpnLinksForSite(siteId, {
// Status GETs are capped inside getVpnLinksForSite; keep enabled
// for the small filtered set so we get real up/down when possible.
fetchStatus: true,
statusConcurrency: 2,
siteNames,
});
}
/**
* Build byPathType map from parallel getAppMetricsByPathType results.
* Keys are path_type strings (VPN, DirectInternet, …).
*/
export function buildAppAudioByPathType({ lossByType, jitterByType, bandwidthByType }) {
const keys = new Set([
...Object.keys(lossByType || {}),
...Object.keys(jitterByType || {}),
...Object.keys(bandwidthByType || {}),
...VOICE_PATH_TYPE_SUBSET,
]);
keys.delete('__all__');
const out = {};
for (const pt of keys) {
const loss = summarizeAppSeries(lossByType?.[pt], 'AppPerfUDPAudioPacketLoss');
const jitter = summarizeAppSeries(jitterByType?.[pt], 'AppPerfUDPAudioJitter');
const bandwidth = summarizeAppSeries(bandwidthByType?.[pt], 'AppPerfUDPAudioBandwidth');
const any = [loss, jitter, bandwidth].some((s) => s && s.validSamples > 0);
if (!any) continue;
out[pt] = { loss, jitter, bandwidth, mos: null };
}
return out;
}
/**
* Mark tunnels that appear in recent alarm vpn_link_ids.
*/
export function annotateTunnelsWithAlarms(tunnels, alarms) {
const list = Array.isArray(tunnels) ? tunnels : [];
const alarmed = new Set();
for (const s of alarms?.samples || []) {
for (const id of s.vpnLinkIds || []) alarmed.add(String(id));
}
return list.map((t) => ({
...t,
recentAlarm: alarmed.has(String(t.id)),
}));
}
/**
* Phase 2: per-waninterface app DPI. One loss+jitter fetch per path
* (bandwidth optional to limit fan-out). Returns Map-like object
* keyed by interface id, or null if nothing useful came back.
*/
async function collectAppAudioByPath(siteId, appId, waninterfaceIds, windowMinutes, errors) {
const byPath = {};
const ids = (waninterfaceIds || []).map(String).filter(Boolean);
// Cap fan-out — stores with many circuits would otherwise explode
// request count (2 metrics × N paths).
const capped = ids.slice(0, 4);
await Promise.all(capped.map(async (pathId) => {
try {
const [lossRaw, jitterRaw] = await Promise.all([
getAppMetric(siteId, appId, 'loss', {
windowMinutes,
pathIds: [pathId],
pathTypes: [...VOICE_PATH_TYPE_SUBSET],
}),
getAppMetric(siteId, appId, 'jitter', {
windowMinutes,
pathIds: [pathId],
pathTypes: [...VOICE_PATH_TYPE_SUBSET],
}),
]);
const loss = summarizeAppSeries(lossRaw, 'AppPerfUDPAudioPacketLoss');
const jitter = summarizeAppSeries(jitterRaw, 'AppPerfUDPAudioJitter');
if ((loss?.validSamples || 0) + (jitter?.validSamples || 0) === 0) return;
byPath[pathId] = { loss, jitter, mos: null, bandwidth: null };
} catch (err) {
errors.push({
scope: `app.voice.path.${pathId}`,
message: err?.message || String(err),
});
}
}));
return Object.keys(byPath).length > 0 ? byPath : null;
}