Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
14 KiB
JavaScript
337 lines
14 KiB
JavaScript
// 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.
|
|
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';
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// 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;
|
|
}
|
|
}
|