feat(monitoring): Supabase host metrics relay and dashboard row
Superseded by !442 (closed) — proposed to close. This MR conflicts with
mainand with !435 (closed), and !442 (closed) contains all of it with the conflicts resolved. Its code is in !442 (closed), with the round-2 cuts applied there: the awk YAML parser, the static scrape job and its relabel toggle are replaced by a scrape file thatmon targets addwrites with the target's labels, the relay'sHandler(enabled bool)lost its argument, and the Docker scrape test CI skipped is replaced by a test on the real VictoriaMetrics binary that CI runs. Closing is Nik's call; nothing here needs to merge separately.
Update 2026-09-30 (review findings, platform-all!884 auth).
- Labels. The Supabase scrape target now carries the pgwatch
clusterandnode_name:sources-generator, which already readsinstances.ymlfor pgwatch, writesprometheus/supabase-host-metrics.json(file_sd) with the tags of the one enabled Supabase target (db.<ref>.supabase.coor*.pooler.supabase.com). With none or several, the target has no labels. It parses the file the way the CLI writes it (js-yaml folds a longconn_strinto a block scalar) and escapes the same in BusyBox awk (bash:5.2), mawk and BSD awk. The row queries now filter on the dashboard's cluster and node selectors, and the test asserts that they do, where it used to assert the opposite.- Instance secret (platform-all!884). The RPC now also requires the box's own secret. The relay reads
instance_secretfrom.pgwatch-config(the file only, never the environment) and sends it in theinstance-secretheader, next toaccess-token, and never in the body. It is never logged. Without it the relay makes no call, answers 503instance_secret_missing, and logs once an hour that the instance needs re-provisioning. A golden request test pins the headers and body.- Superseded for the dashboard. The unified Host row that reads
host_*for all providers is in !442 (closed) (see there for merge order).Required provisioning change (postgres_ai-ansible, not in this MR). SI already receives
instance_secretas an extraVar fromsupabase_monitoring_provision. Inroles/postgres_ai/tasks/main.yml, afterpostgres_ai_project_diris resolved and aftermon local-installhas created.pgwatch-config:- name: Write the instance secret for Supabase host metrics (platform-all!884) ansible.builtin.lineinfile: path: "{{ postgres_ai_project_dir }}/.pgwatch-config" regexp: '^instance_secret=' line: "instance_secret={{ instance_secret }}" create: false no_log: true when: (instance_secret | default('') | length) > 0 - name: Turn on Supabase host metrics ansible.builtin.lineinfile: path: "{{ postgres_ai_project_dir }}/.env" regexp: '^PGAI_SUPABASE_HOST_METRICS=' line: "PGAI_SUPABASE_HOST_METRICS=true" when: (instance_secret | default('') | length) > 0The relay rereads
.pgwatch-configon every credential fetch, so the secret needs no restart. The.envflag is read at container start: rundocker compose up -d sink-prometheus instance-jobsin the project directory after the second task, withinstance-jobsenabled for the box.regexpreplaces an existing line: the config reader takes the firstinstance_secret=line, so a duplicate must never be appended. Boxes provisioned before !884 have no secret and need re-provisioning.
What
Host metrics (CPU, memory, disk, IOPS, network) for Supabase projects, with no key pasted by the customer and no key on disk.
instance-jobsgains an internal relay (GET /supabase/metricson:9188, compose network only). It asks the platform RPCsupabase_host_metrics_credentialfor the project's secret key, keeps it in memory for at most 1 hour, and scrapeshttps://<ref>.supabase.co/customer/v1/privileged/metricswith it.- VictoriaMetrics scrapes the relay every 60 s and keeps only the node_exporter host families.
- Dashboard 01 gets a collapsed "Host (Supabase)" row: CPU busy %, memory available, filesystem available, disk IOPS, network bytes/s.
- The relay is off unless
PGAI_SUPABASE_HOST_METRICS=true.
Platform half: https://gitlab.com/postgres-ai/platform-all/-/merge_requests/884 (RPC, re-consent button).
Key handling
- The key is held in memory only. It is never logged or written to a file, env or argv, and error text never includes it. The relay replies with a fixed Content-Type, so no upstream header is passed through.
metrics_urlmust behttps://<20 a-z>.supabase.co/customer/v1/privileged/metricsand matchproject_ref. Anything else is refused, and redirects are not followed.- Credential fetches are limited to one per 5 minutes and run detached from the scrape request (10 s timeout). On a 401/403 the relay refetches once. The last exposition is served from memory for 30 s, so compose-network peers cannot drive upstream scrapes.
- The org API token goes in the
access-tokenheader only, like Poll, so it never becomes a logged bind parameter.
Tests
- Red/green commits. Go unit tests use httptest fakes: log and header leak checks, 401 refetch, cooldown, TTL, URL validation, consent_needed → 503, and a byte-for-byte passthrough golden.
- The golden
testdata/supabase_metrics.promwas recorded on 2026-09-27 from a real Free-plan Nano project and trimmed to host families. The ref is replaced. - Live test (
-tags supabase_live, needsSUPABASE_TEST_ACCESS_TOKEN+SUPABASE_TEST_PROJECT_REF): it passed against a real project in thepgai-ci-testorg. The relay returned 200 with all five host families and the key did not appear in logs. go test ./...,go vet(both tag sets), gofmt,pytest tests/grafana_dashboards(392 passed), compose config check and VictoriaMetrics-promscrape.configdry run all pass.
Known limits
- When the feature is off, the scrape target is dropped by relabeling, so no
up{job="supabase-host-metrics"}series exists on those boxes. - One project per box, which is how the platform provisions Supabase monitoring. Series carry
supabase_project_refandsupabase_identifier(unique per host, so read replicas stay separate), notcluster/node_name.
Part of postgres-ai/platform-all#296, postgres-ai/internal#354.
Full-stack acceptance (2026-09-28)
We ran instance-jobs (this branch), VictoriaMetrics v1.140.0 and Grafana 12.3.2 against the live Free-plan project for 11 min 11 s. A local stand-in answered the platform RPC, because the OAuth app does not have the scope yet.
up{job="supabase-host-metrics"}stayed at 1 for 11 of 11 scrapes, with 1 credential RPC call and 0 restarts.- All six "Host (Supabase)" queries returned live series through Grafana
/api/ds/query. Sample values: CPU 3.6 %, memory available 206 MiB, filesystem available 2.95 GiB, IOPS 58/s, network rx 400 B/s, tx 1.08 KB/s. - Key leak check: 0 matches across all container logs and every VictoriaMetrics label value. https://claude.ai/code/session_01GRNbWXUQJebE3vqWjAgTFh
Rendered evidence (2026-09-28, SHA 331344f9)
Environment: VictoriaMetrics v1.140.0, Grafana 12.3.2 (provisioned from config/grafana/dashboards on this branch), headless Chrome 1600x1000, dark theme. Data: the relay's golden exposition testdata/supabase_metrics.prom (recorded from the Free-plan Nano project) imported into VictoriaMetrics with job="supabase-host-metrics" over 25 minutes, once for the primary and once as a read replica (supabase_identifier=...-replica-1) to show that replica series stay separate.
Feature on, row expanded (all 5 panels, legends by supabase_identifier), footer above the row:
Feature on, row collapsed, footer above the row header:
Feature off (empty store), row expanded: No data in every panel, no up target exists (see test_supabase_target_only_when_enabled):


