Skip to the content.

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:

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

  1. Sign up at neon.tech (free, no card) and create a project — Neon provisions a Postgres database.
  2. 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:


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:


5. Import the dashboard

  1. Grafana → Dashboards → New → Import.
  2. Upload grafana/dashboard.json (or paste its contents).
  3. When prompted for the pg datasource 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.

  1. 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.
  2. Re-run the workflow. The next run collects the SCIM snapshot and loads it into copilot_enterprise_users. Dashboards then display COALESCE(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.


Gotchas