// 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. 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} 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; } } // ────────────────────────────────────────────── // 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; } }