// 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 `. 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} 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} */ 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} */ export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 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; } let startTime; let endTime; let windowMinutes; if ( windowOrOpts && typeof windowOrOpts === 'object' && windowOrOpts.startTime && windowOrOpts.endTime ) { startTime = windowOrOpts.startTime; endTime = windowOrOpts.endTime; windowMinutes = Math.max( 1, Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000), ); } else { windowMinutes = typeof windowOrOpts === 'number' ? windowOrOpts : 1440; startTime = windowStart(windowMinutes); endTime = nowIso(); } 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: startTime, end_time: endTime, // 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} */ 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; } }