- How it fits together
- Prerequisites
- Why Postgres (and why Neon)
- 1. Create a Neon database
- 2. Set the secrets
- 3. Deploy the workflow and seed history
- 4. Point Grafana at Postgres
- 5. Import the dashboard
- Show emails instead of usernames
- What lands in the database
- Privacy
- Gotchas
- Related
Copilot Metrics & Billing Dashboards in Grafana
Last updated: October 20, 2026
This is an optional add-on to Pulling Copilot Metrics & Billing Into Your Data Lake. That guide gets the raw usage JSON and billing CSV out of GitHub and into files. This one turns those files into a live dashboard: the daily Action pushes per-day summaries into a free Postgres database, and Grafana reads from Postgres.
If you just want the raw data in your own warehouse, stop at the base guide — you don’t need any of this. Reach for this page when you want charts without building your own BI layer.
How it fits together
GitHub Actions (daily cron)
├─ collect job (holds the App key)
│ ├─ aggregate, user, user-team, and repository usage JSON
│ ├─ billing CSV
│ └─ upload as a 90-day artifact (backup only)
└─ load-to-postgres job (NO GitHub creds — only DATABASE_URL)
├─ normalize aggregate rows for Grafana
└─ retain every usage and billing source row
Postgres (Neon) ──SELECT──▶ Grafana dashboards
The Action writes to Postgres and Grafana only ever reads from it, so
Grafana never holds a GitHub credential. The App key stays in the collect
job; the load-to-postgres job sees only the already-collected files and the
database connection string.
Note
GitHub App access to enterprise billing became available on August 26, 2026. The release simplifies collection authentication; it does not change the Postgres schema, loader, retention model, or dashboard.
Note
The base guide’s scripts/
are meant for quickly testing the APIs and your credentials from a laptop.
This add-on is the durable pipeline: it ships its own self-contained copies
under grafana/scripts/
plus the loader, the workflow, and the dashboard.
Prerequisites
You need the Enterprise GitHub App from the base guide with read access to Copilot metrics and enterprise billing. If you haven’t created it, start with Set up the Enterprise GitHub App. This page adds one thing on top: a Postgres database.
Note
Want emails instead of usernames? The dashboards can label per-user panels with members’ email addresses instead of GitHub usernames. To enable that, add the Enterprise SCIM: read permission to the same Enterprise GitHub App you already created, and install the App at enterprise scope. This is optional: if the permission is missing, the pipeline still runs and the dashboards fall back to usernames. See Show emails instead of usernames.
Why Postgres (and why Neon)
Grafana is only a visualization layer; it needs a datastore behind it. Two traps to avoid:
- Workflow artifacts (what the base guide uploads) expire and Grafana can’t query them — they’re a backup, not a datasource.
- Grafana Cloud’s own time-series database (Prometheus/Mimir) looks native, but its free tier retains only ~14 days. For adoption trends that’s useless.
A small SQL database sidesteps both. Neon is fully-managed serverless Postgres with a free tier and no time-based expiry, so a daily job builds history indefinitely — nothing to host.
Note
“No expiry” is not “unlimited space.” The free tier has a fixed storage cap
(currently ~0.5 GB). The daily aggregate rows are tiny, but
copilot_usage_raw and copilot_billing_raw keep every source row and grow
with your user count.
Watch your database size in the Neon console; if it climbs, set a retention
policy on copilot_usage_raw, copilot_billing_raw, and
copilot_billing_user (see Privacy).
1. Create a Neon database
- Sign up at neon.tech (free, no card) and create a project — Neon provisions a Postgres database.
-
Copy the connection string from the project dashboard. It looks like:
postgresql://USER:PASSWORD@HOST/DBNAME?sslmode=require
Two Neon quirks worth knowing up front:
-
No port in the string. Postgres uses
5432implicitly. You still enterHOST:5432wherever a tool asks for host and port separately (Grafana, below). - Cold start. Compute auto-suspends after ~5 min idle, so the first query after a lull takes a few seconds to wake. Neon doesn’t pause the project on inactivity, so a daily job is safe.
2. Set the secrets
In the repo that will host the workflow: Settings → Secrets and variables → Actions. The App ID / installation ID are identifiers (Variables); the key and connection string are sensitive (Secrets).
| Kind | Name | Value |
|---|---|---|
| Variable | ENTERPRISE |
your enterprise slug |
| Variable | COPILOT_APP_ID |
the App ID |
| Variable | COPILOT_INSTALLATION_ID |
the installation ID |
| Secret | COPILOT_APP_PRIVATE_KEY |
the App’s .pem contents |
| Secret | DATABASE_URL |
the Neon connection string from step 1 |
gh variable set ENTERPRISE --body "$ENTERPRISE"
gh variable set COPILOT_APP_ID --body "$APP_ID"
gh variable set COPILOT_INSTALLATION_ID --body "$INSTALLATION_ID"
gh secret set COPILOT_APP_PRIVATE_KEY < ./app.pem
gh secret set DATABASE_URL <<< "$DATABASE_URL"
Important
The App key can mint tokens that read enterprise-wide metrics, billing, and optional SCIM data. The 90-day artifact contains identifiable user usage, team membership, and per-user billing data. Host this workflow in a dedicated private repo with a protected default branch and minimal write access, and restrict who can download its artifacts.
3. Deploy the workflow and seed history
Copy the workflow and the four scripts into your repo so they land at these
paths (the workflow’s SCRIPTS_DIR defaults to scripts):
.github/workflows/copilot-metrics-collection.yml ← grafana/copilot-metrics-collection.yml
scripts/copilot-usage-metrics.sh ← grafana/scripts/copilot-usage-metrics.sh
scripts/copilot-billing-export.sh ← grafana/scripts/copilot-billing-export.sh
scripts/copilot-scim-users.sh ← grafana/scripts/copilot-scim-users.sh
scripts/load_metrics.py ← grafana/scripts/load_metrics.py
copilot-scim-users.sh is what reads members’ emails; include it even if you
haven’t granted SCIM access yet, since its workflow step is optional and skips
when the permission is absent.
Grab them from the
grafana/
folder, commit, and push:
base=https://raw.githubusercontent.com/samqbush/copilot-adoption/main/copilot-metrics-billing/grafana
mkdir -p .github/workflows scripts
curl -fsSL "$base/copilot-metrics-collection.yml" -o .github/workflows/copilot-metrics-collection.yml
curl -fsSL "$base/scripts/copilot-usage-metrics.sh" -o scripts/copilot-usage-metrics.sh
curl -fsSL "$base/scripts/copilot-billing-export.sh" -o scripts/copilot-billing-export.sh
curl -fsSL "$base/scripts/copilot-scim-users.sh" -o scripts/copilot-scim-users.sh
curl -fsSL "$base/scripts/load_metrics.py" -o scripts/load_metrics.py
git add .github/workflows/copilot-metrics-collection.yml scripts/
git commit -m "Add Copilot metrics + Grafana/Postgres collection workflow"
git push
The load-to-postgres job creates the tables on first run — the database
stays empty until then. Seed history once, then let the nightly cron take over:
# one-time: backfill the last 28 days (the most one run collects)
gh workflow run copilot-metrics-collection.yml -f backfill_days=28
Note
GitHub only shows Run workflow (and runs the cron) for workflows on the
repo’s default branch. Merge to default first, or dispatch against your
branch with gh workflow run … --ref <branch>.
After the run, the nightly cron (05:17 UTC) collects all four usage report families plus billing for yesterday. Re-running any day replaces that report family’s raw rows and upserts its inventory and aggregate rows.
4. Point Grafana at Postgres
Works on Grafana Cloud (free) or any Grafana. Connections → Data sources → Add data source → PostgreSQL:
-
Host: the
HOSTfrom the connection string with:5432appended -
Database:
DBNAME - User / Password: from the connection string (prefer the read-only role in Privacy)
-
TLS/SSL Mode:
require - Save & test.
5. Import the dashboard
- Grafana → Dashboards → New → Import.
- Upload
grafana/dashboard.json(or paste its contents). - When prompted for the
pgdatasource variable, pick the PostgreSQL datasource from step 4.
The dashboard groups panels into rows — adoption KPIs, spend, power users (these
show emails when available, otherwise usernames), activity, lines of code, pull
requests, CLI, code-review adoption, usage breakdowns, and a Spend detail
row. Its Top N, Model, Cost center, and User variables filter the
power-user and per-model panels. The time picker defaults to the last 90 days.
Tip
Billed (net) spend is often $0. Enterprise quota and discounts usually
cover the metered value, so net_amount reads $0 on most days — expected,
not a bug. To see consumption, read the Spend detail row (gross vs.
discount vs. net), or set BILLING_COST_COLUMN=gross_amount in the workflow to
make the canonical spend column track metered value.
Show emails instead of usernames
The power-user panels key on the GitHub username from the billing data. To label them with members’ email addresses instead, the collector reads the SCIM API and stores a username→email map that the dashboards join on.
- Grant the App SCIM read. Add Enterprise permissions → Enterprise SCIM: Read-only to the same Enterprise GitHub App you created in the base guide, then re-accept the permission on the enterprise installation. The App must be installed at enterprise scope.
-
Re-run the workflow. The next run collects the SCIM snapshot and loads it
into
copilot_enterprise_users. Dashboards then displayCOALESCE(email, username)— email where mapped, username otherwise.
This is optional and non-fatal: if the permission is missing (or SCIM read fails), that step just logs a warning and the rest of the pipeline still runs with dashboards falling back to usernames.
Note
EMU handle mismatch. On Enterprise Managed Users, the billing username is
an EMU handle like <idp-name>_<shortcode> while the SCIM userName is the
IdP identity (often the email). The loader reconstructs the handle using an
enterprise shortcode that defaults to your enterprise slug. If your
shortcode differs, set an ENTERPRISE_SHORTCODE repo variable to override
it. Users the loader can’t match still render as their username.
Matching is best-effort: it also falls back to email/username local-parts, so
two members whose local-parts collide could in theory share a label. The loader
logs a warning and keeps the first match when that happens.
What lands in the database
load_metrics.py creates and fills these tables (full column reference in the
loader’s docstring):
| Table | Holds | Identities? |
|---|---|---|
copilot_usage (+ copilot_usage_ide / _feature / _model_feature / _language_model / _language_feature / _adoption_phase) |
per-day usage totals and aggregate breakdowns | no |
copilot_usage_report |
collection inventory and metadata by day/report family | no |
copilot_usage_raw |
every aggregate, user, user-team, and repository source row as JSONB | yes |
copilot_billing |
enterprise spend per day (net/gross/discount) + token totals | no |
copilot_billing_model |
spend and tokens per model per day | no |
copilot_billing_user |
spend and tokens per user/model/day | yes (usernames) |
copilot_enterprise_users |
SCIM username→email map (for email labels) | yes (emails) |
copilot_billing_raw |
one row per billing CSV row, full row as JSONB | yes (highest fidelity) |
The user, user-team, and repository report families are retained for downstream
analysis but are not normalized into new dashboard panels here. Aggregate tables
and copilot_usage_report are safe for broad dashboards. copilot_usage_raw,
copilot_billing_user, copilot_enterprise_users, and copilot_billing_raw
are identifiable; treat them as sensitive.
If you already created the grafana_ro role from an older version of this
guide, grant access to the new inventory table after the next workflow run:
GRANT SELECT ON copilot_usage_report TO grafana_ro;
REVOKE ALL ON copilot_usage_raw FROM grafana_ro;
The three spend tables also carry input_tokens, output_tokens,
cache_read_tokens, and cache_write_tokens, summed from the report’s token
columns. Put them next to cost and you can see which models burn tokens rather
than just dollars; on agent-heavy days cache reads dwarf everything else. Days
collected before those columns existed keep them NULL, so a flat line there
means the data was never collected.
Privacy
This pipeline deliberately stores identifiable user usage, team membership, and
per-user billing detail. Enabling emails through SCIM adds member email
addresses in copilot_enterprise_users. Handle all of these tables accordingly.
The bundled dashboard’s power-user panels and the User / Cost center
variables read copilot_billing_user (and, for email labels,
copilot_enterprise_users), so a Grafana role that can’t see those tables will
make those panels error rather than hide. Decide who should see usernames and
emails, then grant to match. A least-privilege read-only role that powers the
full dashboard but can never touch the raw table:
-- Run AFTER the first workflow run has created the tables.
CREATE ROLE grafana_ro LOGIN PASSWORD 'choose-a-strong-password';
GRANT CONNECT ON DATABASE <dbname> TO grafana_ro;
GRANT USAGE ON SCHEMA public TO grafana_ro;
-- Everything the dashboard and coverage checks need, except the raw tables:
GRANT SELECT ON
copilot_usage, copilot_usage_report,
copilot_usage_ide, copilot_usage_feature,
copilot_usage_model_feature, copilot_usage_language_model,
copilot_usage_language_feature, copilot_usage_adoption_phase,
copilot_billing, copilot_billing_model, copilot_billing_user,
copilot_enterprise_users
TO grafana_ro;
-- Never expose raw usage or billing JSONB, and don't auto-grant future tables:
REVOKE ALL ON copilot_usage_raw, copilot_billing_raw FROM grafana_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM grafana_ro;
Then use grafana_ro in the Grafana datasource (step 4) instead of the owner
credentials.
-
Don’t want usernames or emails in Grafana at all? Omit
copilot_billing_userandcopilot_enterprise_usersfrom theGRANTabove, delete the “Power users” dashboard row, and remove theUserandCost centertemplate variables (they query the per-user table and would otherwise error on load) — the aggregate panels keep working. - Restrict the dashboard. Use Grafana folder/team permissions so only the right people can open the power-user panels.
-
Emails are PII.
copilot_enterprise_usersalso carries each member’s SCIM display name and external ID. Limit who can read it, and set a retention policy if you don’t need to keep the mapping. -
Never point Grafana at either raw table.
copilot_usage_rawandcopilot_billing_rawpreserve every source field as JSONB, so fields GitHub adds later can silently widen exposure. Review them periodically and set a retention policy if you do not need unlimited identifiable history. - Artifacts carry the same data. The 90-day workflow artifact includes raw usage reports, the per-user billing CSV, and the SCIM email snapshot when enabled. Restrict who can download it.
Gotchas
- Empty billing days leave a chart gap. A day with no billing activity produces an empty export, which the loader skips (no zero row). Widen the time range or read the usage panels if a recent day looks missing — and remember “latest spend” reflects the most recent day that had activity.
-
One billing report per enterprise at a time. A second export while one is
running returns
409. The workflow serializes its own runs, but a manual export or another repo sharing the enterprise can still collide — retry later. - One enterprise per database. The dashboard doesn’t filter by enterprise slug, so point each enterprise at its own Neon database (or add an enterprise filter to every query).
- Run against the prior complete day. “Today” isn’t fully processed; the workflow defaults to yesterday (UTC).
-
A green aggregate report does not prove full coverage. Check
copilot_usage_reportfor aggregate, user, user-team, and repository inventory; the workflow reports a failure if any family could not be collected. -
Usage vs. billing model names differ (
claude-opus-4.6vs.Auto: GPT-5.3-Codex). They’re separate axes — don’t join them; theModelfilter is sourced from billing only.
Related
- Pulling Copilot Metrics & Billing Into Your Data Lake — the base guide this builds on (credentials, endpoints, the daily pull).
- Managing Copilot usage-based billing — budgets, AI Credits, and keeping spend predictable.