Gateway transport: prometheus service config gains grafana_url/api_key/ datasource_uid; all queries via POST /api/ds/query; frames->series restored. First non-additive canonical sync: MODIFIES 17 SC- requirements (transport changes, intent preserved), PRESERVES 11, ADDS 3 (gateway status, startup validation, sanctioned-transport statement). 3 spec assumptions settled where proposal was silent.
18 KiB
SDD Spec: Grafana Metric Gateway
Change: grafana-metric-gateway
Phase: spec
Date: 2026-07-09
This spec defines the acceptance requirements for the change. Requirements use the GM- prefix (the prometheus-charting canonical domain, created by prometheus-direct-charting, uses SC-; this change supersedes several of those — see §"Canonical delta intent").
Requirement categories
- Gateway transport (config + query path)
- Frames → series normalization
- Window presets + step mapping
- Widget kind behavior (chart / gauge / mean / metric)
- Status check via gateway
- Widget rename to neutral
Metric* - Startup validation + migration
- Test + build greenness
1. Gateway transport (config + query path)
GM-101 — Prometheus service config points at a Grafana gateway
The prometheus service type's config model MUST declare grafana_url: ServiceBaseUrl (http(s)-schema-enforced), datasource_uid: str = "prometheus", and timeout_seconds: int = 10. The former base_url field MUST be removed. The secret schema MUST declare grafana_api_key (required, encrypted via Fernet) and MUST NOT carry the old optional api_key.
Scenario: Config schema reflects gateway fields
- GIVEN the
grafana-metric-gatewaychange is applied - WHEN
integrations/prometheus.pyPrometheusConfigis inspected - THEN it declares
grafana_url,datasource_uid,timeout_seconds - AND it does NOT declare
base_url - AND
DEFINITION.secret_fieldscontainsgrafana_api_key(required=True) - AND
DEFINITION.secret_fieldsdoes NOT containapi_key
GM-102 — All metric queries route through Grafana /api/ds/query
The widget source adapter (renamed MetricSource, service-type key stays "prometheus") MUST issue POST {grafana_url}/api/ds/query for all four widget kinds (chart, gauge, mean, metric). The request body MUST contain a queries array with {datasource: {uid: <datasource_uid>, type: "prometheus"}, expr: <promql>, format: "time_series", intervalMs: <step_ms>, maxDataPoints: <pts>, refId: "A"} plus from/to time bounds. The request MUST carry Authorization: Bearer {grafana_api_key}. The adapter MUST NOT issue any direct GET /api/v1/query or GET /api/v1/query_range call to a Prometheus URL.
Scenario: Chart fetch uses gateway POST
- GIVEN a
prometheusservice configured withgrafana_url,grafana_api_key,datasource_uid - WHEN a
chartwidget data fetch is executed (mocked HTTP) - THEN the adapter issues
POST {grafana_url}/api/ds/query - AND the request body contains
queries[0].datasource.uid == datasource_uid - AND the request body contains
queries[0].expr == <promql> - AND the
Authorizationheader isBearer {grafana_api_key} - AND no
GET /api/v1/query_rangecall is made
Scenario: No direct Prom HTTP call
- GIVEN the change is applied
- WHEN
widgets/sources.pyMetricSourceis inspected - THEN no code path constructs a URL matching
/api/v1/queryor/api/v1/query_rangeagainst a Prometheusbase_url
GM-103 — Gateway errors degrade gracefully
A Grafana timeout, connection error, HTTP 401/403 (auth), or non-2xx response MUST cause the widget data fetch to return { "error": str } (not raise), so the frontend renders the standard per-widget error state and the rest of the dashboard remains functional. The error message MUST be specific enough to distinguish auth failure from unreachable-host from datasource-not-found where the Grafana response permits.
Scenario: Auth failure surfaces a readable error
- GIVEN a
prometheusservice with an invalidgrafana_api_key - WHEN a widget data fetch is executed (mocked HTTP returns 401)
- THEN the adapter returns
{ "error": <str mentioning auth or 401> } - AND no exception propagates
Scenario: Timeout surfaces a readable error
- GIVEN a
prometheusservice whose Grafana gateway does not respond within the timeout - WHEN a widget data fetch is executed
- THEN the adapter returns
{ "error": <str mentioning timeout> }
2. Frames → series normalization
GM-104 — Frames normalizer restored and shared
A normalize_grafana_frames(raw) -> list[dict] helper MUST exist in widgets/prometheus_range.py alongside the existing normalize_prometheus_matrix. It MUST parse Grafana's /api/v1/ds/query response (results.<refId>.frames[] with data.values + schema.fields) and produce the SAME {label, points:[{t:int, v:float|null}]} series shape the frontend chart renderer already consumes. The label-dedup rule (label (n) suffix on collision) MUST be shared with normalize_prometheus_matrix (extracted to a common inner helper, not duplicated).
Scenario: Frames normalized to series shape
- GIVEN a sample Grafana
/api/ds/queryresponse with one frame containing timestamps[1000, 2000]and values[1.5, 2.5] - WHEN
normalize_grafana_frames(raw)is called - THEN it returns
[{"label": <str>, "points": [{"t": 1000, "v": 1.5}, {"t": 2000, "v": 2.5}]}]
Scenario: Label fallback chain
- GIVEN a frame whose value-field has
config.displayNameset - WHEN normalized
- THEN the label is the displayName
- GIVEN a frame whose value-field has Prometheus
labelsbut no displayName - WHEN normalized
- THEN the label is the sorted
k=vjoin (excluding__-prefixed keys) - GIVEN a frame with neither displayName nor labels
- WHEN normalized
- THEN the label is
"value"
Scenario: Dedup shared with matrix path
- GIVEN two frames that would produce the same label
- WHEN normalized
- THEN the second gets a
(1)suffix - AND the dedup logic is not copy-pasted (a shared helper or shared suffix rule is used by both
normalize_grafana_framesandnormalize_prometheus_matrix)
3. Window presets + step mapping
GM-105 — Window presets map to Grafana from/to + intervalMs
The existing WINDOW_PRESETS (1h/6h/24h/7d → seconds) and step_for_window math MUST be reused to derive the gateway request's from (e.g. "now-1h"), to ("now"), and intervalMs (step * 1000). The resulting point count MUST land in the 100–300 band (same target as the pre-change direct-Prom path). Users do not configure from/to/step/intervalMs directly.
Scenario: 1h preset maps correctly
- GIVEN a
chartwidget withwindow: "1h" - WHEN the gateway request body is constructed
- THEN
fromresolves to approximatelynow - 3600s - AND
tois"now" - AND
intervalMsisstep_for_window(3600) * 1000 - AND the expected point count is ~200
4. Widget kind behavior (chart / gauge / mean / metric)
GM-106 — Chart renders multi-series via gateway
The chart widget MUST query the gateway with a range query (PromQL expr + window preset) and return { "series": [...] } via normalize_grafana_frames. The frontend MetricChartWidget MUST render all returned series via LineSeriesChart, each as its own line with a distinct color. There is no single-series restriction.
Scenario: Multi-series chart through gateway
- GIVEN a
chartwidget whose PromQL returns two series - WHEN data is fetched via the gateway (mocked)
- THEN the adapter returns
{ "series": [<2 entries>] } - AND the frontend renders two distinct lines
GM-107 — Gauge renders instant scalar via gateway
The gauge widget MUST query the gateway with an instant PromQL query (mapped to a Grafana instant datasource query or a range query with a single point) and return { "value": float, "warn_at"?, "crit_at"?, "min"?, "max"?, "unit"? }. If the query returns multiple series, the adapter MUST return { "error": str } (scalar-only). The frontend MetricGaugeWidget MUST render threshold bands when warn_at/crit_at are set; otherwise a single neutral gauge.
Scenario: Gauge scalar-only through gateway
- GIVEN a
gaugewidget whose PromQL returns one scalar - WHEN fetched via the gateway
- THEN the adapter returns
{ "value": <float>, ... } - GIVEN a
gaugewidget whose PromQL returns multiple series - WHEN fetched
- THEN the adapter returns
{ "error": <str> }
GM-108 — Mean computes over a gateway range window
The mean widget MUST query the gateway with a range query over the configured window preset, average all non-null point values of the single series client-side, and return { "value": float, "unit"? }. Scalar-only: multiple series → { "error": str }.
Scenario: Mean through gateway
- GIVEN a
meanwidget withwindow: "1h"and a PromQL returning one series - WHEN fetched via the gateway
- THEN the adapter returns
{ "value": <mean of non-null points> }
GM-109 — Metric widget returns scalar via gateway
The instant metric widget MUST query the gateway and return { "result": <data> } (same shape as today) sourced via Grafana rather than direct Prom.
Scenario: Metric instant query through gateway
- GIVEN a
metricwidget with a PromQL expression - WHEN fetched
- THEN the adapter issues a gateway POST (not a direct Prom GET)
- AND returns the scalar result in the existing
{ "result": ... }shape
5. Status check via gateway
GM-110 — get_prometheus_status validates the full gateway path
get_prometheus_status MUST run a trivial query (e.g. up) through the Grafana gateway. Success → { "up": true, "version": <str>, ... } (version derived from the successful path; if Grafana's response doesn't carry a Prom version, a sentinel like "ok" is acceptable). Failure → { "up": false, "error": <specific str> } distinguishing auth failure, unreachable host, and datasource-not-found where the response permits.
Scenario: Healthy gateway
- GIVEN a
prometheusservice with valid gateway credentials - WHEN
get_prometheus_statusis called - THEN it issues a gateway POST with
expr: "up" - AND returns
{ "up": true, ... }
Scenario: Auth failure in status
- GIVEN a
prometheusservice with an invalidgrafana_api_key - WHEN
get_prometheus_statusis called - THEN it returns
{ "up": false, "error": <str mentioning auth> }
6. Widget rename to neutral Metric*
GM-111 — Widget files renamed via git mv
PrometheusChartWidget.tsx → MetricChartWidget.tsx, PrometheusGaugeWidget.tsx → MetricGaugeWidget.tsx, PrometheusMeanWidget.tsx → MetricMeanWidget.tsx, plus their test files, MUST be renamed via git mv (history preserved). The exported component names MUST change to MetricChartWidget / MetricGaugeWidget / MetricMeanWidget. The rendering code (recharts, LineSeriesChart, gauge bands) MUST be preserved unchanged.
Scenario: No Prometheus*Widget names remain
- GIVEN the change is applied
- WHEN
grep -r "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/srcis run - THEN it returns no matches
- AND
MetricChartWidget.tsx,MetricGaugeWidget.tsx,MetricMeanWidget.tsxexist
GM-112 — Registry binds Metric* components
integrations/registry.ts MUST bind the prometheus service's chart/gauge/mean widget kinds to the renamed Metric* components. The widget KIND names (chart/gauge/mean/metric) MUST NOT change (they are persisted in widget instance rows).
Scenario: Registry uses Metric* components
- GIVEN the change is applied
- WHEN
SERVICE_REGISTRY.prometheus.widgetsis inspected - THEN the
chartkind'scomponentisMetricChartWidget - AND the kind strings are still
"chart","gauge","mean","metric"
7. Startup validation + migration
GM-113 — Startup rejects old prometheus config shape
When the backend starts and a persisted prometheus service instance has a config dict containing the old base_url key (without grafana_url), the startup config validation MUST log a clear migration message (naming the service) and either skip the instance gracefully or mark it as misconfigured — it MUST NOT crash startup.
Scenario: Old-shape config logged not crashed
- GIVEN a persisted
prometheusservice withconfig: {"base_url": "http://prom:9090"}(nografana_url) - WHEN the backend starts
- THEN startup completes (no crash)
- AND a log message names the service and instructs reconfiguration
GM-114 — CHANGELOG documents the migration
CHANGELOG.md [Unreleased] MUST include an entry stating that prometheus service instances must be reconfigured: replace base_url with grafana_url, add the grafana_api_key secret, and optionally set datasource_uid. It MUST be marked BREAKING.
Scenario: CHANGELOG entry present
- GIVEN the change is applied
- WHEN
CHANGELOG.mdis inspected - THEN an
[Unreleased]entry mentionsgrafana_url,grafana_api_key,datasource_uid - AND it is marked BREAKING
8. Test + build greenness
GM-115 — Backend tests + ruff green
PYTHONPATH=src python3 -m pytest -q and PYTHONPATH=src python3 -m ruff check src tests from backend/ MUST pass. New/updated tests MUST cover: gateway POST assertion (mocked), normalize_grafana_frames (label fallback chain, dedup, NaN handling), gauge/mean scalar-only through gateway, status check through gateway, and the startup old-config validation.
GM-116 — Frontend build + lint + test green
npm run build, npm run lint, and npx vitest run from frontend/ MUST pass (0 errors). The renamed Metric* widget tests MUST cover loading, error, and rendered states.
Canonical delta intent
This is the project's first non-additive canonical sync. The prometheus-charting canonical domain (openspec/specs/prometheus-charting/spec.md) has 27 requirements (SC-101..SC-127). This change MODIFIES the transport-related requirements, PRESERVES the durable-behavior requirements, and ADDS new requirements. The sync phase (sdd-sync) MUST author a ## MODIFIED Requirements delta (not ## ADDED only).
MODIFIED (supersedes a canonical SC-xxx — intent may be preserved or changed)
| GM-req | Supersedes | What changes |
|---|---|---|
| GM-101 | SC-101 (partial) | Transport: direct Prom /api/v1/query_range → Grafana /api/ds/query; config base_url → grafana_url + datasource_uid + grafana_api_key |
| GM-102 | SC-101 (partial) | No direct Prom HTTP; all queries via gateway POST |
| GM-103 | SC-103 | Error semantics preserved (graceful {error}), but transport-specific failures (Grafana 401/403, datasource-not-found) are new |
| GM-104 | SC-102 | Shared helper now holds BOTH normalize_prometheus_matrix (kept for qBit) AND normalize_grafana_frames (new/restored); dedup shared |
| GM-105 | SC-104 | Step math reused but mapped to intervalMs/maxDataPoints instead of Prom step |
| GM-106 | SC-107 | Multi-series intent preserved; transport changes |
| GM-107 | SC-109, SC-111 | Gauge scalar-only preserved; instant query now via gateway |
| GM-108 | SC-112, SC-114 | Mean over window preserved; range query now via gateway |
| GM-109 | SC-105 (partial) | metric kind still bound to prometheus; transport changes |
| GM-110 | (new behavior for status) | get_prometheus_status now validates gateway path (previously direct Prom health) |
| GM-111 | SC-125 | Widget names change: Prometheus*Widget → Metric*Widget |
| GM-112 | SC-105 (partial) | Registry still binds kinds to prometheus; component refs rename |
| GM-113 | SC-126 (partial) | No auto-migration still holds; startup validation of old shape is new |
| GM-114 | SC-122 | CHANGELOG migration note changes from "delete grafana, recreate prometheus" to "reconfigure prometheus with gateway fields" |
| — | SC-115 | MODIFIED: "no grafana refs in backend" is no longer fully true — normalize_grafana_frames is a grafana-named function. The criterion becomes "no grafana SERVICE TYPE refs" (transport refs allowed). |
| — | SC-116 | MODIFIED: same as SC-115 for frontend — grafana transport is allowed; no grafana service type / link widget. |
| — | SC-117 | PRESERVED — no grafana service type is reintroduced. Still true. |
| — | SC-118 | PRESERVED — get_grafana_status stays removed; no grafana status surface. |
| — | SC-119 | PRESERVED — orphaned widgets still degrade gracefully. |
| — | SC-120 | PRESERVED — no grafana-specific tests reintroduced. |
| — | SC-121 | MODIFIED: config.yaml now references Grafana as the sanctioned transport (not "must not reference grafana"). |
PRESERVED (unchanged in canonical — no delta entry needed)
SC-106 (chart renderer preserved), SC-108 (chart window preset), SC-110 (gauge threshold bands), SC-113 (mean plain PromQL), SC-117 (no grafana service type), SC-118 (no grafana status), SC-119 (orphan degrade), SC-120 (no grafana tests), SC-123 (backend tests pass), SC-124 (frontend build+lint), SC-127 (independent of service-storage-harness).
ADDS (new canonical requirements — ## ADDED Requirements in delta)
GM-110 (gateway-path status check — new behavior), GM-113 (startup old-config validation — new), and the canonical domain gains an explicit statement that Grafana is the sanctioned transport for the prometheus service.
Assumptions (where the proposal was silent and the spec settles)
- Grafana instant-query mapping. Grafana's
/api/ds/queryis inherently a range query. For thegauge(instant) andmetric(instant) paths, the spec assumes the adapter issues a gateway query with a minimal window (e.g.from=now-1m, to=now, maxDataPoints=1) and extracts the single latest point, rather than attempting a separate Grafana instant-query endpoint. This mirrors how the pre-Change-A Grafana path handled chart data and is the pragmatic mapping. If Grafana exposes a cleaner instant path, the design phase may refine this — but the requirement (scalar result via gateway) holds either way. - Version field in status. Grafana's
/api/ds/queryresponse does not carry Prometheus build-info.get_prometheus_statusreturnsversion: "ok"(or omits it) on success. A richer version (via Grafana's datasource health endpoint) is a future-phase item, not this change. normalize_prometheus_matrixretention. The matrix normalizer is kept (not deleted) becauseQbittorrentSampleStore.window()returns data in a matrix-adjacent shape consumed byQbittorrentSpeedWidget. Removing it would break the qBit speed widget. Both normalizers coexist.- Old
api_keysecret. The old optionalapi_keysecret on theprometheusservice is removed from the definition. Persisted rows may still carry it in their encrypted secrets blob; it is inert (the adapter readsgrafana_api_keyonly) and gets dropped on the next save.