Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
544 lines
22 KiB
JavaScript
544 lines
22 KiB
JavaScript
// tests/paloalto.metrics.test.js
|
||
//
|
||
// Regression guards for the Prisma SD-WAN metric-body shapes. Every
|
||
// bug in this file was found the hard way — live tenant returned
|
||
// SCHEMA_CHECK_FAIL 400s that took a round trip to pin down. Keep
|
||
// these tests thin, focused, and worded so a future refactor can
|
||
// see exactly WHICH schema constraint the assertion is protecting.
|
||
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import http from 'node:http';
|
||
|
||
import {
|
||
getHealthscore,
|
||
getLqmMetric,
|
||
getAppMetric,
|
||
getAlarms,
|
||
APP_METRIC_NAMES,
|
||
} from '../integrations/paloalto/metrics.js';
|
||
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
||
|
||
async function makeFakePrisma(routes) {
|
||
const server = http.createServer((req, res) => {
|
||
let body = '';
|
||
req.on('data', (c) => (body += c));
|
||
req.on('end', () => {
|
||
if (req.url === '/oauth2/access_token') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ access_token: 't', expires_in: 900 }));
|
||
return;
|
||
}
|
||
if (req.url === '/sdwan/v2.1/api/profile' && req.method === 'GET') {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ id: 'stub-profile' }));
|
||
return;
|
||
}
|
||
const h = routes[`${req.method} ${req.url}`];
|
||
if (h) {
|
||
const parsed = body ? JSON.parse(body) : null;
|
||
const r = h({ req, body: parsed });
|
||
res.writeHead(r.status || 200, { 'Content-Type': 'application/json', ...(r.headers || {}) });
|
||
res.end(JSON.stringify(r.body || {}));
|
||
return;
|
||
}
|
||
res.writeHead(404);
|
||
res.end();
|
||
});
|
||
});
|
||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||
const { port } = server.address();
|
||
return { baseUrl: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) };
|
||
}
|
||
|
||
function setSaseEnv(baseUrl) {
|
||
process.env.PRISMA_AUTH_MODE = 'sase';
|
||
process.env.PRISMA_SASE_BASE_URL = baseUrl;
|
||
process.env.PRISMA_AUTH_URL = `${baseUrl}/oauth2/access_token`;
|
||
process.env.PRISMA_CLIENT_ID = 'id';
|
||
process.env.PRISMA_CLIENT_SECRET = 'secret';
|
||
process.env.PRISMA_TSG_ID = 'tsg';
|
||
}
|
||
function clearEnv() {
|
||
['PRISMA_AUTH_MODE','PRISMA_SASE_BASE_URL','PRISMA_AUTH_URL',
|
||
'PRISMA_CLIENT_ID','PRISMA_CLIENT_SECRET','PRISMA_TSG_ID',
|
||
'PRISMA_LEGACY_BASE_URL','PRISMA_EMAIL','PRISMA_PASSWORD']
|
||
.forEach((k) => delete process.env[k]);
|
||
}
|
||
|
||
// ─── Body-shape regressions ─────────────────────────────────────────
|
||
|
||
test('getHealthscore: uses v2.6 unified metrics endpoint (v2.0 aiops/health is a dead end on this tenant)', async () => {
|
||
_resetPrismaAuthCache();
|
||
const validIntervals = new Set(['10sec', '1min', '5min', '1hour', '1day']);
|
||
let seenBody = null;
|
||
let seenUrl = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ req, body }) => {
|
||
seenBody = body;
|
||
seenUrl = req.url;
|
||
return { body: { metrics: [] } };
|
||
},
|
||
// Trip-wire on the old v2.0 aiops/health endpoint. Fails loudly
|
||
// if a future refactor accidentally regresses to it.
|
||
'POST /sdwan/monitor/v2.0/api/monitor/aiops/health': () => {
|
||
throw new Error('REGRESSION: healthscore should hit v2.6 /monitor/metrics, not v2.0 /aiops/health');
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getHealthscore('site-A');
|
||
// v2.6 endpoint is the ONLY healthscore endpoint that returns 200
|
||
// on the observed tenant (verified 2026-07-09 via prismaProbe
|
||
// try-shapes; both v2.0 aiops/health and v2.1 aggregates dead-end
|
||
// on tenant-enforced schema errors).
|
||
assert.equal(seenUrl, '/sdwan/monitor/v2.6/api/monitor/metrics');
|
||
assert.deepEqual(seenBody.filter, { site: ['site-A'] },
|
||
'v2.6 accepts filter.site as an ARRAY (unlike v2.0)');
|
||
assert.deepEqual(seenBody.view, {},
|
||
'v2.6 accepts view as an empty object (unlike v2.0 aiops/health which wants a string enum)');
|
||
assert.equal(seenBody.metrics[0].name, 'Healthscore');
|
||
assert.deepEqual(seenBody.metrics[0].statistics, ['max']);
|
||
assert.equal(seenBody.metrics[0].unit, 'gauge');
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
|
||
'no end_time — endpoint interprets window as [start_time, now)',
|
||
);
|
||
assert.ok(validIntervals.has(seenBody.interval),
|
||
`interval must be one of ${[...validIntervals].join(', ')} — got "${seenBody.interval}"`);
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getLqmMetric: uses dedicated lqm_point_metrics endpoint (not sys_point_metrics)', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
let seenUrl = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ req, body }) => {
|
||
seenBody = body;
|
||
seenUrl = req.url;
|
||
return { body: { metrics: [] } };
|
||
},
|
||
// Regression trip-wire: sys_point_metrics is the CPU/Memory/Disk
|
||
// endpoint and rejects filter.wan_interfaces. If we regress to it,
|
||
// fail the test with a specific message instead of a generic 404.
|
||
'POST /sdwan/monitor/v2.0/api/monitor/sys_point_metrics': () => {
|
||
throw new Error('REGRESSION: getLqmMetric should use lqm_point_metrics, not sys_point_metrics');
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getLqmMetric('site-A', ['wi-1', 'wi-2'], 'latency');
|
||
assert.equal(seenUrl, '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics');
|
||
// Live 400 regressions + LIVEcommunity #1235108 working example:
|
||
// - `end_time` is REJECTED ("not defined in the schema"). The
|
||
// endpoint interprets the window as [start_time, now).
|
||
// - filter.site must be an ARRAY on lqm_point_metrics — 400 was
|
||
// "$.filter.site: string found, array expected". OPPOSITE of
|
||
// sys_point_metrics, which wants a string.
|
||
// - filter.path is the KEY THAT WORKS. Both `wan_interfaces`
|
||
// and `waninterface` were rejected as "not defined in the
|
||
// schema". The LIVEcommunity example for LqmLatency uses:
|
||
// filter: { site: [id], path: [wi_id] }
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(seenBody, 'end_time'), false,
|
||
'lqm_point_metrics rejects `end_time` — do not send',
|
||
);
|
||
assert.ok(Array.isArray(seenBody.filter.site),
|
||
'lqm_point_metrics requires filter.site as an ARRAY (opposite of sys_point_metrics)');
|
||
assert.deepEqual(seenBody.filter.site, ['site-A']);
|
||
assert.deepEqual(seenBody.filter.path, ['wi-1', 'wi-2'],
|
||
'filter key is `path` — NOT wan_interfaces (rejected), NOT waninterface (sys_point_metrics variant)');
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(seenBody.filter, 'wan_interfaces'),
|
||
false,
|
||
'do NOT send `wan_interfaces` — rejected as "not defined in the schema"',
|
||
);
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(seenBody.filter, 'waninterface'),
|
||
false,
|
||
'do NOT send `waninterface` — that key belongs to sys_point_metrics',
|
||
);
|
||
assert.equal(
|
||
Object.prototype.hasOwnProperty.call(seenBody.filter, 'elements'),
|
||
false,
|
||
'do NOT send `elements` — that was a sys_point_metrics quirk',
|
||
);
|
||
assert.equal(seenBody.metrics[0].name, 'LqmLatencyPointMetric');
|
||
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getLqmMetric: correct PointMetric name + unit per key (jitter/loss/mos)', async () => {
|
||
_resetPrismaAuthCache();
|
||
const seen = [];
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': ({ body }) => {
|
||
seen.push(body.metrics[0]);
|
||
return { body: { metrics: [] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getLqmMetric('site-A', ['wi-1'], 'jitter');
|
||
await getLqmMetric('site-A', ['wi-1'], 'loss');
|
||
await getLqmMetric('site-A', ['wi-1'], 'mos');
|
||
assert.deepEqual(seen.map((m) => [m.name, m.unit]), [
|
||
// Regression: units are CASE-SENSITIVE per Prisma's schema
|
||
// validator. `Percentage` (capital P) was rejected on this
|
||
// tenant with 400 METRIC_UNIT_NOT_SUPPORTED — the correct
|
||
// spelling is lowercase `percentage`. Verified 2026-07-09 via
|
||
// `try-shapes lqm-loss`.
|
||
['LqmJitterPointMetric', 'milliseconds'],
|
||
['LqmPktLossPointMetric', 'percentage'],
|
||
['LqmMosPointMetric', 'count'],
|
||
]);
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getLqmMetric: empty waninterfaceIds → skip (no HTTP call)', async () => {
|
||
_resetPrismaAuthCache();
|
||
let called = false;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics': () => {
|
||
called = true;
|
||
return { body: { metrics: [] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
assert.equal(await getLqmMetric('site-A', [], 'latency'), null);
|
||
assert.equal(called, false);
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
// ─── 429 retry ──────────────────────────────────────────────────────
|
||
|
||
test('client: 429 → retry with exponential backoff, then succeed on second attempt', async () => {
|
||
_resetPrismaAuthCache();
|
||
let attempt = 0;
|
||
const startTs = Date.now();
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
|
||
attempt += 1;
|
||
if (attempt === 1) {
|
||
return { status: 429, body: { error: 'too many' } };
|
||
}
|
||
return { body: { metrics: [{ name: 'Healthscore', series: [{ data: [{ value: 95 }] }] }] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
const resp = await getHealthscore('site-A');
|
||
const elapsed = Date.now() - startTs;
|
||
assert.equal(attempt, 2, 'should have retried exactly once');
|
||
assert.ok(resp, 'second attempt should return the payload');
|
||
assert.equal(resp.metrics[0].name, 'Healthscore');
|
||
assert.ok(elapsed >= 1500, `should have waited ~1.5s (base backoff) — waited ${elapsed}ms`);
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('client: 429 → honors Retry-After header when present', async () => {
|
||
_resetPrismaAuthCache();
|
||
let attempt = 0;
|
||
const startTs = Date.now();
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
|
||
attempt += 1;
|
||
if (attempt === 1) {
|
||
// Retry-After of 2 seconds — should be honored over the
|
||
// client's default 1.5s exponential backoff.
|
||
return { status: 429, headers: { 'Retry-After': '2' }, body: {} };
|
||
}
|
||
return { body: { metrics: [] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getHealthscore('site-A');
|
||
const elapsed = Date.now() - startTs;
|
||
assert.ok(elapsed >= 2000, `should have waited ~2s per Retry-After — waited ${elapsed}ms`);
|
||
assert.equal(attempt, 2);
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('client: 429 → gives up after MAX_429_RETRIES and returns null', async () => {
|
||
_resetPrismaAuthCache();
|
||
let attempt = 0;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': () => {
|
||
attempt += 1;
|
||
return { status: 429, body: {} };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
const resp = await getHealthscore('site-A');
|
||
assert.equal(resp, null, 'metric wrapper absorbs the exhausted-retry error');
|
||
// 1 original + 2 retries = 3 total attempts.
|
||
assert.equal(attempt, 3, 'should attempt exactly MAX_429_RETRIES + 1 times');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
// ─── Alarms body shape (events/query endpoint) ──────────────────────
|
||
|
||
test('getAlarms: uses events/query endpoint (not /monitor/alarms which 404s)', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
let seenUrl = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/v3.7/api/events/query': ({ req, body }) => {
|
||
seenBody = body;
|
||
seenUrl = req.url;
|
||
return { body: { items: [] } };
|
||
},
|
||
// Regression trip-wire: if we accidentally regress to the old
|
||
// /monitor/alarms path, this handler catches it and fails the
|
||
// test with a specific message instead of a generic 404.
|
||
'POST /sdwan/monitor/v2.0/api/monitor/alarms': () => {
|
||
throw new Error('REGRESSION: getAlarms should use /events/query, not /monitor/alarms');
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
const resp = await getAlarms('site-A');
|
||
assert.ok(resp, 'events/query response returned');
|
||
assert.equal(seenUrl, '/sdwan/v3.7/api/events/query');
|
||
assert.deepEqual(seenBody.query.site, ['site-A']);
|
||
assert.deepEqual(seenBody.query.type, ['alarm'],
|
||
'must filter for type=alarm — informational events would inflate the count');
|
||
assert.deepEqual(seenBody.severity, ['critical', 'major', 'minor']);
|
||
assert.ok(seenBody.limit && typeof seenBody.limit === 'object',
|
||
'limit is an OBJECT on events/query (count + sort_on + sort_order), not an int');
|
||
assert.equal(seenBody.limit.sort_on, 'time');
|
||
assert.equal(seenBody.limit.sort_order, 'descending');
|
||
assert.ok(seenBody.start_time, 'start_time populated');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
// ─── Per-application metrics — body-shape regressions ──────────────
|
||
//
|
||
// Ground truth for these assertions comes from two HAR captures
|
||
// against a live tenant (site CG00127), both taken 2026-07-09:
|
||
// - /Users/McqueenJ/Downloads/rtp-base-metricCG00127.har
|
||
// (app id 15932000365560116)
|
||
// - /Users/McqueenJ/Downloads/webex-base-metricCG00127.har
|
||
// (app id 1708539371717015196 → Webex_Calling_RTP)
|
||
//
|
||
// The metric names, units, and filter shapes are IDENTICAL across
|
||
// voice apps — only filter.app differs. That's why the check code is
|
||
// app-agnostic and these tests use `<APP_ID>` as a filler rather than
|
||
// pinning one app.
|
||
//
|
||
// Every non-obvious body key here is one Prisma silently rejects if
|
||
// wrong. Do NOT relax these without a fresh HAR — see
|
||
// scripts/prismaProbe.js `try-shapes app-audio-*` to re-discover.
|
||
|
||
test('getAppMetric(mos): AppAudioMos requires direction="Ingress" and OMITS filter.path_type', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: { metrics: [{ series: [{ name: 'AppAudioMos', unit: 'count', data: [{ datapoints: [] }] }] }] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getAppMetric('site-CG127', '15932000365560116', 'mos', 60);
|
||
assert.ok(seenBody, 'request reached fake server');
|
||
|
||
// The winning metric name from the HAR — anything else 400s
|
||
// with METRIC_NOT_SUPPORTED, so pin it.
|
||
assert.deepEqual(seenBody.metrics, [{
|
||
name: 'AppAudioMos', statistics: ['average'], unit: 'count',
|
||
}]);
|
||
|
||
// AppAudioMos is the ONE audio metric that does NOT take
|
||
// filter.path_type. If we add it, the schema check flips from
|
||
// "no matching metric" to "unsupported filter" — different bug
|
||
// symptom, but same net "returns nothing" outcome.
|
||
assert.ok(!('path_type' in seenBody.filter),
|
||
'AppAudioMos must NOT include filter.path_type (unlike AppPerfUDP*)');
|
||
|
||
// Direction is REQUIRED. Without it Prisma returns 400.
|
||
assert.equal(seenBody.filter.direction, 'Ingress',
|
||
'AppAudioMos requires filter.direction="Ingress" (audio quality is what you RECEIVE)');
|
||
|
||
assert.deepEqual(seenBody.filter.site, ['site-CG127']);
|
||
assert.deepEqual(seenBody.filter.app, ['15932000365560116'],
|
||
'app id must be sent as a STRING inside an ARRAY, not a bare int or a bare string');
|
||
|
||
// Empty view {} — the single-metric per-app queries use this
|
||
// rather than {summary:true} or {individual:'app'}.
|
||
assert.deepEqual(seenBody.view, {});
|
||
|
||
assert.ok(seenBody.start_time && seenBody.end_time,
|
||
'start_time + end_time both required — v2.6 monitor/metrics rejects without them');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getAppMetric(loss): AppPerfUDPAudioPacketLoss includes ALL 5 path_types', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: { metrics: [{ series: [{ name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', data: [{ datapoints: [] }] }] }] } };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getAppMetric('site-CG127', '15932000365560116', 'loss', 60);
|
||
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioPacketLoss');
|
||
// Case-sensitive — 'Percentage' returned METRIC_UNIT_NOT_SUPPORTED
|
||
// in earlier probe runs. Lowercase-p is a live constraint.
|
||
assert.equal(seenBody.metrics[0].unit, 'percentage');
|
||
assert.equal(seenBody.filter.direction, 'Ingress');
|
||
|
||
const paths = seenBody.filter.path_type;
|
||
assert.ok(Array.isArray(paths));
|
||
for (const t of ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink']) {
|
||
assert.ok(paths.includes(t), `path_type must include "${t}" (Prisma UI passes all 5)`);
|
||
}
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getAppMetric(jitter): AppPerfUDPAudioJitter uses milliseconds unit', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: {} };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getAppMetric('site-CG127', '15932000365560116', 'jitter', 60);
|
||
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioJitter');
|
||
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
|
||
assert.equal(seenBody.filter.direction, 'Ingress');
|
||
assert.ok(Array.isArray(seenBody.filter.path_type));
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getAppMetric(bandwidth): AppPerfUDPAudioBandwidth has NO direction (bidirectional aggregate)', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: {} };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getAppMetric('site-CG127', '15932000365560116', 'bandwidth', 60);
|
||
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioBandwidth');
|
||
assert.equal(seenBody.metrics[0].unit, 'Mbps');
|
||
assert.ok(!('direction' in seenBody.filter),
|
||
'bandwidth is a bidirectional aggregate — Prisma rejects filter.direction on this metric');
|
||
assert.ok(Array.isArray(seenBody.filter.path_type));
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getAppMetric: no-op with clean null return when siteId or appId missing', async () => {
|
||
// No network setup — if the wrapper tried to make a call, the
|
||
// client would fail to resolve the SASE URL and throw. A silent
|
||
// null keeps the enrichment pipeline resilient when a caller
|
||
// forgets to configure PRISMA_APP_ID_VOICE (or the legacy
|
||
// PRISMA_APP_ID_RTP_BASE).
|
||
const r1 = await getAppMetric(null, '1708539371717015196', 'mos');
|
||
const r2 = await getAppMetric('site-X', null, 'mos');
|
||
assert.equal(r1, null);
|
||
assert.equal(r2, null);
|
||
});
|
||
|
||
test('getAppMetric: uses 5min interval for 24h window (NOT 1day — preserves 288-point time series)', async () => {
|
||
// The whole point of client-side aggregation is that we can
|
||
// extract WORST-window numbers from the series. If we ask for
|
||
// interval=1day on a 1440min window, Prisma returns 1-2
|
||
// aggregated datapoints and the min/max collapse to the same
|
||
// value as the avg — completely defeating the purpose. HAR
|
||
// confirms the Prisma UI uses 5min for the 24h view.
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: {} };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
await getAppMetric('site-CG127', '15932000365560116', 'mos', 1440);
|
||
assert.equal(seenBody.interval, '5min',
|
||
'1440-min window MUST use 5min interval (yields ~288 pts, matches HAR). ' +
|
||
'Falling back to 1day would collapse the series to 1-2 aggregated pts.');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('getAppMetric: uses 1hour interval for 7d window (168 points — the new default)', async () => {
|
||
_resetPrismaAuthCache();
|
||
let seenBody = null;
|
||
const fake = await makeFakePrisma({
|
||
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||
seenBody = body;
|
||
return { body: {} };
|
||
},
|
||
});
|
||
setSaseEnv(fake.baseUrl);
|
||
try {
|
||
// 7 days = 10080 min. This is now the default window (widened
|
||
// from 24h so sporadic Webex Calling stores get enough per-app
|
||
// samples). 5min buckets would be 2016 points per metric × 4
|
||
// metrics = 8064 numbers per site request. Snap to 1hour (168
|
||
// pts) to keep payloads reasonable while still catching
|
||
// worst-hour degradation.
|
||
await getAppMetric('site-CG127', '1708539371717015196', 'mos', 10080);
|
||
assert.equal(seenBody.interval, '1hour');
|
||
} finally {
|
||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||
}
|
||
});
|
||
|
||
test('APP_METRIC_NAMES: registry exposes the HAR-confirmed winners', () => {
|
||
// Cheap "someone renamed the constant" check. The four keys are
|
||
// the API surface that sdwanEnrichment.js iterates over — a rename
|
||
// would silently drop that metric from the enrichment payload
|
||
// (parseAppSeries would return null and the check would go to
|
||
// skipped rather than error).
|
||
assert.equal(APP_METRIC_NAMES.mos.name, 'AppAudioMos');
|
||
assert.equal(APP_METRIC_NAMES.loss.name, 'AppPerfUDPAudioPacketLoss');
|
||
assert.equal(APP_METRIC_NAMES.jitter.name, 'AppPerfUDPAudioJitter');
|
||
assert.equal(APP_METRIC_NAMES.bandwidth.name, 'AppPerfUDPAudioBandwidth');
|
||
assert.equal(APP_METRIC_NAMES.mos.lowIsBad, true,
|
||
'MOS grades against worst-window minimum — lowIsBad must be true');
|
||
});
|