Skip to content

Add cross-org usage dashboard to Grafana - #2476

Open
harry-rhesis wants to merge 5 commits into
mainfrom
feat/usage-grafana-dashboard
Open

Add cross-org usage dashboard to Grafana#2476
harry-rhesis wants to merge 5 commits into
mainfrom
feat/usage-grafana-dashboard

Conversation

@harry-rhesis

Copy link
Copy Markdown
Contributor

Purpose

Usage accrues per org into the usage table, but the only way to look at it is the in-app Usage page, which shows one org its own numbers. There is no way for us to see consumption across every tenant: who is heaviest, what the platform total is, whether a month looks abnormal. This adds that view to the Grafana we already run, on top of the read-only Postgres datasource from #2348.

This is the Observability slice of the usage-limits work, redesigned. The original plan called for prometheus_client, a /metrics endpoint, in-process gauges updated on increment, and a Celery beat task to resync them after restarts. That does not fit this codebase, for four independent reasons: /metrics is already taken by routers/metric.py; there is no Celery beat process anywhere, so one would mean a new container and deployment; the Celery worker never calls bootstrap_ee, so a beat task would resolve community limits for every org rather than their real ones; and the backend runs several replicas, so gauges updated on increment would each hold a different subset of the state and Prometheus would scrape disagreeing values. Alertmanager is also disabled, so the planned PrometheusRule thresholds would have fired into nothing.

Current usage is database state, not process state. Reading it with SQL is both simpler and more accurate, needs no new dependency and no backend code, and sidesteps the unbounded per-organization label cardinality the metrics approach would have introduced.

What Changed

  • kubernetes/base/grafana-resources/usage-dashboard.yaml (new) — the repo's first GrafanaDashboard. Six panels plus a notes panel: platform totals and a per-org breakdown for the current billing period, top consumers, a 12-month trend by resource and by org, and a month-over-month table.
  • kubernetes/clusters/dev/grafana-resources/ (new datasource + credentials) — Feat/Grafana Dashboard For Rhesis #2348 covered stg and prd only, so dev had nothing for the dashboard to query.
  • charts/rhesis/values-dev.yaml — an init-grafana-viewer.sh initdb script creating the grafana-viewer role. Dev has no CNPG, so it cannot come from managed.roles; this mirrors how rhesis-user and rhesis-analytics-user are already created there.
  • infrastructure/config/gsm-secrets.json.example — added the dev entry for GRAFANA_VIEWER_PASSWORD, which stg and prd already had.

No backend, frontend, SDK or EE changes. No migration, no new dependency.

Two design notes worth reviewing:

The dashboard lives in base/ even though each env names its datasource differently, because the datasource is chosen by a template variable rather than hardcoded. That variable queries type postgres, which is what all three GrafanaDatasource CRs declare, so it resolves whether or not the running Grafana maps the newer grafana-postgresql-datasource plugin alias.

Every panel joins organization and filters deleted_at on both sides. Without the join, a deleted org's live usage rows inflate the platform total and make it disagree with the per-org table directly beneath it. My fixture reproduced exactly that: tracing_spans read 19777 instead of 12000.

Additional Context

  • Builds on Feat/Grafana Dashboard For Rhesis #2348 (read-only Postgres datasources), Fix: Permit Rls Bypuss Grafana Viewer #2422 and fix(dev): let stg grafana-viewer bypass RLS for cross-org dashboards #2437 (bypassrls: true on grafana-viewer, without which every cross-org query returns zero rows under the usage table's FORCE ROW LEVEL SECURITY).
  • BYPASSRLS on the dev role is required for that same reason, not for convenience.
  • Limits are deliberately absent. They live in the EE tier config and the org's signed license, neither readable from SQL, so a used-vs-limit percentage is not derivable here. An org's own limits are already on its Usage page. A panel on the dashboard says so.
  • seats, projects and endpoints are absent too. They are live counts off the user, project and endpoint tables rather than accrued into usage, so they have no history to chart.
  • Alerting is out of scope. Alertmanager is disabled and Grafana has no contact points, so alert rules need a destination decision first.
  • Roles and grants are not in Terraform. terraform/infrastructure/ is GCP-only, with no postgresql provider and no postgresql_role/postgresql_grant resources anywhere, so this follows the existing manifest-and-initdb pattern rather than pioneering a new home for one role.
  • Noticed while in here, not fixed: the existing stg and prd grafana-viewer-credentials.yaml use secretKey: password, so gsm-secrets-sync.sh looks up .stg.password, which does not exist in the JSON. Their GRAFANA_VIEWER_PASSWORD entries are unreachable by that script as written, and were presumably set by hand. The dev wiring added here uses a matching name so the sync resolves it. Left the deployed ones alone.

Testing

Three prerequisites before any panel shows data. These are environment state, not code:

  1. Run ./infrastructure/config/gsm-secrets-sync.sh for dev once the real password value is filled in. That creates dev-grafana-viewer-password and binds ESO access.
  2. stg and prd need their one-time GRANT run. bypassrls: true reconciles onto a running cluster via managed.roles, but the GRANT lives in postInitApplicationSQL, which only fires on a fresh initdb. Check with SELECT has_table_privilege('grafana-viewer','public.usage','SELECT'), rolbypassrls FROM pg_roles WHERE rolname = 'grafana-viewer'; — both must be true, otherwise panels fail with permission denied for table usage.
  3. Dev's already-initialized cluster needs the CREATE ROLE and GRANT block run once by hand, same fresh-PVC caveat.

What I verified locally:

  • All six panel queries and both template-variable queries against a seeded Postgres 17: zero failures, correct numbers. Soft-deleted usage rows and deleted orgs excluded, COALESCE(display_name, name) falling back correctly for an org with no display name, month-over-month deltas correct at +200 and -280.
  • The dev role SQL exactly as it ships, against a table with FORCE ROW LEVEL SECURITY and the real tenant_isolation policy: grafana-viewer reads across all orgs, writes are refused with permission denied, and as a control an identically granted role without BYPASSRLS sees zero rows. That control is what justifies the BYPASSRLS line rather than assuming it.
  • kubectl kustomize kubernetes/clusters/{dev,stg,prd}/grafana-resources all render the dashboard alongside their own datasource, and the embedded spec.json re-parses from each rendered output. Note the render path is the grafana-resources overlay, not the cluster root, which only renders the wrapper Argo Applications.
  • helm template with dev values, confirming the init script and the GRAFANA_VIEWER_PASSWORD env var.

To check after it syncs to dev: the datasource passes Grafana's "Save & test", the dashboard appears in the Rhesis folder, the current-period table lists more than one org, and the 12-month trend is non-empty (the 91607f0dd412 and 9550c62e80a5 backfills already populated trailing months). Then cross-check one org's current-period figure against that org's own Usage page under organizations/usage. Both read the same rows, so a mismatch means a panel query is wrong.

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this is a solid approach: using the DB as the source of truth avoids the cardinality / multi-replica problems of in-process metrics, and the env-agnostic datasource variable in the dashboard makes the base placement make sense.

Main things to consider are (1) using org ids (not display names) for template variable values to avoid collisions/quoting issues, and (2) forcing UTC when computing month boundaries in SQL so “current month” is stable regardless of DB session timezone.

Found 3 issues (0 critical, 2 improvements, 1 nit).

"label": "Organization",
"type": "query",
"datasource": { "type": "postgres", "uid": "${datasource}" },
"query": "SELECT COALESCE(display_name, name) FROM public.organization WHERE deleted_at IS NULL ORDER BY 1",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement Org variable uses COALESCE(display_name, name) as the value.

If two orgs share a display name/name (or a name contains quotes), series can merge and the IN ($org) filter can get tricky.

Fix: return ids + labels (Grafana convention SELECT id AS __value, COALESCE(display_name,name) AS __text ...), and filter/group by o.id (e.g. o.id IN (${org:csv})) while still displaying the name in the legend.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One extra reason to switch the org variable/value to ids: several panels also GROUP BY / filter on COALESCE(o.display_name, o.name), so two orgs sharing a display_name would get merged into one series/row.

Using SELECT id AS __value, COALESCE(display_name,name) AS __text ... and grouping/filtering by o.id keeps the labels nice while guaranteeing correctness.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed now: org templating query returns id AS __value + __text, so filters/grouping can safely key on o.id without name collisions.

"editorMode": "code",
"rawQuery": true,
"format": "table",
"rawSql": "SELECT\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_executions'), 0) AS \"Test executions\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'tracing_spans'), 0) AS \"Tracing spans\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'test_generation'), 0) AS \"Tests generated\",\n COALESCE(SUM(u.used) FILTER (WHERE u.resource = 'model_tokens'), 0) AS \"Model tokens\"\nFROM public.usage u\nJOIN public.organization o ON o.id = u.organization_id\nWHERE u.deleted_at IS NULL\n AND o.deleted_at IS NULL\n AND u.period_start = date_trunc('month', now())::date"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement Month boundaries use date_trunc('month', now()) / now().

The dashboard pins Grafana timezone to UTC, but the SQL month calculation uses the DB session timezone (which isn’t configured to UTC in stg/prd CNPG manifests). That can shift “this month” around UTC midnight.

Fix: compute bounds in UTC in SQL (e.g. date_trunc('month', now() AT TIME ZONE 'UTC')::date and similarly for prev month), or ensure the datasource/session sets TimeZone=UTC.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this looks fixed now with date_trunc('month', now() AT TIME ZONE 'UTC') / UTC bounds.

One remaining timezone-dependent spot: the trend panels use u.period_start::timestamptz for the time column (and $__timeFilter(u.period_start) on a date). If the DB session TimeZone isn’t UTC, the rendered timestamps can shift across UTC day/month boundaries. Consider anchoring those as UTC too, e.g. u.period_start::timestamp AT TIME ZONE 'UTC' (and apply the macro to the same expression).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fcc6b09/a7b8b02: month bounds are now computed with now() AT TIME ZONE 'UTC', so “this month” won’t depend on the DB session TimeZone.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks fixed now: trend panels anchor the plotted time via u.period_start::timestamp AT TIME ZONE 'UTC'.

One small follow-up: the queries still use AND $__timeFilter(u.period_start) (a date). To avoid reintroducing session-TimeZone casts at the range boundaries, it’d be safer to apply the macro to the same UTC expression, e.g. AND $__timeFilter(u.period_start::timestamp AT TIME ZONE 'UTC').

matchLabels:
dashboards: grafana
datasource:
name: "Rhesis DEV Postgres (RW)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit Datasource name says (RW) but the role is intended to be read-only.

Fix: rename to something like Rhesis DEV Postgres (RO) to avoid confusion when selecting datasources.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Confirmed: datasource name is now Rhesis DEV Postgres (RO), which matches the intended read-only role.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed fixed: datasource is now labeled (RO).

Usage accrues per org into the `usage` table, but the only view of it is
the in-app Usage page, which shows one org its own numbers. This reads
across every tenant: platform totals, per-org breakdown, top consumers,
and a 12-month trend.

SQL rather than Prometheus, because current usage is database state, not
process state. The backend runs several replicas, so gauges updated on
increment would each hold a different subset of it, and there is no
Celery beat process to resync them. Grafana already has a read-only
Postgres datasource, so the data is one query away.

Lives in base/ even though each env names its datasource differently,
because the datasource is chosen by a template variable. That variable
queries type "postgres", which is what all three GrafanaDatasource CRs
declare, so it matches whether or not the running Grafana resolves the
newer grafana-postgresql-datasource plugin alias.

Every panel joins organization and filters deleted_at on both sides.
Without the join, a deleted org's live rows inflate the platform total
and make it disagree with the per-org table directly below it.

Limits are absent on purpose: they live in the EE tier config and the
org's signed license, neither of which is readable from SQL. seats,
projects and endpoints are absent too, being live counts with no history
to chart. Both are called out in a panel on the dashboard.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
The stg and prd datasources landed in #2348, but dev never got one, so
the shared dashboard in base/grafana-resources has nothing to query
there, which is the environment you would want to check it in first.

Dev has no CNPG, so the role cannot come from managed.roles. It is
created by a Bitnami initdb script instead, mirroring how rhesis-user
and rhesis-analytics-user are already created there.

BYPASSRLS is required, not convenience: `usage` is FORCE ROW LEVEL
SECURITY and Grafana never sets app.current_organization, so without it
every cross-org query returns zero rows. Verified against a scratch
Postgres, where an identically granted role without BYPASSRLS sees zero
rows while this one reads every tenant and is still refused writes.

GRAFANA_VIEWER_PASSWORD was the one env missing from
gsm-secrets.json.example, and the name matches the ExternalSecret's
secretKey so gsm-secrets-sync.sh can resolve it. That makes the GSM
secret and its ESO IAM binding a sync run rather than a manual step.

initdb only runs on a fresh PVC, so the already-initialized dev cluster
still needs the CREATE ROLE and GRANT block run once by hand. Same
caveat the stg and prd manifests already carry for their own grants.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
@harry-rhesis
harry-rhesis force-pushed the feat/usage-grafana-dashboard branch from 3b407e6 to 812534b Compare August 13, 2026 13:54
Three fixes from review on #2476.

date_trunc('month', now()) truncates in the DB session TimeZone, which no
CNPG manifest sets, so it was relying on the Postgres image happening to
default to Etc/UTC. Under a non-UTC session the month boundary moves: at
00:30 UTC on the 1st with TimeZone=America/Los_Angeles it reports the
previous month. Periods are UTC-anchored calendar months, so the SQL now
says so explicitly and matches services/usage.py:_current_period.

The $org filter used bare interpolation, which quotes each value without
escaping quotes inside it, so an organization named like O'Brien Corp
turned panel 5 into a syntax error. ${org:sqlstring} escapes properly.

Renamed the dev datasource from (RW) to (RO). In stg and prd the suffix
says which CNPG service is targeted, but dev has no CNPG and so no
-rw/-ro services, leaving the role's SELECT-only grant as the only thing
the suffix can usefully describe there.

Signed-off-by: Harry Cruz <harry@rhesis.ai>

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Month-boundary fix to UTC looks good.

Improvement: consider scoping grafana-viewer privileges tighter than SELECT ON ALL TABLES (esp. combined with BYPASSRLS).

Improvement: in trend panels, u.period_start::timestamptz can still shift timestamps by DB session timezone—anchor as UTC.

Improvement: use org ids for the org template var + grouping/filtering to avoid merging orgs with the same display name.

Found 3 issues (0 critical, 3 improvements).

--username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<'EOSQL'
GRANT CONNECT ON DATABASE :"postgres_db" TO "grafana-viewer";
GRANT USAGE ON SCHEMA public TO "grafana-viewer";
GRANT SELECT ON ALL TABLES IN SCHEMA public TO "grafana-viewer";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement grafana-viewer gets GRANT SELECT ON ALL TABLES IN SCHEMA public + default privileges for future tables.

That’s broader than this dashboard needs (usage + organization) and, combined with BYPASSRLS, means a Grafana compromise can read any table in public across all tenants.

Fix: if feasible, scope privileges to the minimal set (e.g. GRANT SELECT ON TABLE public.usage, public.organization TO "grafana-viewer"; and avoid granting default privileges broadly), or document explicitly why full-schema read is required.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed this matches the existing stg/prd pattern (CNPG postInitApplicationSQL also grants SELECT ON ALL TABLES IN SCHEMA public + default privileges).

Still worth calling out as a conscious tradeoff: BYPASSRLS + full-schema SELECT means a Grafana compromise can read any tenant data in public. If scoping isn’t feasible operationally (because usage is created later), maybe add an explicit comment in the script explaining why the broader grant is intentional.

},
{
"id": 5,
"type": "timeseries",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement Trends panel casts u.period_start::timestamptz.

period_start is a date; casting to timestamptz interprets midnight in the DB session timezone, which can shift the plotted timestamp away from the intended UTC month boundary.

Fix: anchor as UTC, e.g. u.period_start::timestamp AT TIME ZONE 'UTC' AS "time" (and apply $__timeFilter to the same expression).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: period_start is now anchored as UTC (::timestamp AT TIME ZONE 'UTC').

Same minor note as above: consider changing AND $__timeFilter(u.period_start) to AND $__timeFilter(u.period_start::timestamp AT TIME ZONE 'UTC') so the filter uses the same timezone/type as the plotted time.

"fieldConfig": {
"defaults": { "unit": "short", "decimals": 0, "custom": { "align": "auto", "filterable": true } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Organization" }, "properties": [{ "id": "custom.width", "value": 260 }] },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improvement Org filter/groups use COALESCE(o.display_name, o.name).

If two orgs share a display name (or a name changes), series/rows can merge and filtering becomes ambiguous.

Fix: switch the org template var to ids (SELECT id AS __value, COALESCE(display_name,name) AS __text ...) and filter/group by o.id (while still using the name only for display/legend).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is addressed now that the org variable is ID-valued and the panels group by o.id / filter on o.id (vs grouping/filtering by COALESCE(...)).

Two more from review, both the same shape as the fixes in the previous
commit: an implicit dependency on the DB session TimeZone, and a value
that is not as unique as it looks.

period_start is a date, and casting a date to timestamptz reads midnight
in the session TimeZone. East of UTC that moves the point into the
previous month: under Asia/Tokyo the August bucket plotted at
2026-07-31 15:00 UTC. Casting to timestamp and attaching UTC puts it back
on the month boundary it belongs to.

The org variable used COALESCE(display_name, name) as its value, and four
panels grouped or filtered on the same expression. organization.name has
no unique constraint and display_name is free text, so two orgs sharing a
display name were silently summed into a single row. The variable now
returns ids with names as labels, and grouping is on o.id. Verified with
two orgs deliberately sharing a display name: they stay separate rows of
11 and 22 rather than merging into 33.

One limit worth knowing: Grafana keys time-series by the metric value, so
same-named orgs still share a legend entry in the trend panel. The tables
and the filter are correct; only that legend is ambiguous, and making it
unique would mean appending an id fragment to every label.

Signed-off-by: Harry Cruz <harry@rhesis.ai>
@peqy

peqy Bot commented Aug 13, 2026

Copy link
Copy Markdown

Most of the earlier issues are addressed now (org variable keyed on id, month bounds/time columns anchored to UTC, dev datasource labeled RO).

Remaining small improvement: in the trend queries you anchor "time" as UTC, but still use AND $__timeFilter(u.period_start) on the date column—consider applying $__timeFilter to the same UTC expression (u.period_start::timestamp AT TIME ZONE 'UTC') to keep boundaries consistent.

The grafana-viewer grants look intentionally broad and match stg/prd (full-schema SELECT + BYPASSRLS); worth keeping an explicit comment noting this is a conscious tradeoff.

The plotted time column was anchored to UTC in the previous commit but the
range filter still compared the bare `date` column, and Postgres converts
a date to timestamptz using the session TimeZone. So the two disagreed at
the edges: with a range starting at 2026-08-01T00:00Z under Asia/Tokyo,
the August bucket converts to 2026-07-31T15:00Z, falls before the range
start, and drops off the chart entirely.

I had left this on the grounds that wrapping the column costs an index
scan. That was wrong: `usage` has no index on `period_start` alone, only
the (organization_id, resource, period_start) unique constraint where it
is the third column and unusable for a standalone range scan. There was
no scan to preserve.

Also spelled out why grafana-viewer gets full-schema read rather than a
grant on `usage` and `organization`: default privileges cannot name tables
that do not exist yet, and at initdb time none of them do. #2479 carries
the question of whether that posture is right across all three envs.

Signed-off-by: Harry Cruz <harry@rhesis.ai>

@peqy peqy Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most earlier issues are addressed now (org variable keyed on id, month bounds + trend timestamps anchored to UTC, and $__timeFilter applied to the same UTC expression). The dev datasource/secret wiring and initdb role script look consistent with the existing stg/prd pattern.

Ship it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant