#!/usr/bin/env node /** * Dashboard-completeness check (TASK-0135, phase-15). * * The spec's dashboard requirement: "every emitted metric must appear in at * least one panel, OR be explicitly listed as unpaneled in README.md with a * reason." This script makes that check mechanical (and re-runnable in CI): * * 1. parse every panel target's `expr` in the six dashboards and extract * the metric families it actually READS — a token only counts if it * sits in a metric position (immediately before `{` or `[`, or a bare * metric reference); * 2. assert every family the panels read is in the emitted-metric catalog * (catches typos in dashboards); * 3. assert every catalog family (the 15 OTel instruments incl. the * operational search_index_lag_seconds, the 10 realtime-local series, * target_info, and the 2 expected-external families) is read by some * panel OR listed in the README's "Unpaneled / gap" section. * * Run from anywhere (paths are resolved relative to this file): * node infrastructure/observability/scripts/dashboard-completeness.mjs * Exit 0 = complete, 1 = gap or typo. */ import { readFileSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const dashboardsDir = join(here, "..", "grafana", "dashboards"); const readmePath = join(here, "..", "README.md"); /** The emitted-metric catalog (by source). EXPOSED names — classic text * format, so the http counter is `http_request_count_total`. */ const CATALOG = [ // packages/observability instruments (15 — 14 spec + 1 operational) "http_request_duration_seconds", "http_request_count_total", "db_query_duration_seconds", "redis_command_duration_seconds", "queue_depth", "queue_job_duration_seconds", "ws_active_connections", "ws_messages_total", "search_query_duration_seconds", "ai_tokens_total", "ai_request_duration_seconds", "storage_ops_total", "storage_op_duration_seconds", "error_total", "search_index_lag_seconds", // realtime app-local registry (10 — merged into the same exposition) "uptime_seconds", "connected_clients", "events_processed_total", "ws_handshake_failures_total", "ws_connections", "ws_drops_total", "ws_room_peers", "ws_update_log_size", "ws_rooms", // OTel exporter always emits this on every target "target_info", // expected-present external exporters (the alerts read them; the Infra // panels read the node ones directly) "node_filesystem_usage", "node_cpu_seconds_total", "node_memory_MemAvailable_bytes", "node_memory_MemTotal_bytes", "probe_ssl_earliest_cert_expiry" ]; /** Recording-rule names the dashboards may legitimately read (derived, not * raw emitted families — allowed as panel sources). */ const RECORDING_RULES = new Set([ "factory_api:http_request_duration_seconds:p50", "factory_api:http_request_duration_seconds:p95", "factory_api:http_request_duration_seconds:p99", "factory_api:http_requests:rate5m", "factory_api:http_request_error_rate:5m", "factory_worker:queue_depth", "factory_worker:queue_jobs:rate5m", "factory_worker:queue_job_duration_seconds:p95", "factory_worker:search_index_lag_seconds", "factory_realtime:ws_connections", "factory_realtime:ws_messages:rate5m", "factory_api:search_query_duration_seconds:p95", "factory_api:ai_tokens:rate5m", "factory_api:ai_request_duration_seconds:p95", "factory_api:db_query_duration_seconds:p95" ]); const catalogSet = new Set(CATALOG); function collectExprs() { const files = readdirSync(dashboardsDir).filter((f) => f.endsWith(".json")).sort(); const out = []; for (const f of files) { const d = JSON.parse(readFileSync(join(dashboardsDir, f), "utf8")); for (const p of d.panels ?? []) { for (const t of p.targets ?? []) { if (typeof t.expr === "string" && t.expr.trim()) out.push({ file: f, panel: p.title ?? `panel ${p.id}`, expr: t.expr }); } } } return out; } /** Histogram derived-series suffixes (map back to the base family). */ const HIST_SUFFIXES = ["_bucket", "_sum", "_count", "_total"]; /** Extract the metric references an expr touches. A token counts as a * metric family iff it is immediately followed by `{` (labels) or `[` * (range vector), OR is a bare metric (at an expression start / after an * operator, not a keyword). `by (label)` group-by clauses are stripped * first so their label names are never mistaken for metrics; histogram * derived series (foo_bucket/foo_sum/foo_count/foo_total) map to `foo`. */ function familiesInExpr(expr) { const found = new Set(); // strip quoted label values (e.g. mountpoint="/var/lib") and group-by // clauses (their contents are label names, not metrics) const stripped = expr.replace(/"[^"]*"/g, '""').replace(/\bby\s*\(([^)]*)\)/g, " by () "); for (const m of stripped.matchAll(/([a-zA-Z_][a-zA-Z0-9_:]*)(\s*\{|\s*\[)/g)) { found.add(resolveFamily(m[1])); } for (const m of stripped.matchAll(/(?:^|[+\-*/()\s]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=[+\-*/<>!]=(?!=)|[)\s]|$)/g)) { const tok = m[1]; if (isBareMetricTok(tok)) found.add(resolveFamily(tok)); } return found; } /** Map a series name onto its catalog family: strip histogram suffixes, and * for recording rules (namespace:family:record) use the middle segment. */ function resolveFamily(tok) { let base = tok; if (tok.includes(":") && tok.startsWith("factory")) { const parts = tok.split(":"); if (parts.length >= 3) base = parts.slice(1, -1).join(":"); } if (catalogSet.has(base)) return base; for (const s of HIST_SUFFIXES) { if (base.endsWith(s) && catalogSet.has(base.slice(0, -s.length))) return base.slice(0, -s.length); } return tok; } const BARE_NON_METRIC = new Set([ "by", "on", "ignoring", "group_left", "group_right", "bool", "or", "and", "unless", "offset", "sum", "avg", "min", "max", "count", "rate", "irate", "increase", "histogram_quantile", "avg_over_time", "max_over_time", "min_over_time", "sum_over_time", "last_over_time", "label_replace", "label_join", "time", "label_values", "topk", "bottomk", "sort", "absent", "changes", "resets", "deriv", "idelta", "clamp", "clamp_max", "clamp_min", "vector", "scalar", "timestamp", "predict_linear", "le", "quantile", "if", "with", "true", "false", "null", "instance", "job", "mode", "idle" ]); function isBareMetricTok(tok) { return !BARE_NON_METRIC.has(tok) && /^[a-z][a-zA-Z0-9_]*$/.test(tok); } function main() { const exprs = collectExprs(); if (!exprs.length) { console.error("FAIL: no dashboard exprs found"); process.exit(1); } const readme = readFileSync(readmePath, "utf8"); const gapSection = readme.includes("## Unpaneled / gap") ? readme.split("## Unpaneled / gap")[1].split(/\n## /)[0] : ""; const paneld = new Set(); const violations = []; for (const e of exprs) { for (const fam of familiesInExpr(e.expr)) { if (catalogSet.has(fam)) { paneld.add(fam); } else if (RECORDING_RULES.has(fam) || fam.includes(":")) { // derived series — fine } else if (isBareMetricTok(fam) && !/[A-Z]/.test(fam)) { // a lowercase bare token that is NOT a catalog family, NOT a // recording rule, NOT a known keyword → probable typo violations.push(`TYPO? ${e.file} panel "${e.panel}" reads unknown family "${fam}" — expr: ${e.expr.trim().slice(0, 80)}`); } } } const unpaneld = CATALOG.filter((f) => !paneld.has(f)); for (const fam of unpaneld) { if (!gapSection.includes(fam)) { violations.push(`UNPANELLED: catalog family "${fam}" is read by no panel and is NOT listed in README "Unpaneled / gap"`); } } console.log(`dashboards : ${readdirSync(dashboardsDir).filter((f) => f.endsWith(".json")).length}`); console.log(`exprs : ${exprs.length}`); console.log(`catalog : ${CATALOG.length} families (15 OTel + 10 realtime-local + target_info + 5 external)`); console.log(`paneld : ${CATALOG.filter((f) => paneld.has(f)).length}/${CATALOG.length}`); if (unpaneld.length) console.log(`unpaneld : ${unpaneld.join(", ")}`); if (violations.length) { console.error("\nDASHBOARD-COMPLETENESS VIOLATIONS:"); for (const v of violations) console.error(" - " + v); process.exit(1); } console.log("\nOK: every emitted metric is paneld or listed unpaneled in README.md"); process.exit(0); } main();