collabSupport/integrations/paloalto/metrics.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
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>
2026-07-09 09:45:29 -04:00

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;
}
}