collabSupport/integrations/paloalto/metrics.js
jmcqueen 9d0dbb071e Add per-app DPI voice-quality checks + widen WAN window to 7d
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>
2026-07-09 13:54:15 -04:00

503 lines
21 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/integrations/paloalto/metrics.js
//
// Thin POST wrappers around Prisma SD-WAN's monitor v2.0 metrics
// API. All endpoints are POST — Prisma's monitor API always accepts
// a JSON body with `start_time` + `interval` + `metrics` + `filter`,
// even for what would idiomatically be a GET on other systems. See
// https://pan.dev/sdwan/api/metrics/ for the endpoint catalog.
//
// Endpoint / body-shape notes verified against a live SASE tenant.
// This section is the historical record of what broke and how the
// tenant's schema differs from pan.dev — read before changing any
// wrapper, because "helpful cleanup" here almost always regresses.
//
// - LQM point metrics live at a DEDICATED endpoint:
// POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics
// NOT `sys_point_metrics` — that endpoint is for system metrics
// (CPU, Memory, Disk) and its schema rejects `filter.waninterface`
// ("is not defined in the schema"). LQM has its own endpoint.
// Metric names on the LQM endpoint carry the `PointMetric`
// suffix: `LqmLatencyPointMetric`, `LqmJitterPointMetric`,
// `LqmPktLossPointMetric` (Pkt, not Packet), `LqmMosPointMetric`.
//
// - LQM body shape on this tenant:
// `end_time` → rejected as "not defined in the schema"
// (endpoint interprets window as [start_time, now))
// Filter shape:
// `filter.site` — ARRAY of strings. Live 400 was
// "$.filter.site: string found, array expected".
// This is OPPOSITE to sys_point_metrics,
// which wants a plain string.
// `filter.path` — array. This is the KEY THAT WORKS on
// lqm_point_metrics for scoping to
// specific circuits — even though the
// circuit ids we pass are waninterface
// ids from /sites/{id}/waninterfaces.
// The LIVEcommunity working example uses
// this exact shape:
// filter: { site: [id], path: [wi_id] }
// Both `wan_interfaces` (plural + underscore)
// and `waninterface` (sys_point_metrics
// variant) are rejected as "not defined in
// the schema" on this tenant.
// No `elements` filter (that's a sys_point_metrics quirk).
//
// - `metrics[].statistics` is a PLURAL array (`["average"]` /
// `["max"]`), not the singular `statistic`.
//
// - `metrics[].unit` is case-sensitive: `milliseconds`, `Percentage`,
// `gauge`, `count`.
//
// - Healthscore on this tenant: v2.0 aiops/health is a DEAD END.
// Confirmed via prismaProbe try-shapes:
// v2.0 `metrics: [...]` → rejected ("not defined")
// v2.0 with `end_time` removed → 400 "end_time is required"
// v2.0 with `end_time` added + → same "$.metrics: not defined"
// v2.1 aiops/aggregates → requires app_id + aggregates
// v2.6 unified /monitor/metrics → ✅ 200 OK (only winner)
// We use `POST /sdwan/monitor/v2.6/api/monitor/metrics` with the
// standard `metrics: [{Healthscore}]` + `filter: {site: [id]}`.
// This endpoint accepts filter.site as an ARRAY and returns a
// per-site healthscore in the response.
//
// - `interval` must be one of Prisma's canonical bucket sizes:
// `10sec`, `1min`, `5min`, `1hour`, `1day`. `15min` etc.
// yield HTTP 400 with SCHEMA_CHECK_FAIL on `$.interval`.
//
// - Alarms live at `POST /sdwan/v3.7/api/events/query`, NOT
// `/sdwan/monitor/v2.0/api/monitor/alarms` (that endpoint
// 404s "ROUTE_NOT_FOUND" on the observed tenant).
//
// All wrappers return `null` on any failure and log the error with
// a preview of the response body so shape drift is diagnosable
// without cranking LOG_LEVEL=debug. Higher layers absorb per-metric
// failures via `Promise.allSettled`.
import { paloAltoAxios } from './client.js';
import { logger } from '../../utils/logger.js';
// Prisma metric-name catalog for the LQM (Link Quality Monitoring)
// bucket. Short JS keys → { name, unit } for the API body.
//
// Names carry the `PointMetric` suffix because this maps to the
// dedicated `lqm_point_metrics` endpoint (NOT `sys_point_metrics`).
// The unit strings are verified via 400 SCHEMA_CHECK_FAIL responses
// when they drift and are case-sensitive:
// - `milliseconds` (lowercase) → latency, jitter
// - `percentage` (lowercase — NOT `Percentage`, verified via
// prismaProbe try-shapes lqm-loss on 2026-07-09
// which returned 400 METRIC_UNIT_NOT_SUPPORTED for
// every capitalized/alternate variant) → loss
// - `count` → mos
// If Prisma ever rejects a unit here, re-run the matrix probe:
// `node scripts/prismaProbe.js try-shapes lqm-loss <site> <wis>`.
export const LQM_METRIC_NAMES = Object.freeze({
latency: { name: 'LqmLatencyPointMetric', unit: 'milliseconds' },
jitter: { name: 'LqmJitterPointMetric', unit: 'milliseconds' },
loss: { name: 'LqmPktLossPointMetric', unit: 'percentage' },
mos: { name: 'LqmMosPointMetric', unit: 'count' },
});
// ──────────────────────────────────────────────
// URL helpers — SASE and legacy have different prefixes
// ──────────────────────────────────────────────
function isSase() {
return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase';
}
function monitorPath(endpoint) {
return isSase()
? `/sdwan/monitor/v2.0/api/monitor/${endpoint}`
: `/v2.0/api/monitor/${endpoint}`;
}
// v2.6 unified monitor/metrics endpoint. The observed tenant only
// serves healthscore correctly here — v2.0 aiops/health is a dead
// end (see the top-of-file schema notes).
function metricsV26Path() {
return isSase()
? '/sdwan/monitor/v2.6/api/monitor/metrics'
: '/v2.6/api/monitor/metrics';
}
// Events endpoint (SASE v3.7 is the current top of the docs). Used
// for alarm retrieval — the older `/monitor/alarms` path 404s on
// the SASE surface.
function eventsQueryPath() {
return isSase()
? '/sdwan/v3.7/api/events/query'
: '/v3.7/api/events/query';
}
// ──────────────────────────────────────────────
// Time-window helpers
// ──────────────────────────────────────────────
function windowStart(minutes) {
return new Date(Date.now() - minutes * 60 * 1000).toISOString();
}
function nowIso() {
return new Date().toISOString();
}
// Prisma's valid `interval` enum (verified via HTTP 400 error message).
// `15min`, `30min` etc. are NOT accepted — snap up to the next valid
// bucket (1hour) rather than silently down-sampling.
//
// Used for LQM and healthscore. Those endpoints return a SINGLE
// aggregated value per path/site (regardless of interval), so the
// interval only affects Prisma's internal downsampling — coarser is
// fine, and 1day for a 24h window is the cheapest ask.
function pickInterval(minutes) {
if (minutes <= 1) return '1min';
if (minutes <= 5) return '5min';
if (minutes < 60) return '5min'; // 15-minute default now snaps to 5min buckets
if (minutes <= 60) return '1hour';
return '1day';
}
// App-metric variant. UNLIKE LQM, per-app metrics return the FULL
// datapoint time series (metrics[].series[].data[].datapoints[]) and
// downstream code aggregates {avg, min, max, p95} client-side. That
// only works if the interval is fine enough to capture the transient
// windows we're trying to surface. HAR (2026-07-09) confirms the
// Prisma UI queries `5min` for a 24h window (yielding 288 datapoints
// per metric) — matching that keeps our worst-window statistics
// meaningful. Falling back to `1day` (as `pickInterval` does) would
// collapse the whole series into 1-2 aggregated points and hide the
// exact degradation these checks exist to catch.
function pickAppMetricInterval(minutes) {
if (minutes <= 1) return '1min';
if (minutes <= 5) return '5min';
if (minutes <= 60) return '5min'; // 12 points per hour
if (minutes <= 1440) return '5min'; // 24h → 288 points (HAR-confirmed)
// Beyond 24h, cap the point count with hourly buckets: 7d @ 1h = 168
// points which is still a manageable payload.
if (minutes <= 10080) return '1hour';
return '1day';
}
// Small helper that logs a failed metric call with a response-body
// preview AND (on 4xx) the request body that Prisma rejected — the
// most common failure mode is a metric name or filter key drifting
// on the tenant, and seeing both sides in the same log line makes
// the schema mismatch obvious without cranking LOG_LEVEL=debug.
function logMetricFailure(scope, err) {
const status = err.response?.status;
const respPreview = err.response?.data
? JSON.stringify(err.response.data).slice(0, 300)
: '';
const isSchemaError = status && status >= 400 && status < 500;
const reqPreview = isSchemaError && err.config?.data
? String(err.config.data).slice(0, 500)
: '';
const parts = [
`${scope} failed: ${err.message}`,
status ? `(HTTP ${status})` : '',
respPreview ? `— resp: ${respPreview}` : '',
reqPreview ? `— req: ${reqPreview}` : '',
].filter(Boolean);
logger('paloalto:metrics', parts.join(' '), 'warn');
}
// ──────────────────────────────────────────────
// Healthscore (site-level roll-up 0-100)
// ──────────────────────────────────────────────
/**
* Site healthscore (0-100) over the given window. Uses the v2.6
* unified monitor/metrics endpoint — the only healthscore endpoint
* that returned 200 on this tenant (verified via prismaProbe
* try-shapes; v2.0 aiops/health and v2.1 aiops/aggregates both
* dead-end on schema errors this tenant enforces).
*
* The v2.6 endpoint accepts:
* - `metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }]`
* - `view: {}`
* - `filter: { site: [siteId] }` — scoped server-side (unlike
* v2.0 which rejects any filter)
* - `interval` + `start_time` (no `end_time`)
*
* @param {string} siteId
* @param {number} windowMinutes default 15
* @returns {Promise<object|null>} raw response or null on failure
*/
export async function getHealthscore(siteId, windowMinutes = 15) {
if (!siteId) return null;
try {
const res = await paloAltoAxios.post(metricsV26Path(), {
start_time: windowStart(windowMinutes),
interval: pickInterval(windowMinutes),
metrics: [{
name: 'Healthscore',
statistics: ['max'],
unit: 'gauge',
}],
view: {},
filter: { site: [String(siteId)] },
});
return res.data || null;
} catch (err) {
logMetricFailure(`getHealthscore(${siteId})`, err);
return null;
}
}
// ──────────────────────────────────────────────
// LQM point metrics (per-path latency / jitter / loss / MOS)
// ──────────────────────────────────────────────
/**
* Fetch one of the four LQM point metrics over the given window.
* `metricKey` is one of the keys of LQM_METRIC_NAMES ('latency',
* 'jitter', 'loss', 'mos').
*
* Uses the DEDICATED `lqm_point_metrics` endpoint. `sys_point_metrics`
* is a different endpoint for CPU/Memory/Disk system metrics and its
* schema rejects `filter.wan_interfaces` — that was the bug that led
* to the endpoint switch.
*
* Filter shape verified against live 400s on this tenant + the
* pan.dev LIVEcommunity #1235108 working example:
* - `filter.site` — ARRAY (opposite of sys_point_metrics, which
* wants a string). Live 400 was
* "$.filter.site: string found, array expected".
* - `filter.path` — array of waninterface ids. Yes, the key is
* "path" — NOT `wan_interfaces` (rejected as
* "not defined in the schema" on this tenant),
* NOT `waninterface` (that's the sys_point_metrics
* key). Prisma's LQM data model treats each
* circuit as a "path".
*
* Missing `waninterfaceIds` returns null (with a log line) rather
* than firing a doomed request that would come back empty.
*
* @param {string} siteId
* @param {string[]} waninterfaceIds one or more WAN interface ids
* @param {'latency'|'jitter'|'loss'|'mos'} metricKey
* @param {number} windowMinutes default 5
* @returns {Promise<object|null>}
*/
export async function getLqmMetric(siteId, waninterfaceIds, metricKey, windowMinutes = 5) {
const spec = LQM_METRIC_NAMES[metricKey];
if (!spec) {
throw new Error(`Unknown LQM metric key "${metricKey}" (expected: ${Object.keys(LQM_METRIC_NAMES).join(', ')})`);
}
if (!siteId) return null;
if (!Array.isArray(waninterfaceIds) || waninterfaceIds.length === 0) {
logger('paloalto:metrics', `getLqmMetric(${metricKey}): no waninterfaceIds — skipping`, 'debug');
return null;
}
try {
const res = await paloAltoAxios.post(monitorPath('lqm_point_metrics'), {
// NOTE: no `end_time` — the observed tenant's lqm_point_metrics
// schema rejects it with 400 "$.end_time: is not defined in the
// schema and the schema does not allow additional properties".
// The endpoint interprets the window as [start_time, now).
start_time: windowStart(windowMinutes),
interval: pickInterval(windowMinutes),
metrics: [{
name: spec.name,
statistics: ['average'],
unit: spec.unit,
}],
view: {},
filter: {
site: [String(siteId)],
path: waninterfaceIds,
},
});
return res.data || null;
} catch (err) {
logMetricFailure(`getLqmMetric(${siteId}, ${metricKey})`, err);
return null;
}
}
// ──────────────────────────────────────────────
// Per-application "Application Path Details" metrics (v2.6 API)
// ──────────────────────────────────────────────
//
// These are what Prisma's Application Path Details dashboard renders:
// real audio/video traffic quality per app (rtp-base,
// Webex_Calling_RTP, MS_Teams_RTP, etc.) — measured on ACTUAL user
// packets via DPI, not on synthetic LQM probes across the underlying
// link. For voice diagnostics this is
// the more meaningful signal — link probes can pass while real RTP
// experiences packet loss spikes and MOS dips during flap events
// that get averaged out of the link probe view.
//
// Registry entries were confirmed via HAR captures 2026-07-09 against
// a live tenant (site CG00127) for both `rtp-base` (id 15932000365560116)
// and `Webex_Calling_RTP` (id 1708539371717015196). The metric names,
// units, and filter shapes are identical across voice apps — the only
// per-app knob is filter.app, so the check code is app-agnostic. The
// winning shapes are shown per-metric in the table below.
//
// Key filter-shape quirks:
// - `filter.direction` is REQUIRED as `"Ingress"` for audio-quality
// metrics (loss/jitter/MOS are what you RECEIVE). Bandwidth is
// bidirectional aggregate and does NOT take a direction.
// - `filter.path_type` is REQUIRED for AppPerf* metrics (loss,
// jitter, bandwidth) but NOT for AppAudioMos.
// - `filter.app` uses the numeric app id (resolvable via
// `POST /sdwan/v2.5/api/appdefs/query`), NOT the display name.
//
// Response shape is IDENTICAL to v2.6 healthscore
// (metrics[].series[].data[].datapoints[{time, value}]) so downstream
// parsing can reuse the same helper.
const ALL_PATH_TYPES = Object.freeze(
['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'],
);
// eslint-disable-next-line no-restricted-syntax -- frozen shared registry.
export const APP_METRIC_NAMES = Object.freeze({
mos: {
name: 'AppAudioMos',
unit: 'count',
direction: 'Ingress',
includePathType: false,
// Lower is worse for MOS: 5=excellent, 4=good, 3.5=acceptable,
// <3=impaired, <2=unintelligible. Grade against worst-window
// rather than average — a 24h avg smooths over the flap events
// that actually degrade calls.
lowIsBad: true,
},
loss: {
name: 'AppPerfUDPAudioPacketLoss',
unit: 'percentage',
direction: 'Ingress',
includePathType: true,
lowIsBad: false,
},
jitter: {
name: 'AppPerfUDPAudioJitter',
unit: 'milliseconds',
direction: 'Ingress',
includePathType: true,
lowIsBad: false,
},
bandwidth: {
name: 'AppPerfUDPAudioBandwidth',
unit: 'Mbps',
direction: null,
includePathType: true,
lowIsBad: null, // bandwidth isn't a quality metric — used for context only
},
});
// v2.6 monitor/metrics path (SASE unified prefix vs. legacy).
function appMetricsPath() {
return isSase()
? '/sdwan/monitor/v2.6/api/monitor/metrics'
: '/v2.6/api/monitor/metrics';
}
/**
* Fetch a single per-app metric time series for a site.
*
* Returns the raw Prisma response body (with metrics[].series[].data
* [].datapoints[] intact) or null on failure. The datapoints array is
* usually 24h × 5min = 288 points; the caller is responsible for
* client-side aggregation (avg / min / max / p95) because for voice
* quality the WORST window matters more than the average.
*
* @param {string} siteId
* @param {string} appId Prisma numeric app id (NOT display name)
* @param {'mos'|'loss'|'jitter'|'bandwidth'} metricKey
* @param {number} windowMinutes default 1440 (24h) — matches the
* shipping default for WAN checks
* @returns {Promise<object|null>}
*/
export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) {
const spec = APP_METRIC_NAMES[metricKey];
if (!spec) {
throw new Error(
`Unknown app metric key "${metricKey}" ` +
`(expected: ${Object.keys(APP_METRIC_NAMES).join(', ')})`,
);
}
if (!siteId || !appId) {
logger('paloalto:metrics',
`getAppMetric(${metricKey}): missing siteId or appId — skipping`, 'debug');
return null;
}
const filter = {
site: [String(siteId)],
app: [String(appId)],
};
if (spec.includePathType) filter.path_type = [...ALL_PATH_TYPES];
if (spec.direction) filter.direction = spec.direction;
try {
const res = await paloAltoAxios.post(appMetricsPath(), {
start_time: windowStart(windowMinutes),
end_time: nowIso(),
// NOT pickInterval — see pickAppMetricInterval doc for why:
// app metrics need the fine-grained series or the client-side
// worst-window aggregation is meaningless.
interval: pickAppMetricInterval(windowMinutes),
metrics: [{
name: spec.name,
statistics: ['average'],
unit: spec.unit,
}],
filter,
view: {},
});
return res.data || null;
} catch (err) {
logMetricFailure(`getAppMetric(${siteId}, app=${appId}, ${metricKey})`, err);
return null;
}
}
// ──────────────────────────────────────────────
// Alarms (via the events/query endpoint)
// ──────────────────────────────────────────────
/**
* Recent alarms for the site. Uses the SASE events endpoint
* (`POST /sdwan/v3.7/api/events/query`) — the older
* `/monitor/v2.0/api/monitor/alarms` path 404s on this tenant
* ("ROUTE_NOT_FOUND_0001").
*
* Body shape mirrors the Prisma ServiceNow CloudBlade example
* (pan.dev + PA integration guide): `limit` is an OBJECT with
* `count`/`sort_on`/`sort_order`, `query` carries `site`/`type`,
* `severity` is a top-level array, `start_time` is ISO.
* Additionally we filter `type: ['alarm']` so we don't get
* informational events mixed into the alarm count.
*
* @param {string} siteId
* @param {number} windowMinutes default 60
* @returns {Promise<object|null>}
*/
export async function getAlarms(siteId, windowMinutes = 60) {
if (!siteId) return null;
try {
const res = await paloAltoAxios.post(eventsQueryPath(), {
limit: {
count: 100,
sort_on: 'time',
sort_order: 'descending',
},
query: {
site: [String(siteId)],
type: ['alarm'],
},
severity: ['critical', 'major', 'minor'],
start_time: windowStart(windowMinutes),
});
return res.data || null;
} catch (err) {
logMetricFailure(`getAlarms(${siteId})`, err);
return null;
}
}