Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497)

* feat(telemetry): D1 schema + migrations for raw events and daily rollups

First step of replacing PostHog with self-hosted telemetry on Cloudflare D1.
Creates the codegraph-telemetry database binding and the initial migration; no
worker code paths change yet (the ingest write path and the nightly rollup cron
land next).

Schema is raw events plus daily rollups: `events` holds one row per sanitized
event with the envelope broken out into columns and event-specific props as
JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the
nightly rollups the dashboard reads; `machine_first_seen` and `machine_days`
carry the retention cohorts and are never purged. One generic dimension table
covers every bar and pie, so a new breakdown is a cron change rather than a
migration.

The migration is commented as an audit surface, like the rest of this worker —
every column, and which dashboard chart each rollup table serves.

Three judgment calls worth flagging, all documented in the file:

- `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)`
  indexes. D1 bills a row write per index touched, so a third index on the hot
  table costs ~97k writes/day, and `(day, event)` is a covering index for plain
  day-range scans anyway (verified with EXPLAIN QUERY PLAN).
- `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and
  `machine_days` a `prod` flag. The "users by ..." panels and the production-user
  count are distinct-machine numbers, not event counts, and they are
  unrecoverable once raw events are purged.
- No CHECK constraint on `event`: the worker's allowlist is the source of truth
  and the write path is fail-silent, so a rejected INSERT would lose data
  quietly instead of erroring loudly.

Volume note in the migration footer: ~30M row writes/month against the 50M
included on Workers Paid. Storage is the tighter constraint — raw events grow
~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180,
which would exceed D1's 10 GB per-database cap.

* feat(telemetry): admin dashboard worker — scaffold + shared-password auth

New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and
bound read-only to the same D1 database. Serves a static frontend plus a JSON
API behind a shared password, on stats.getcodegraph.com.

Auth is the simplest thing that is actually safe for exactly two users: one
password in a secret, compared in constant time over SHA-256 digests, and an
HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year
expiry so you sign in once per browser. The cookie is a signed assertion, not a
lookup key — no session store. Its payload carries a fingerprint of the password
it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login
attempts are capped at 5/min per IP via a ratelimit binding.

Everything is deny-by-default: assets.run_worker_first routes every request
through the worker before the static-asset server sees it, so the dashboard
HTML, its JS, its CSS and the chart library are all behind the session check.
The login page is rendered inline by the worker rather than served from public/,
which leaves no "is this file public?" judgement calls in the asset directory.
Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing
secret fails closed rather than opening the dashboard.

scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway
`wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/
truncated cookies, open-redirect refusal, brute-force capping, and password
rotation invalidating live sessions.

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(telemetry-dashboard): simplify the chart-library probe in the shell

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10)

Adds a scheduled() handler to the ingest worker that recomputes
daily_event_counts / daily_dim_counts / daily_machines for the just-completed
UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw
events past the retention window. Rollup writes are idempotent upserts, so a
re-run never double-counts. Also adds an ADMIN_TOKEN-guarded
POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog
forwarding path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13)

Replaces the scaffold page with the dashboard proper: 19 panels covering every
view of the PostHog dashboard this retires, driven by one filter row.

src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries,
breakdown,activation,retention}, all range-scoped, all parameterized against a
closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend
does no arithmetic. Rollups answer everything except the activation funnel,
which needs raw events and says where they start.

The frontend splits into a DOM-free panel registry (public/panels.js) and the
page that mounts it (public/app.js), so the render check can drive the same
registry the browser rendered from. Panels fail alone, refetch dims rather than
flashing, and every chart carries a table twin.

Two numbers are labelled rather than rounded off: range-wide "users" per
dimension is machine-days (the rollups cannot give distinct machines, and
per-day counts are taken as the largest single-event count so one machine's
install + index + usage is not counted three times), and recent activation and
retention cohorts are marked as still-converting instead of drawn as a cliff.

Both colour scales were run through the data-viz validator against the panel
surface, not picked by eye; the results are recorded in public/theme.js.

Verification, all against the committed fixture (12 machines over 10 days, every
expected number worked out by hand from the events, not recorded from a run):
  scripts/smoke-api.sh      98 assertions
  scripts/render-check.mjs  79 assertions — real Chromium over CDP, no new deps
  scripts/smoke-auth.sh     54 assertions (unchanged, still green)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14)

The account-level steps of the PostHog cutover are the maintainer's to run, so
this lands the runbook they follow and the check that has to pass first.

The runbook (telemetry-worker/README.md) walks the six steps in the order that
keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the
first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the
subscription. Step 3 records the outgoing version id because `wrangler rollback`
is the escape hatch for the whole verification window, and that window is
precisely why the PostHog key is deleted last rather than first.

The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one
seam nothing else did. Both workers declare the same D1 database_id, so pointing
them at a single --persist-to directory runs the real chain: a client batch →
the ingest worker → D1 → the nightly rollup → the dashboard API reading the
numbers back. Every other suite stops at one link — smoke-ingest at the events
table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture
that the cron never touched. That left the dimension names the rollup WRITES
versus the ones the dashboard READS agreeing by convention across two branches,
where a mismatch is silent: no error, no failed request, just a panel reading
zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a
ci machine that is active but not a production user, usage_rollup counts that
must be summed rather than tallied, and an uninstall's `targets` that must not
leak into the install-scoped breakdown.

Writing it caught that the activation funnel's denominator is first-seen
machines, not install events (deliberate — a reinstall must not re-enter the
funnel), so the suite now pins that distinction rather than assuming it.

Also rewords the last PostHog reference in dashboard code: a comment justifying
the 14-day retention curve by pointing at a dashboard step 6 deletes. The
reasoning now stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(telemetry): tell the truth about where events are stored (CG-15)

The telemetry docs are a privacy contract, and they still described a
managed analytics store that no longer receives anything. Replace that
with what actually happens now — events land in our own D1 database on
Cloudflare, the endpoint makes no outbound requests, raw events are
purged after 90 days and only anonymous daily rollups outlive them.
This strengthens the guarantee rather than restating it: there is no
second party to share with.

- TELEMETRY.md: new "Where it is stored" section; the never-collected
  IP bullet no longer leans on a vendor-side setting to hold.
- docs/design/telemetry.md: ingest section rewritten around D1 + the
  nightly rollup/retention cron; volume math redone on Workers Paid and
  the D1 quota (storage, not writes, is what sets the 90-day window);
  new section documenting the dashboard worker and cross-linking it.
- Fixed three drifts from the worker allowlist the sweep surfaced:
  schema_version was still 1, client_name/client_version was still
  marked "plumbing to add" though session.ts passes it today, and the
  legacy sqlite_backend field the worker still accepts was undocumented.
- telemetry-worker/README.md: step 6 claimed a repo-wide grep came back
  clean, which this runbook itself falsifies. Added step 7 — deleting
  the runbook is what makes that grep true, and is the completion check.
- smoke-cutover.sh: the vendor guarantee is now asserted by class
  (no analytics-ingest endpoint referenced) rather than by one vendor's
  name, so it keeps working once the name is gone. Verified it still
  catches a planted forwarding URL. 61/61 pass.

Retention is documented as 90 days, not the 180 in the task notes: 180
days of raw events exceeds D1's 10 GB per-database cap, and the code
purges at 90.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: untrack local Kommandr issue DB and ignore its sqlite artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-08-01 16:17:10 -05:00
committed by GitHub
parent f6ac7b36e6
commit 49c11fc2e0
39 changed files with 7683 additions and 88 deletions
+3
View File
@@ -76,3 +76,6 @@ __tests__/zz-scratch*
# linux-arm64 kernel cross-build cache (rust:1-bookworm builder)
target-linux/
.kommandr/kommandr.db
.kommandr/kommandr.db-wal
.kommandr/kommandr.db-shm
Binary file not shown.
+4
View File
@@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### New Features
- Anonymous usage telemetry is now stored entirely on CodeGraph's own first-party infrastructure — no third-party analytics vendor receives any of it, and the endpoint that receives it makes no outbound requests at all. Individual events are deleted after 90 days, leaving only anonymous daily totals. Nothing about what is collected changed, your IP address is still never read or stored, and every off-switch works exactly as before (`codegraph telemetry off`, `CODEGRAPH_TELEMETRY=0`, `DO_NOT_TRACK=1`). `TELEMETRY.md` remains the complete field-by-field list.
### Fixes
- A CodeGraph process that gets force-killed — by the stuck-process watchdog, a crash, or the OS — no longer leaves the database's write-ahead log behind to grow without bound. Previously each killed session stacked more data onto the same log file and nothing ever shrank it, which on machines where sessions were killed regularly could quietly eat tens of gigabytes of disk. The log is now capped, and any oversized leftover is reclaimed automatically the next time the project is opened. Thanks @tiendungdev for the exceptional Windows report that pinned this down. (#1431)
+25 -8
View File
@@ -70,8 +70,8 @@ per-call event stream, and nothing is sent in real time.
- **No source code.** No file paths, file names, directory names, repository names or
URLs, symbol names, search queries, or anything else derived from the contents of an
indexed project.
- **No IP addresses.** The ingest endpoint never reads, logs, or forwards the client IP,
and IP discarding is enabled at the analytics backend on top of that. No geolocation.
- **No IP addresses.** The ingest endpoint never reads, logs, or stores the client IP
and there is no analytics vendor downstream that could. No geolocation.
- **No fingerprinting.** The machine ID is a random UUID stored in
`~/.codegraph/telemetry.json` — delete that file (or run `codegraph telemetry off`,
then `on`) and the old ID is gone forever, with no way to reconnect it.
@@ -81,12 +81,29 @@ per-call event stream, and nothing is sent in real time.
Events POST to `telemetry.getcodegraph.com` — a first-party endpoint whose complete
source lives in [`telemetry-worker/`](telemetry-worker/) in this repository. It validates
every event and property against the allowlist above (anything else is dropped), strips
IPs, rate-limits, and forwards to a managed analytics store (PostHog, US region) as
anonymous events. Sends are fire-and-forget with a short timeout: offline or air-gapped
machines buffer a bounded local file (256 KB cap) and never retry-loop, log errors, or
slow a command down. Telemetry never adds latency to MCP tool calls — recording is an
in-memory counter.
every event and property against the allowlist above (anything else is dropped), never
reads the client IP, and rate-limits per machine ID. Sends are fire-and-forget with a
short timeout: offline or air-gapped machines buffer a bounded local file (256 KB cap)
and never retry-loop, log errors, or slow a command down. Telemetry never adds latency to
MCP tool calls — recording is an in-memory counter.
## Where it is stored
Accepted events are written to **our own database on Cloudflare** (D1) and go nowhere
else. **No third-party analytics vendor receives any of this data**, because the ingest
endpoint makes no outbound requests at all — its source is the entire path your events
take, and there is nothing after it. This is a stronger guarantee than a promise not to
share: there is no second party to share with.
What is kept is checkable rather than asserted. The storage schema —
[`telemetry-worker/migrations/0001_init.sql`](telemetry-worker/migrations/0001_init.sql),
checked in beside the endpoint that writes it — is the complete list of what a row can
hold, with a comment on every column.
Individual events are **deleted after 90 days**. What outlives them is anonymous daily
totals: counts per day of things like operating system, version, and language, plus which
days each machine ID was active so returning-user numbers survive. No event details, and
still nothing that identifies a person or a codebase.
The engineering contract behind all of this — including the rule that schema changes must
update this page, the client, and the public endpoint in one PR — is in
+83 -25
View File
@@ -1,8 +1,9 @@
# Anonymous usage telemetry
Status: implemented — ingest Worker (`telemetry-worker/`), client (`src/telemetry/`),
`codegraph telemetry` CLI, MCP + installer wiring, `TELEMETRY.md`. Pending: Worker deploy
+ DNS, release.
Status: implemented — client (`src/telemetry/`), `codegraph telemetry` CLI, MCP + installer
wiring, `TELEMETRY.md`, ingest Worker (`telemetry-worker/`) storing to its own Cloudflare D1
database, nightly rollup + retention cron, and the admin dashboard Worker
(`telemetry-dashboard/`).
Scope: public `codegraph` engine (CLI + MCP server + installer)
CodeGraph is a local-first tool whose whole pitch is "your code never leaves your machine."
@@ -26,7 +27,10 @@ Answer, in aggregate and anonymously:
- **No source code, ever.** No file paths, file names, repo names, symbol names, query
strings, search terms, or anything derived from the contents of an indexed project.
- No IP addresses (stripped at the edge; storage disabled at the backend too).
- No IP addresses — never read at the edge, and there is no downstream backend that could
see one.
- No third-party analytics vendor. Events are stored only in our own database; the ingest
Worker makes no outbound requests at all.
- No hardware fingerprinting — the machine ID is a random UUID, not derived from anything.
- No per-keystroke / per-call event stream — usage is aggregated locally into daily rollups
before anything is sent.
@@ -58,7 +62,7 @@ Common envelope on every batch (computed once per process):
| `os` / `arch` | `darwin` / `arm64` | `process.platform` / `process.arch` |
| `node_major` | `22` | major only |
| `ci` | `false` | `CI` env var present |
| `schema_version` | `1` | bump when the schema changes |
| `schema_version` | `2` | bump when the schema changes (v2 dropped `index.sqlite_backend`) |
Event types:
@@ -70,8 +74,8 @@ Event types:
- **`usage_rollup`** — the workhorse. One event per `(day, kind, name)` per machine,
aggregated locally. Props: `kind` (`mcp_tool`/`cli_command`), `name`
(e.g. `codegraph_explore`, `affected`), `count`, `error_count`, and for MCP:
`client_name`/`client_version` from the `initialize` handshake (`src/mcp/session.ts`
`case 'initialize'` — plumbing to add; currently unread).
`client_name`/`client_version` captured from the `initialize` handshake
(`src/mcp/session.ts`) and passed through on every `recordUsage` call.
The prompt hook additionally rolls up its gate DECISION as `cli_command`
counters named `prompt-hook-gate-<outcome>`, outcome ∈ `high-keyword` /
`high-token` / `medium-segment` / `nudge-projects` / `noop-shape` /
@@ -87,13 +91,23 @@ Event types:
rather than polluting `noop-unverified` (#1142).
- **`uninstall`** — one per `uninstall`/`uninit` run (churn signal). Props: `targets`.
Volume math: rollups mean monthly events ≈ active machines × active days × distinct
tools used (single digits) — the PostHog free tier (1M events/mo) covers tens of
thousands of MAU. There is no per-call event by design.
One legacy field is still *accepted* and belongs in the mirror even though nothing sends
it: `sqlite_backend` (`native`/`wasm`) on `install` and `index`. Pre-schema-v2 clients
(≤ June 2026) sent it; `node:sqlite` is the only backend now, so current clients omit it.
It is never `required`, and it is safe to drop from the Worker once those clients'
share is negligible.
Events are sent as PostHog **anonymous events** (`$process_person_profile: false`):
cheaper, no person profiles, unique-machine counts still work on `distinct_id` =
`machine_id`. Revisit only if retention tooling demands profiles.
Volume math: rollups mean monthly events ≈ active machines × active days × distinct tools
used (single digits) — there is no per-call event by design. At ~97k accepted POSTs/day
that is ≈30M D1 row writes/month against the 50M included on **Workers Paid**, roughly
doubling to ≈48M once the retention purge reaches steady state (a delete bills like an
insert). Storage is the binding constraint, not writes: raw events grow ≈74 MB/day, so the
90-day window lands at ≈6.7 GB against D1's 10 GB per-database cap — which is what sets the
window. Full arithmetic and the remaining levers are in the migration's footer comment.
There are no person profiles to opt out of: `machine_id` is the only identifier that exists
anywhere in the system, it is a client-minted random UUID, and unique-machine counts are
computed from it directly in SQL.
## Consent & controls
@@ -166,15 +180,59 @@ public on purpose, so anyone can audit exactly what the endpoint stores. It ship
with the npm package (excluded by the `files` allowlist):
- `POST /v1/events`: validate against the event/property allowlist (drop unknown events,
strip unknown props), enforce sane sizes, **never forward or log the client IP**
(drop `CF-Connecting-IP`), light per-`machine_id` rate limit so abuse can't burn the
ingest cap, forward to `https://us.i.posthog.com/batch/` with the project key from a
Worker secret. Responds `204` on accept (including events dropped by the allowlist)
and honest `4xx` for malformed/oversized/rate-limited requests — the client treats
every response as final and never retries.
- Backend today: PostHog Cloud US, free plan, "discard client IP" enabled, GeoIP disabled,
autocapture/replay/heatmaps/web-vitals all off. The Worker is the seam: swapping the
backend later is a Worker change, not a client release.
strip unknown props), enforce sane sizes, **never read or log the client IP**, light
per-`machine_id` rate limit so abuse can't burn the ingest cap, then write the survivors
to D1. Responds `204` on accept (including events dropped by the allowlist) and honest
`4xx` for malformed/oversized/rate-limited requests — the client treats every response
as final and never retries.
- **Storage: our own Cloudflare D1 database** (`codegraph-telemetry`, bound as `env.DB`).
The Worker makes **no outbound requests** — nothing is forwarded to a third-party
analytics vendor, so there is no vendor-side privacy setting to get wrong and no second
copy of the data anywhere. The complete stored schema is
[`telemetry-worker/migrations/0001_init.sql`](../../telemetry-worker/migrations/0001_init.sql),
checked in for the same reason the Worker's source is public.
- The write is off the response path (`ctx.waitUntil`, one `batch()` = one transaction) and
deliberately **fail-silent**: a D1 error is logged as counts only, never the payload, and
the client still gets its `204`. Clients never retry, so losing a datapoint beats losing
availability.
- **Nightly cron (00:30 UTC, `src/rollup.ts`)** rolls each finished day into anonymous daily
counts (`daily_machines`, `daily_event_counts`, `daily_dim_counts`) and re-runs the two
days before it, since offline clients ship completed-day rollups late. Aggregation is
`INSERT … SELECT … ON CONFLICT DO UPDATE` inside D1 — no event row crosses the wire, and
re-running a day is a no-op rather than a double count. The same job **purges raw
`events` older than `RETENTION_DAYS`** (90; a var in `wrangler.jsonc`). Rollups and
`machine_days`/`machine_first_seen` are kept forever, so shortening the window costs
ad-hoc drill-back, never a chart.
- The Worker remains the seam: changing storage later is a Worker change, not a client
release. The client only ever knows the domain.
Operational detail — deploy, migrations, the cron, the `POST /admin/rollup` backfill hatch,
and the D1 quota arithmetic — lives in
[`telemetry-worker/README.md`](../../telemetry-worker/README.md).
## Admin dashboard (Cloudflare Worker)
`stats.getcodegraph.com` → a second Worker at
[`telemetry-dashboard/`](../../telemetry-dashboard/) — the read side, and the reason
self-hosting the data costs us no analysis capability. Also public source, for the same
reason: the code that touches telemetry should be readable by the people it collects from.
Full documentation is [`telemetry-dashboard/README.md`](../../telemetry-dashboard/README.md).
- **Same D1 database, read-only.** It never migrates and never writes; schema changes belong
to the ingest Worker. The two Workers are separate deployments that agree on a list of
dimension names by convention alone, which is exactly the seam
`telemetry-worker/scripts/smoke-cutover.sh` exists to cover — a mismatch there is silent,
showing up as a panel that reads zero forever rather than as an error.
- **Reads rollups, not raw events**, so a chart stays correct for days whose raw rows have
been purged. `/api/activation` is the one exception — "did this machine ever run an index"
is not a daily aggregate — so it reads raw `events` and is bounded by the retention window,
which it reports as `raw_events_from`.
- **Auth is a shared password and a signed cookie**, sized for exactly two people:
`ADMIN_PASSWORD` + `SESSION_SECRET` as Worker secrets, constant-time compare, HMAC-signed
cookie with no session store, everything except `/login` and `robots.txt` gated. Rotating
the password signs everyone out; that is the revocation story.
- This Worker *does* read the client IP, solely as a login rate-limit key, never stored or
logged — the one deliberate difference from the ingest Worker, which never reads it at all.
## codegraph-pro rule (do not lose this in upstream merges)
@@ -187,9 +245,9 @@ CLAUDE.md and must survive every upstream merge.
## Rollout
1. This doc + repo-root `TELEMETRY.md` (user-facing field-by-field list) + README section.
2. Worker + DNS live first (so the first shipping client never 404s), PostHog dashboards:
weekly active machines, installs by target, usage by tool × client, version adoption,
languages indexed.
2. Worker + DNS live first (so the first shipping client never 404s), then the dashboard
Worker over the same D1: weekly active machines, installs by target, usage by
tool × client, version adoption, languages indexed.
3. Client module + config + `codegraph telemetry` subcommand + MCP `clientInfo` plumbing.
4. Installer toggle + first-run notice. CHANGELOG entry under `[Unreleased]` announcing
telemetry, the default, and every off-switch. Release.
+7
View File
@@ -0,0 +1,7 @@
# Copy to .dev.vars for local development (`npm run dev`) and so that
# `wrangler types` includes both secrets in the generated Env.
# The real values live only in the deployed secrets:
# wrangler secret put ADMIN_PASSWORD
# wrangler secret put SESSION_SECRET
ADMIN_PASSWORD="dev-password"
SESSION_SECRET="dev-session-secret-not-the-real-one"
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.wrangler/
.dev.vars
# generated by `wrangler types` (npm run types) — includes .dev.vars keys
worker-configuration.d.ts
# copied out of node_modules by `npm run vendor`
public/vendor/
+193
View File
@@ -0,0 +1,193 @@
# codegraph telemetry dashboard
The private admin view behind `stats.getcodegraph.com`. Its sibling
[`telemetry-worker/`](../telemetry-worker/) writes anonymous usage events into a D1 database;
this worker reads them back and draws the charts. Two people use it, so the auth is
deliberately the simplest thing that is actually safe: one shared password in a secret, and
a long-lived signed cookie.
This directory is in the public repo for the same reason the ingest worker is — the code
that touches telemetry should be readable by the people it collects from. Nothing secret
lives here: the password and the cookie-signing key are deployment secrets, and the D1
database ID is an identifier, not a credential.
## What is gated
Everything except the login page and `robots.txt`. `assets.run_worker_first` is `true` in
`wrangler.jsonc`, so Cloudflare hands *every* request to `src/index.ts` before the static
asset server sees it — the dashboard HTML, its JS, its CSS and the chart library are all
behind the session check, and a request without a valid cookie gets a redirect (pages) or a
`401` (`/api/*`). The login page is rendered inline by the worker rather than served from
`public/`, so the asset directory needs no "is this file public?" judgement calls.
| Route | Auth | Notes |
|---|---|---|
| `GET /login` | public | Password form. Redirects to `/` if already signed in. |
| `POST /login` | public | Rate-limited per IP; sets the session cookie on success. |
| `POST /logout` | public | Clears the cookie. |
| `GET /robots.txt` | public | `Disallow: /`. |
| `GET /api/*` | required | JSON. `401` without a session. See the API below. |
| everything else | required | Static assets from `public/`. `302 /login` without a session. |
## The API
Every endpoint is `GET`, session-gated, and scoped by `?from=YYYY-MM-DD&to=YYYY-MM-DD`
(inclusive, UTC days). Ranges wider than 366 days are clamped and say so in
`range.clamped`. Responses come back Chart.js-shaped — `labels[] + datasets[]` — plus a
`rows[]` in the data's natural shape, which is what each panel's "Show numbers" table
renders. Bad input is a `400` with a message, never a guess. Chart data carries
`Cache-Control: private, max-age=300`.
| Endpoint | Answers |
|---|---|
| `/api/meta` | The days data actually exists for. The picker anchors its presets on `latest_day` so no chart ends on a day the nightly rollup has not written yet. |
| `/api/summary` | Big numbers: production users, active machines, new machines, installs, uninstalls, indexing runs, tool calls. |
| `/api/timeseries?metric=` | `installs_uninstalls`, `new_installs`, `production_users`, `indexing_activity`, `tool_calls`, `duration_buckets`. One dense point per day — a day with nothing is a zero, not a gap. |
| `/api/breakdown?dim=` | `os`, `arch`, `codegraph_version`, `node_major`, `language`, `file_count_bucket`, `duration_bucket`, `target`, `scope`, `kind`, `name`, `client_name`, `name_error`. Optional `&event=`, `&metric=count\|machines`, `&limit=`. |
| `/api/activation?window=7` | Install → first index funnel, plus the daily rate. |
| `/api/retention` | Day 014 cohort curve for machines first seen in the range. |
| `/api/health` | Liveness plus the latest event/rollup day. Uncached. |
Everything reads the `daily_*` rollups and `machine_days`, which are kept forever, so a
chart stays correct for days whose raw events have been purged. `/api/activation` is the
one exception — "did this machine ever run an index" is not a daily aggregate — so it
reads raw `events` and is bounded by the ingest worker's retention window. It reports
`raw_events_from` for that reason.
### Two numbers that are easy to misread
Both are labelled honestly in the UI rather than rounded off into something friendlier:
- **Machine-days, not users.** `daily_dim_counts.machines` is per day, so summing it over
a range counts a machine once per day it was active. A range-wide distinct count per
dimension value is not recoverable from the rollups at all, so the panels that use it
say "machine-days" and are share-of-total panels where the distinction does not move the
shape. Where a dimension rides several event types, the per-day figure is the largest
single-event count rather than their sum, so one machine's install + index + usage on
one day is not counted three times.
- **Recent cohorts have not finished converting.** A machine that installed yesterday has
not had seven days to run an index, so the tail of the activation curve is a floor, not
a result. The API marks those days (`complete: false`, `incomplete_from`) and the panel
says so instead of drawing a cliff and calling it a drop in conversion. Retention does
the same thing with a per-day denominator: day *k* is measured only over the machines
that have actually had *k* days to come back.
## How the session works
- The password is compared in constant time, over SHA-256 digests so the operands are always
the same length and nothing about the secret leaks through timing.
- The cookie is a signed assertion — `base64url(payload).base64url(HMAC-SHA256)` — not a
lookup key. There is no session store; a tampered payload fails the signature check.
- `HttpOnly; Secure; SameSite=Lax; Path=/`, `Max-Age` one year. You sign in once per browser
and it survives restarts.
- The payload carries a fingerprint of the password it was minted against, so
**rotating `ADMIN_PASSWORD` signs everyone out** — that is the revocation story.
- Login attempts are capped at 5/min per IP. Unlike the ingest worker, which never reads the
client IP at all, this one does — solely as a rate-limit key, never stored or logged.
## Deploy
Prereqs: the `getcodegraph.com` zone on the deploying Cloudflare account (the custom domain
auto-provisions DNS + cert), and the D1 database from `telemetry-worker/` already created.
```bash
cd telemetry-dashboard
npm install
npx wrangler login # once
npx wrangler secret put ADMIN_PASSWORD # the shared password
npx wrangler secret put SESSION_SECRET # cookie-signing key, e.g. `openssl rand -base64 48`
npm run deploy
```
Both secrets are required — the worker refuses every request if either is missing, so a
half-configured deployment fails closed rather than becoming an open dashboard.
Rotating either one is a `wrangler secret put` away. Rotating `SESSION_SECRET` invalidates
outstanding cookies too, and is the right move if you think one leaked.
Migrations belong to the writer, not to this worker: apply schema changes from
`telemetry-worker/` (`npm run db:migrate`). D1 is read-only here.
## Local dev & checks
```bash
cp .dev.vars.example .dev.vars # placeholder secrets; also feeds `wrangler types`
npm run check # vendor + wrangler types + tsc --noEmit + deploy --dry-run
npm run seed # load scripts/fixture.sql into the LOCAL D1
npm run dev # http://localhost:8787
npm run smoke:auth # the auth gate (54 assertions)
npm run smoke:api # the SQL and its numbers (98 assertions)
npm run smoke:render # the panels, in a browser (79 assertions)
```
Each suite starts its own throwaway `wrangler dev` on its own port and cleans up after
itself, so they can be run in any order (`DASH_PORT` overrides the port).
**`smoke-auth.sh`** is the regression net for the gate: unauthenticated requests reach
nothing (pages, API *and* static assets), the cookie is persistent and correctly flagged,
flipped/truncated/forged cookies are all rejected, brute force is capped, and rotating the
password invalidates existing sessions. Run it after touching `src/auth.ts` or the route
table in `src/index.ts`.
**`smoke-api.sh`** checks every endpoint against `scripts/fixture.sql` — twelve machines
over ten days, listed machine by machine in that file's header, small enough that every
expected number was worked out by hand rather than recorded from a passing run. It also
covers the boring half: bad dims, malformed dates, backwards ranges and over-wide ranges.
**`render-check.mjs`** loads the real page in whatever Chromium is already on the machine
(over the DevTools protocol — no new dependency; it *skips* if there is no browser) and
reads the live Chart.js instance behind each canvas, comparing what every panel plotted
against the same endpoint fetched from Node. That is what catches a panel wired to the
wrong dimension, which neither of the other two suites can see. It also drives the range
picker and asserts a clean console, so a CSP regression fails the build.
`RENDER_SHOT=/tmp/dash.png npm run smoke:render` writes a full-page screenshot — the only
way to check the things assertions cannot, like label collisions.
## Frontend
Plain static files in `public/` — one HTML page, ES modules, no framework, no build step.
| File | Holds |
|---|---|
| `index.html` | The shell: masthead, the one filter row, an empty grid. |
| `panels.js` | The panel registry — data in, chart config out, no DOM. Adding a panel is one entry. |
| `theme.js` | Palette, formatters, and the Chart.js defaults every panel inherits. |
| `app.js` | The page: range picker, one fetch per panel, loading/empty/error states. |
The split is what lets `render-check.mjs` import the *same* registry the browser just
rendered from, so its expectations cannot drift from the panels under test.
Panels fail alone: each fetches, draws and reports independently, so a failed query leaves
the other eighteen on screen. There is no client-side cache — the only reuse is
deduplicating identical URLs within a single render (four stat tiles share one
`/api/summary`), and that map is discarded afterwards, so refresh really does re-ask.
A refetch dims the previous render rather than tearing it down, so nothing jumps. Every
chart has a "Show numbers" table twin, which is what keeps a value from being reachable
only by hovering.
### Colours
Two scales, both run through the data-viz validator against this dashboard's actual chart
surface (`#ffffff`, the panel fill) rather than picked by eye — the exact results are
recorded at the top of `theme.js`:
- **Categorical** `#a8342a #2a6f9e #17916a #c98500` — identity (which series). Slot 1 is
the brand oxblood stepped up into the legible lightness band. Clears every gate
including all-pairs colour-vision separation, with no contrast relief needed.
- **Ordinal** `#d99a90 #c26a5c #a3423a #7a201a` — one hue, light to dark, for scales whose
order *is* their meaning (run length, codebase size), so the ordering is visible in the
colour instead of needing the legend.
Nominal bars all take slot 1: colouring them by value would spend the identity channel
re-encoding what bar length already shows. If you change a hex, re-run the validator — the
red/green pair that "looks fine" is the one that collapses under deuteranopia.
Workers Static Assets serves them verbatim, so third-party libraries are copied out of
`node_modules` into `public/vendor/` by `npm run vendor` (wired into `dev` and `deploy`).
That keeps the version pinned by the lockfile, avoids a third-party origin at runtime, and
lets the CSP stay `script-src 'self'`. `public/vendor/` is gitignored — it is build output.
Visual conventions follow the rest of codegraph: flat and editorial, square corners, hairline
rules, sentence-case headings, one oxblood accent, no tiny all-caps tracked labels.
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "codegraph-telemetry-dashboard",
"private": true,
"type": "module",
"description": "Password-gated admin dashboard over the codegraph telemetry D1 database (stats.getcodegraph.com)",
"scripts": {
"vendor": "node scripts/vendor-assets.mjs",
"dev": "npm run vendor && wrangler dev",
"deploy": "npm run vendor && wrangler deploy",
"types": "wrangler types",
"check": "npm run vendor && wrangler types && tsc --noEmit && wrangler deploy --dry-run",
"seed": "./scripts/seed-fixture.sh",
"smoke:auth": "./scripts/smoke-auth.sh",
"smoke:api": "./scripts/smoke-api.sh",
"smoke:render": "npm run vendor && node scripts/render-check.mjs"
},
"devDependencies": {
"chart.js": "^4.4.0",
"typescript": "^5.0.0",
"wrangler": "^4.36.0"
}
}
+395
View File
@@ -0,0 +1,395 @@
/**
* The dashboard page: one filter row, a grid of panels, and a fetch per panel.
*
* Deliberate properties:
* - **One filter row, above everything it scopes.** Changing the range or
* hitting refresh re-queries every panel against the same slice; no panel
* carries its own time control.
* - **Panels fail alone.** Each one fetches, draws, and reports independently,
* so a 503 on one query leaves the other eighteen on screen instead of
* blanking the page.
* - **No client-side cache.** The only reuse is deduplicating identical URLs
* within a single render (four stat tiles read one /api/summary); that map is
* thrown away afterwards, so refresh really does re-ask. Anything longer-lived
* is the API's `Cache-Control` doing its job in the browser's own cache.
* - **No skeleton flash.** A refetch dims the previous render instead of tearing
* it down, so nothing jumps while new numbers land.
* - **Every chart has a table twin.** "Show numbers" reveals the same data as
* text, which is what keeps a value from being reachable only by hovering.
*/
import { PANELS } from './panels.js';
import { applyChartDefaults, shortDay } from './theme.js';
const RANGE_PRESETS = [
{ days: 7, label: 'Last 7 days' },
{ days: 14, label: 'Last 14 days' },
{ days: 30, label: 'Last 30 days' },
{ days: 90, label: 'Last 90 days' },
];
const DEFAULT_PRESET = 30;
const DAY_MS = 86_400_000;
const Chart = window.Chart;
/** Every fetch goes through here so an expired session lands on /login instead
* of failing silently mid-render. */
export async function api(path) {
const response = await fetch(path, { headers: { accept: 'application/json' } });
if (response.status === 401) {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
throw new Error('session expired');
}
if (!response.ok) {
const detail = await response.json().catch(() => null);
throw new Error(detail?.error ?? `responded ${response.status}`);
}
return response.json();
}
// ---------------------------------------------------------------------------
// Days
// ---------------------------------------------------------------------------
const utcDay = (atMs) => new Date(atMs).toISOString().slice(0, 10);
const dayMs = (day) => Date.parse(`${day}T00:00:00Z`);
const addDays = (day, delta) => utcDay(dayMs(day) + delta * DAY_MS);
const isDay = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(dayMs(value));
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const state = {
/** Latest day the nightly rollup has written; every preset ends here. */
anchor: utcDay(Date.now()),
earliest: null,
preset: DEFAULT_PRESET,
custom: { from: null, to: null },
/** Panels whose table twin the reader has opened, kept across re-renders. */
openTables: new Set(),
renderToken: 0,
};
const charts = new Map();
function currentRange() {
if (state.preset === 'custom' && state.custom.from && state.custom.to) {
return { from: state.custom.from, to: state.custom.to };
}
const to = state.anchor;
return { from: addDays(to, -(state.preset - 1)), to };
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
const $ = (root, role) => root.querySelector(`[data-role="${role}"]`);
// ---------------------------------------------------------------------------
// Building the page
// ---------------------------------------------------------------------------
function buildFilters() {
const bar = document.getElementById('filters');
const presets = $(bar, 'presets');
for (const preset of RANGE_PRESETS) {
const button = el('button', 'range', preset.label);
button.type = 'button';
button.dataset.days = String(preset.days);
button.addEventListener('click', () => {
state.preset = preset.days;
syncFilters();
render();
});
presets.append(button);
}
const from = $(bar, 'custom-from');
const to = $(bar, 'custom-to');
const apply = $(bar, 'custom-apply');
apply.addEventListener('click', () => {
if (!isDay(from.value) || !isDay(to.value)) {
setRangeSummary('Enter both dates as YYYY-MM-DD.');
return;
}
if (from.value > to.value) {
setRangeSummary('The start date must come before the end date.');
return;
}
state.preset = 'custom';
state.custom = { from: from.value, to: to.value };
syncFilters();
render();
});
$(bar, 'refresh').addEventListener('click', () => {
refreshMeta().finally(render);
});
}
function syncFilters() {
const bar = document.getElementById('filters');
for (const button of bar.querySelectorAll('button.range')) {
const selected = String(state.preset) === button.dataset.days;
button.classList.toggle('is-selected', selected);
button.setAttribute('aria-pressed', String(selected));
}
const { from, to } = currentRange();
$(bar, 'custom-from').value = from;
$(bar, 'custom-to').value = to;
}
function setRangeSummary(text) {
document.getElementById('range-summary').textContent = text;
}
function buildPanels() {
const grid = document.getElementById('grid');
for (const panel of PANELS) {
const section = el('section', `panel span-${panel.span}`);
section.id = `panel-${panel.id}`;
section.dataset.panel = panel.id;
section.dataset.state = 'loading';
const head = el('div', 'panel-head');
head.append(el('h2', null, panel.title));
const figure = el('p', 'panel-figure');
figure.dataset.role = 'figure';
head.append(figure);
section.append(head);
if (panel.note) section.append(el('p', 'panel-note', panel.note));
const body = el('div', 'panel-body');
body.dataset.role = 'body';
if (panel.kind === 'chart') {
const wrap = el('div', 'chart-wrap');
const canvas = document.createElement('canvas');
canvas.dataset.role = 'canvas';
// Chart.js renders to canvas, so the accessible copy is the table twin
// below — say so rather than leaving a bare graphic.
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', `${panel.title}. The same data is in the table below.`);
wrap.append(canvas);
body.append(wrap);
} else if (panel.kind === 'stat') {
const stat = el('div', 'stat');
stat.dataset.role = 'stat';
stat.append(el('p', 'stat-value'), el('p', 'stat-caption'));
body.append(stat);
} else if (panel.kind === 'funnel') {
const funnel = el('div', 'funnel');
funnel.dataset.role = 'funnel';
body.append(funnel);
}
const status = el('p', 'panel-state');
status.dataset.role = 'state';
body.append(status);
section.append(body);
const toggle = el('button', 'link', 'Show numbers');
toggle.type = 'button';
toggle.dataset.role = 'toggle';
toggle.setAttribute('aria-expanded', 'false');
const table = el('div', 'table-wrap');
table.dataset.role = 'table';
table.hidden = true;
toggle.addEventListener('click', () => {
const open = table.hidden;
table.hidden = !open;
toggle.textContent = open ? 'Hide numbers' : 'Show numbers';
toggle.setAttribute('aria-expanded', String(open));
if (open) state.openTables.add(panel.id);
else state.openTables.delete(panel.id);
});
section.append(toggle, table);
grid.append(section);
}
}
// ---------------------------------------------------------------------------
// Drawing one panel
// ---------------------------------------------------------------------------
function setState(section, name, message) {
section.dataset.state = name;
$(section, 'state').textContent = message ?? '';
}
function drawTable(section, spec) {
const host = $(section, 'table');
host.replaceChildren();
if (!spec) return;
const table = el('table');
const thead = el('thead');
const headRow = el('tr');
for (const column of spec.columns) {
const th = el('th', null, column);
th.scope = 'col';
headRow.append(th);
}
thead.append(headRow);
const tbody = el('tbody');
for (const row of spec.rows) {
const tr = el('tr');
row.forEach((cell, i) => {
const node = el(i === 0 ? 'th' : 'td', null, String(cell));
if (i === 0) node.scope = 'row';
tr.append(node);
});
tbody.append(tr);
}
table.append(thead, tbody);
host.append(table);
}
function drawStat(section, stat) {
const host = $(section, 'stat');
host.querySelector('.stat-value').textContent = stat.value;
host.querySelector('.stat-caption').textContent = stat.caption ?? '';
}
/**
* The two-stage conversion funnel, drawn as proportional bars rather than a
* chart: two bars and a percentage is the whole story, and a two-slice pie or a
* two-bar chart would be more chrome than data.
*/
function drawFunnel(section, funnel) {
const host = $(section, 'funnel');
host.replaceChildren();
for (const stage of funnel.stages) {
const row = el('div', 'funnel-stage');
const head = el('div', 'funnel-label');
head.append(el('span', null, stage.label), el('span', 'funnel-value', stage.value.toLocaleString('en-US')));
const track = el('div', 'funnel-track');
const fill = el('div', 'funnel-fill');
// Width is the datum, so it is set from JS rather than a style attribute —
// the CSP here allows no inline styles at all.
fill.style.width = `${Math.max(0, Math.min(1, stage.share)) * 100}%`;
track.append(fill);
row.append(head, track);
host.append(row);
}
const rate = funnel.rate === null ? '—' : `${(funnel.rate * 100).toFixed(1)}%`;
host.append(
el('p', 'funnel-summary', `${rate} converted · ${funnel.dropped.toLocaleString('en-US')} dropped off`),
);
}
function drawChart(section, panel, config) {
const canvas = $(section, 'canvas');
const existing = charts.get(panel.id);
if (existing) existing.destroy();
charts.set(panel.id, new Chart(canvas, config));
}
async function drawPanel(panel, request, token) {
const section = document.getElementById(`panel-${panel.id}`);
section.dataset.stale = 'true';
try {
const data = await request;
// A slower panel from a superseded render must never overwrite the current one.
if (token !== state.renderToken) return;
if (panel.empty?.(data)) {
setState(section, 'empty', 'Nothing in this range.');
drawTable(section, panel.table?.(data));
return;
}
if (panel.kind === 'stat') drawStat(section, panel.stat(data));
else if (panel.kind === 'funnel') drawFunnel(section, panel.funnel(data));
else drawChart(section, panel, panel.chart(data));
$(section, 'figure').textContent = panel.figure ? panel.figure(data) : '';
drawTable(section, panel.table?.(data));
setState(section, 'ready');
} catch (err) {
if (token !== state.renderToken) return;
// One panel's failure is one panel's problem: the message lands in the
// panel, the rest of the page keeps its data.
setState(section, 'error', `Could not load this panel — ${err.message ?? err}`);
const chart = charts.get(panel.id);
if (chart) {
chart.destroy();
charts.delete(panel.id);
}
} finally {
if (token === state.renderToken) section.dataset.stale = 'false';
}
}
// ---------------------------------------------------------------------------
// Rendering everything
// ---------------------------------------------------------------------------
async function refreshMeta() {
try {
const meta = await api('/api/meta');
if (meta.latest_day) state.anchor = meta.latest_day;
state.earliest = meta.earliest_day ?? null;
syncFilters();
} catch {
// A meta failure is not fatal: the picker falls back to today's date and
// every panel still answers. The banner is what says so.
document.getElementById('data-through').textContent = 'Could not read the data range.';
}
}
async function render() {
const token = ++state.renderToken;
const { from, to } = currentRange();
const query = `from=${from}&to=${to}`;
setRangeSummary(`${shortDay(from)} ${shortDay(to)}, ${to.slice(0, 4)}`);
document.getElementById('data-through').textContent = `Data through ${shortDay(state.anchor)}`;
// Deduplicate identical URLs within THIS render only — the four stat tiles
// share one /api/summary. Discarded when the render ends, so refresh refetches.
const inFlight = new Map();
const request = (path) => {
if (!inFlight.has(path)) inFlight.set(path, api(path));
return inFlight.get(path);
};
await Promise.allSettled(PANELS.map((panel) => drawPanel(panel, request(panel.source(query)), token)));
if (token === state.renderToken) {
document.getElementById('refreshed-at').textContent =
`Last refreshed ${new Date().toLocaleTimeString('en-US')}`;
document.body.dataset.ready = 'true';
}
}
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
if (!Chart) {
document.getElementById('data-through').textContent =
'The chart library did not load — run `npm run vendor` and reload.';
} else {
applyChartDefaults(Chart);
buildFilters();
buildPanels();
syncFilters();
await refreshMeta();
await render();
}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>codegraph telemetry</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="masthead">
<div>
<h1>codegraph telemetry</h1>
<p class="subtitle">Anonymous usage from the public engine, straight out of D1.</p>
</div>
<form method="post" action="/logout">
<button type="submit" class="secondary">Sign out</button>
</form>
</header>
<!-- One filter row for the whole page: every panel below is drawn against the
range chosen here, and no panel carries a time control of its own. The
panels themselves are built from the registry in public/panels.js. -->
<section class="filters" id="filters" aria-label="Time range">
<div class="filter-group" data-role="presets"></div>
<div class="filter-group custom-range">
<label for="custom-from">From</label>
<input type="date" id="custom-from" data-role="custom-from" />
<label for="custom-to">To</label>
<input type="date" id="custom-to" data-role="custom-to" />
<button type="button" class="secondary" data-role="custom-apply">Apply</button>
</div>
<div class="filter-group filter-end">
<button type="button" class="secondary" data-role="refresh">Refresh</button>
</div>
<p class="filter-status">
<span id="range-summary">Loading…</span>
<span class="dot" aria-hidden="true">·</span>
<span id="data-through"></span>
<span class="dot" aria-hidden="true">·</span>
<span id="refreshed-at"></span>
</p>
</section>
<main class="grid" id="grid"></main>
<script src="/vendor/chart.umd.js"></script>
<script type="module" src="/app.js"></script>
</body>
</html>
+534
View File
@@ -0,0 +1,534 @@
/**
* The panel registry what the dashboard shows, in the order it shows it.
*
* Every panel is data in, chart config out, with no DOM anywhere in this file:
* app.js owns the page, this owns the mapping from an API response to a chart.
* Keeping them apart is what lets scripts/render-check.mjs drive the real panel
* definitions in a real browser and compare what each one plotted against what
* the API returned.
*
* A panel is:
* id stable key, also the DOM id and the anchor in a bug report
* title sentence case, at a readable size never a tracked-out caps label
* note the honest footnote: what the number actually counts
* span grid columns out of 12
* source (query) => API path; panels sharing a path share one fetch
* kind 'stat' | 'funnel' | 'chart'
* figure optional headline shown under the title (pie totals)
* empty (data) => is there nothing to draw
* table (data) => the WCAG-clean twin every chart owes the reader
*/
import {
CATEGORICAL,
INDEX_HOVER,
NEUTRAL,
SURFACE,
categoryScale,
compact,
number,
paletteFor,
percent,
shortDay,
valueScale,
} from './theme.js';
// ---------------------------------------------------------------------------
// Sources
// ---------------------------------------------------------------------------
const summary = (q) => `/api/summary?${q}`;
const activation = (q) => `/api/activation?${q}`;
const retention = (q) => `/api/retention?${q}`;
const series = (metric) => (q) => `/api/timeseries?metric=${metric}&${q}`;
const breakdown =
(dim, extra = '') =>
(q) =>
`/api/breakdown?dim=${dim}${extra}&${q}`;
// ---------------------------------------------------------------------------
// Chart builders
// ---------------------------------------------------------------------------
const allZero = (data) => data.datasets.every((ds) => ds.data.every((v) => !v));
const noRows = (data) => data.labels.length === 0 || data.datasets[0].data.every((v) => !v);
/** Alpha-suffixed hex for the ~10% area wash under a single-series line. */
const wash = (hex) => `${hex}1a`;
/**
* A line per series over days. One axis, always two measures of different
* scale get two panels rather than a second y-axis, which would invent a
* correlation the data does not have.
*/
function lineChart(data, { unit = 'count' } = {}) {
const dense = data.labels.length > 21;
const isPercent = unit === 'percent';
// A wash under a single line reads well — but not across gaps, where the fill
// would colour in days the series has no value for. Days with no cohort at
// all are exactly that case, so a gapped series goes unfilled.
const gapped = data.datasets.some((ds) => ds.data.some((v) => v === null));
const single = data.datasets.length === 1 && !gapped;
return {
type: 'line',
data: {
labels: data.labels.map(shortDay),
datasets: data.datasets.map((ds, i) => {
const colour = CATEGORICAL[i] ?? NEUTRAL;
return {
label: ds.label,
data: ds.data,
borderColor: colour,
backgroundColor: single ? wash(colour) : colour,
fill: single,
// Dots on a 90-day line are noise; the index-mode tooltip is how you
// read a value, and the table view is how you read all of them.
pointRadius: dense ? 0 : 3,
pointHoverRadius: 5,
pointBackgroundColor: colour,
// 2px surface ring, so a marker stays legible where lines cross.
pointBorderColor: SURFACE,
pointBorderWidth: 2,
spanGaps: false,
};
}),
},
options: {
interaction: INDEX_HOVER,
plugins: {
// A single series needs no legend box — the panel title names it.
legend: { display: data.datasets.length > 1 },
tooltip: {
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${
ctx.parsed.y === null ? 'no data' : isPercent ? `${ctx.parsed.y}%` : number(ctx.parsed.y)
}`,
},
},
},
scales: {
x: categoryScale(),
y: valueScale(
isPercent
? { max: 100, ticks: { color: undefined, padding: 8, callback: (v) => `${v}%` } }
: {},
),
},
},
};
}
/**
* Bands stacked to the day's total, for an ordered split of one measure.
*
* Four separate lines is the wrong form here: same-hue ordinal steps crossing
* each other read as scribble, and the question ("how is run length shifting?")
* is part-to-whole, not four independent trends. Stacked, the band heights are
* the mix and the outline is the total. The 2px surface-coloured border is the
* gap between touching fills white doing the separating, not a stroke.
*/
function stackedAreaChart(data) {
const colours = paletteFor(
data.datasets.map((ds) => ds.label),
'ordinal',
);
const config = lineChart(data);
config.data.datasets.forEach((ds, i) => {
ds.backgroundColor = colours[i];
ds.borderColor = SURFACE;
ds.borderWidth = 2;
ds.pointRadius = 0;
ds.pointHoverRadius = 4;
ds.pointBackgroundColor = colours[i];
ds.pointBorderColor = SURFACE;
ds.fill = true;
});
config.options.scales.y.stacked = true;
// The swatch has to be the band's colour; the line is surface-coloured here.
config.options.plugins.legend = {
display: true,
labels: { generateLabels: () => data.datasets.map((ds, i) => ({
text: ds.label,
fillStyle: colours[i],
strokeStyle: colours[i],
pointStyle: 'circle',
datasetIndex: i,
})) },
};
return config;
}
/**
* Horizontal bars. `scale: 'ordinal'` is for categories whose order is their
* meaning (run length, codebase size) and takes the one-hue ramp; nominal
* categories all take slot 1, because colouring them by value would spend the
* identity channel re-encoding what bar length already says.
*/
function barChart(data, { scale = 'nominal' } = {}) {
const colours =
scale === 'ordinal'
? paletteFor(data.labels, 'ordinal')
: data.labels.map((label) => (label === 'Other' ? NEUTRAL : CATEGORICAL[0]));
return {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
label: data.datasets[0].label,
data: data.datasets[0].data,
backgroundColor: colours,
maxBarThickness: 24,
// Rounded at the data end, square at the baseline (Chart.js skips the
// 'start' edge by default, which is the baseline on a horizontal bar).
borderRadius: 4,
},
],
},
options: {
indexAxis: 'y',
plugins: { legend: { display: false } },
scales: {
x: valueScale(),
y: categoryScale({ ticks: { color: undefined, padding: 6, autoSkip: false } }),
},
},
};
}
/** Part-to-whole at a glance. Capped at a handful of slices by the API's `limit`. */
function pieChart(data, { scale = 'categorical' } = {}) {
const total = data.datasets[0].data.reduce((n, v) => n + v, 0);
return {
type: 'pie',
data: {
labels: data.labels,
datasets: [
{
label: data.datasets[0].label,
data: data.datasets[0].data,
backgroundColor: paletteFor(data.labels, scale === 'ordinal' ? 'ordinal' : 'categorical'),
},
],
},
options: {
plugins: {
legend: { display: true },
tooltip: {
callbacks: {
label: (ctx) =>
`${ctx.label}: ${number(ctx.parsed)} (${total > 0 ? percent(ctx.parsed / total, 1) : '—'})`,
},
},
},
},
};
}
// ---------------------------------------------------------------------------
// Table twins
// ---------------------------------------------------------------------------
/** Days down the side, one column per series. */
const seriesTable = (data) => ({
columns: ['Day', ...data.datasets.map((ds) => ds.label)],
rows: data.labels.map((day, i) => [
day,
...data.datasets.map((ds) => (ds.data[i] === null ? '—' : number(ds.data[i]))),
]),
});
/** Both numbers, always — the panel plots one of them, the table shows both. */
const breakdownTable = (data) => ({
columns: [data.title, 'Events', 'Machine-days'],
rows: data.rows.map((r) => [r.value, number(r.count), number(r.machines)]),
});
// ---------------------------------------------------------------------------
// The panels
// ---------------------------------------------------------------------------
export const PANELS = [
{
id: 'production-users',
title: 'Production users',
note: 'Distinct machines active in the range, excluding CI runners.',
span: 3,
kind: 'stat',
source: summary,
stat: (d) => ({ value: compact(d.production_users), caption: `${number(d.active_machines)} including CI` }),
table: (d) => ({
columns: ['Measure', 'Machines'],
rows: [
['Production users', number(d.production_users)],
['All active machines', number(d.active_machines)],
['First seen in range', number(d.new_machines)],
],
}),
},
{
id: 'installs',
title: 'Installs',
note: 'Install events, including upgrades and reinstalls.',
span: 3,
kind: 'stat',
source: summary,
stat: (d) => ({ value: compact(d.installs), caption: `${number(d.new_machines)} from machines never seen before` }),
table: (d) => ({
columns: ['Measure', 'Events'],
rows: [
['Installs', number(d.installs)],
['New machines', number(d.new_machines)],
],
}),
},
{
id: 'uninstalls',
title: 'Uninstalls',
note: 'Uninstall events in the range.',
span: 3,
kind: 'stat',
source: summary,
stat: (d) => ({
value: compact(d.uninstalls),
caption: d.installs > 0 ? `${percent(d.uninstalls / d.installs)} of installs` : 'No installs in range',
}),
table: (d) => ({
columns: ['Measure', 'Events'],
rows: [
['Uninstalls', number(d.uninstalls)],
['Installs', number(d.installs)],
],
}),
},
{
id: 'indexing-runs',
title: 'Indexing runs',
note: 'Index events in the range, across every machine.',
span: 3,
kind: 'stat',
source: summary,
stat: (d) => ({ value: compact(d.index_runs), caption: `${compact(d.tool_calls)} tool and command calls` }),
table: (d) => ({
columns: ['Measure', 'Events'],
rows: [
['Indexing runs', number(d.index_runs)],
['Tool and command calls', number(d.tool_calls)],
],
}),
},
{
id: 'activation-funnel',
title: 'Install to first use',
note: 'Machines first seen in the range that ran an index within 7 days.',
span: 4,
kind: 'funnel',
source: activation,
empty: (d) => d.installs === 0,
funnel: (d) => ({
stages: [
{ label: 'Installed', value: d.installs, share: 1 },
{
label: `Indexed within ${d.window_days} days`,
value: d.activated,
share: d.installs > 0 ? d.activated / d.installs : 0,
},
],
rate: d.rate,
dropped: d.dropped,
}),
table: (d) => ({
columns: ['Stage', 'Machines', 'Share'],
rows: [
['Installed', number(d.installs), '100%'],
[`Indexed within ${d.window_days} days`, number(d.activated), percent(d.rate)],
['Dropped off', number(d.dropped), percent(d.installs > 0 ? d.dropped / d.installs : null)],
],
}),
},
{
id: 'activation-rate',
title: 'Conversion rate over time',
note: 'By the day a machine was first seen. Recent days are still converting, so their rate only rises.',
span: 8,
kind: 'chart',
source: activation,
empty: (d) => d.installs === 0,
chart: (d) => lineChart(d, { unit: 'percent' }),
table: (d) => ({
columns: ['Day', 'Installs', 'Indexed', 'Rate', 'Window elapsed'],
rows: d.rows.map((r) => [
r.day,
number(r.installs),
number(r.activated),
percent(r.rate),
r.complete ? 'Yes' : 'Not yet',
]),
}),
},
{
id: 'os',
title: 'Users by operating system',
note: 'Share of machine-days: a machine active on several days counts once per day.',
span: 4,
kind: 'chart',
// Three hues plus a neutral "Other" — the point past which categorical
// colours stop being reliably distinguishable under colour-vision deficiency.
source: breakdown('os', '&limit=3'),
empty: noRows,
figure: (d) => `${compact(d.total)} machine-days`,
chart: (d) => pieChart(d),
table: breakdownTable,
},
{
id: 'run-length',
title: 'Session run length',
note: 'Indexing runs by how long they took.',
span: 4,
kind: 'chart',
source: breakdown('duration_bucket'),
empty: noRows,
figure: (d) => `${compact(d.total)} runs`,
chart: (d) => pieChart(d, { scale: 'ordinal' }),
table: breakdownTable,
},
{
id: 'codebase-size',
title: 'Codebase size',
note: 'Files per indexed project.',
span: 4,
kind: 'chart',
source: breakdown('file_count_bucket'),
empty: noRows,
chart: (d) => barChart(d, { scale: 'ordinal' }),
table: breakdownTable,
},
{
id: 'installs-uninstalls',
title: 'Installs and uninstalls over time',
note: 'Install and uninstall events per day.',
span: 6,
kind: 'chart',
source: series('installs_uninstalls'),
empty: allZero,
chart: (d) => lineChart(d),
table: seriesTable,
},
{
id: 'new-installs',
title: 'New installs over time',
note: 'Machines seen for the first time, by day.',
span: 6,
kind: 'chart',
source: series('new_installs'),
empty: allZero,
chart: (d) => lineChart(d),
table: seriesTable,
},
{
id: 'indexing-activity',
title: 'Daily indexing activity',
note: 'Indexing runs and the machines that ran them.',
span: 6,
kind: 'chart',
source: series('indexing_activity'),
empty: allZero,
chart: (d) => lineChart(d),
table: seriesTable,
},
{
id: 'daily-production-users',
title: 'Daily production users',
note: 'Distinct machines active each day, excluding CI runners.',
span: 6,
kind: 'chart',
source: series('production_users'),
empty: allZero,
chart: (d) => lineChart(d),
table: seriesTable,
},
{
id: 'run-length-over-time',
title: 'Run length over time',
note: 'Indexing runs per day, split by how long they took.',
span: 6,
kind: 'chart',
source: series('duration_buckets'),
empty: allZero,
// Ordered buckets, so the bands take the one-hue ramp rather than four
// unrelated hues: the reader sees "longer" in the colour.
chart: stackedAreaChart,
table: seriesTable,
},
{
id: 'retention',
title: 'Daily retention cohorts',
note: 'Machines first seen in the range, and the share still active k days later.',
span: 6,
kind: 'chart',
source: retention,
empty: (d) => d.cohort === 0,
figure: (d) => `${compact(d.cohort)} machines in cohort`,
chart: (d) => lineChart(d, { unit: 'percent' }),
table: (d) => ({
columns: ['Day', 'Machines old enough', 'Still active', 'Rate'],
rows: d.rows.map((r) => [
`Day ${r.day}`,
number(r.eligible),
number(r.retained),
percent(r.rate),
]),
}),
},
{
id: 'languages',
title: 'Most-indexed programming languages',
note: 'One count per indexing run that found the language; a mixed repo counts under each.',
span: 6,
kind: 'chart',
source: breakdown('language'),
empty: noRows,
chart: (d) => barChart(d),
table: breakdownTable,
},
{
id: 'indexing-speed',
title: 'Indexing speed',
note: 'Indexing runs by duration bucket.',
span: 6,
kind: 'chart',
source: breakdown('duration_bucket'),
empty: noRows,
chart: (d) => barChart(d, { scale: 'ordinal' }),
table: breakdownTable,
},
{
id: 'versions',
title: 'Users by app version',
note: 'Machine-days per version, newest first.',
span: 6,
kind: 'chart',
source: breakdown('codegraph_version'),
empty: noRows,
chart: (d) => barChart(d),
table: breakdownTable,
},
{
id: 'targets',
title: 'AI agent targets',
note: 'Agents wired up at install time. One install can configure several.',
span: 6,
kind: 'chart',
source: breakdown('target'),
empty: noRows,
chart: (d) => barChart(d),
table: breakdownTable,
},
];
+345
View File
@@ -0,0 +1,345 @@
/* Flat and editorial: square corners, hairline rules, sentence-case headings,
one oxblood accent. Matches getcodegraph.com.
No tiny all-caps tracked-out labels anywhere panel titles are real headings
at a readable size, and the fine print under them is sentence case. */
:root {
--paper: #f7f6f2;
--surface: #ffffff;
--ink: #16150f;
--secondary: #56534a;
--muted: #807d74;
--oxblood: #7a201a;
--rule: #d8d5cb;
--hairline: #e7e5de;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px;
background: var(--paper);
color: var(--ink);
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.5;
}
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--rule);
}
h1 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 600;
}
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--secondary);
}
/* --- filter row --------------------------------------------------------- */
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
padding: 16px 0;
border-bottom: 1px solid var(--rule);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-end {
margin-left: auto;
}
.custom-range label {
color: var(--secondary);
}
.custom-range input {
padding: 7px 10px;
font: inherit;
font-size: 15px;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 0;
}
.custom-range input:focus-visible,
button:focus-visible {
outline: 2px solid var(--oxblood);
outline-offset: 1px;
}
.filter-status {
flex-basis: 100%;
margin: 0;
color: var(--muted);
font-size: 14px;
}
.filter-status .dot {
padding: 0 4px;
}
/* --- buttons ------------------------------------------------------------ */
button {
padding: 8px 14px;
font: inherit;
font-size: 15px;
color: var(--paper);
background: var(--oxblood);
border: 1px solid var(--oxblood);
border-radius: 0;
cursor: pointer;
}
button.secondary,
button.range {
color: var(--ink);
background: transparent;
border-color: var(--rule);
}
button.secondary:hover,
button.range:hover {
border-color: var(--ink);
}
button.range.is-selected {
color: var(--paper);
background: var(--oxblood);
border-color: var(--oxblood);
}
button.link {
align-self: flex-start;
margin-top: 12px;
padding: 0;
color: var(--oxblood);
background: none;
border: none;
font-size: 14px;
text-decoration: underline;
text-underline-offset: 2px;
}
/* --- grid --------------------------------------------------------------- */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
margin-top: 24px;
}
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
/* A laptop is the target; below that the columns just widen rather than
pretending to be a phone layout. */
@media (max-width: 1180px) {
.span-3 { grid-column: span 6; }
.span-4,
.span-8 { grid-column: span 6; }
}
@media (max-width: 760px) {
.span-3,
.span-4,
.span-6,
.span-8 { grid-column: span 12; }
}
/* --- panels ------------------------------------------------------------- */
.panel {
display: flex;
flex-direction: column;
padding: 16px;
background: var(--surface);
border: 1px solid var(--rule);
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.panel-figure {
margin: 0;
color: var(--secondary);
font-size: 14px;
white-space: nowrap;
}
.panel-note {
margin: 6px 0 0;
color: var(--muted);
font-size: 13px;
}
.panel-body {
flex: 1;
margin-top: 12px;
/* Refetch dims the previous render instead of tearing it down no skeleton
flash, no layout jump. */
transition: opacity 120ms ease-out;
}
.panel[data-stale='true'] .panel-body {
opacity: 0.55;
}
.panel-state {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.panel[data-state='ready'] .panel-state {
display: none;
}
.panel[data-state='error'] .panel-state {
color: var(--oxblood);
}
/* Until a panel has data there is nothing to show but its state line. */
.panel:not([data-state='ready']) .chart-wrap,
.panel:not([data-state='ready']) .stat,
.panel:not([data-state='ready']) .funnel {
display: none;
}
/* Height covers the plot AND the axis band, so a panel never grows its own
little scrollbar. */
.chart-wrap {
position: relative;
height: 232px;
}
/* --- stat tiles --------------------------------------------------------- */
.stat-value {
margin: 4px 0 0;
font-size: 40px;
font-weight: 600;
line-height: 1.1;
/* Proportional figures on purpose: tabular-nums makes a number like 121 look
loose at display sizes. Tabular is for the table below. */
}
.stat-caption {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
}
/* --- funnel ------------------------------------------------------------- */
.funnel-stage + .funnel-stage {
margin-top: 16px;
}
.funnel-label {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--secondary);
font-size: 14px;
}
.funnel-value {
color: var(--ink);
font-size: 18px;
font-weight: 600;
}
.funnel-track {
height: 10px;
margin-top: 6px;
background: var(--hairline);
}
.funnel-fill {
height: 100%;
background: var(--oxblood);
}
.funnel-summary {
margin: 16px 0 0;
color: var(--secondary);
font-size: 14px;
}
/* --- table twins -------------------------------------------------------- */
.table-wrap {
margin-top: 12px;
max-height: 260px;
overflow-y: auto;
}
.table-wrap table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
/* Columns of numbers that align vertically the one place tabular figures
are the right call. */
font-variant-numeric: tabular-nums;
}
.table-wrap th,
.table-wrap td {
padding: 5px 8px 5px 0;
text-align: left;
border-bottom: 1px solid var(--hairline);
}
.table-wrap thead th {
position: sticky;
top: 0;
background: var(--surface);
color: var(--secondary);
font-weight: 600;
}
.table-wrap tbody th {
font-weight: 400;
}
.table-wrap td {
color: var(--secondary);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Chart theme the colours and the Chart.js defaults every panel inherits.
*
* The palette is not eyeballed. Both scales below were run through the data-viz
* validator against this dashboard's actual chart surface (#ffffff, the panel
* fill not the page's paper), and both clear every hard gate:
*
* categorical #a8342a,#2a6f9e,#17916a,#c98500 (light, surface #ffffff, --pairs all)
* lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair
* ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) ·
* contrast PASS (all 3:1, so no panel depends on the relief rule)
*
* ordinal #d99a90,#c26a5c,#a3423a,#7a201a (light, surface #ffffff, --ordinal)
* monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1
* PASS · single hue PASS (spread 3°)
*
* If you change a hex, re-run the validator rather than trusting your eye
* the red/green pair that "looks fine" is the one that collapses under
* deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence,
* never cycle, and fold a ninth series into "Other".
*/
/** Panel fill — the surface every contrast number above was measured against. */
export const SURFACE = '#ffffff';
export const INK = '#16150f';
export const SECONDARY = '#56534a';
export const MUTED = '#807d74';
export const GRID = '#e7e5de';
export const AXIS = '#c9c6bc';
/**
* Categorical identity. Slot 1 is the brand oxblood stepped up into the
* lightness band (#7a201a itself is too dark to sit in a categorical scale).
*/
export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500'];
/**
* Neutral, deliberately outside the categorical scale: "Other" is a leftover,
* not a series, and should not read as one.
*/
export const NEUTRAL = '#8d8a80';
/**
* Ordinal order IS the meaning (run length, codebase size). One hue, light to
* dark, so the reader sees the ordering in the colour instead of decoding a legend.
*/
export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a'];
/** Identity by position, never by rank — a filter must not repaint the survivors. */
export function categorical(index) {
return CATEGORICAL[index] ?? NEUTRAL;
}
/**
* Colours for an ordered set of n marks. Four buckets map onto the ramp exactly;
* a shorter set is spread across it so the lightdark reading survives. Anything
* past the ramp (an unexpected bucket from an old client) goes neutral rather
* than inventing a step that would misstate the order.
*/
export function ordinal(n) {
if (n <= 0) return [];
if (n === 1) return [ORDINAL[2]];
const out = [];
for (let i = 0; i < n; i++) {
out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL);
}
return out;
}
/** "Other" keeps the neutral wherever the API folded a tail into it. */
export function paletteFor(labels, scale) {
const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i));
return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i]));
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
const PLAIN = new Intl.NumberFormat('en-US');
/** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */
export function compact(n) {
if (n === null || n === undefined || Number.isNaN(n)) return '—';
return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n);
}
export function number(n) {
if (n === null || n === undefined || Number.isNaN(n)) return '—';
return PLAIN.format(n);
}
export function percent(fraction, digits = 1) {
if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—';
return `${(fraction * 100).toFixed(digits)}%`;
}
/** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */
export function shortDay(day) {
const parsed = Date.parse(`${day}T00:00:00Z`);
if (!Number.isFinite(parsed)) return day;
return new Date(parsed).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
// ---------------------------------------------------------------------------
// Chart.js defaults
// ---------------------------------------------------------------------------
/**
* Applied once, before any chart is built. Everything here is the recessive
* half of the design: hairline grid, muted axis text, no animation loud enough
* to notice. Text never wears a series colour identity comes from the mark
* beside it, which is why the legend uses point-style swatches.
*/
export function applyChartDefaults(Chart) {
const { defaults } = Chart;
defaults.font.family =
"'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
defaults.font.size = 12;
defaults.color = MUTED;
defaults.borderColor = GRID;
defaults.maintainAspectRatio = false;
defaults.animation.duration = 180;
defaults.plugins.legend.position = 'bottom';
defaults.plugins.legend.align = 'start';
defaults.plugins.legend.labels.usePointStyle = true;
defaults.plugins.legend.labels.pointStyle = 'circle';
defaults.plugins.legend.labels.boxWidth = 8;
defaults.plugins.legend.labels.boxHeight = 8;
defaults.plugins.legend.labels.padding = 14;
defaults.plugins.legend.labels.color = SECONDARY;
defaults.plugins.tooltip.backgroundColor = INK;
defaults.plugins.tooltip.padding = 10;
defaults.plugins.tooltip.cornerRadius = 0;
defaults.plugins.tooltip.displayColors = true;
defaults.plugins.tooltip.usePointStyle = true;
defaults.plugins.tooltip.boxWidth = 8;
defaults.plugins.tooltip.boxHeight = 8;
defaults.elements.line.borderWidth = 2;
defaults.elements.line.borderJoinStyle = 'round';
defaults.elements.line.borderCapStyle = 'round';
defaults.elements.line.tension = 0;
defaults.elements.point.hoverBorderWidth = 2;
defaults.elements.bar.borderRadius = 4;
defaults.elements.arc.borderColor = SURFACE;
// The 2px surface gap between touching fills — white doing the separating,
// rather than a stroke drawn around each mark.
defaults.elements.arc.borderWidth = 2;
}
/**
* `ticks` is merged rather than replaced: spreading an override on top would
* silently drop the tick limit and hand back a y-axis labelled every 10%.
*/
const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } });
/** A value axis: hairline grid, clean ticks, always anchored at zero. */
export function valueScale(extra = {}) {
return scale(
{
beginAtZero: true,
border: { color: AXIS },
grid: { color: GRID, drawTicks: false },
ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 },
},
extra,
);
}
/** A category or time axis: no grid at all, so the marks carry the chart. */
export function categoryScale(extra = {}) {
return scale(
{
border: { color: AXIS },
grid: { display: false },
ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 },
},
extra,
);
}
/**
* Crosshair-style reading on anything plotted against days: hovering anywhere in
* a column reports every series at that day, so a 2px line never has to be hit
* dead-centre.
*/
export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' };
+208
View File
@@ -0,0 +1,208 @@
-- Seed data for the dashboard's local checks: 12 machines over 10 days
-- (2026-07-01 … 2026-07-10), small enough that every number on every panel can
-- be worked out by hand from the events below and checked against the API.
--
-- npm run seed (writes the LOCAL .wrangler D1 — never the remote one)
--
-- Only the raw `events` rows are hand-authored. `machine_days`,
-- `machine_first_seen` and the three `daily_*` rollups are DERIVED from them at
-- the bottom of this file by the same aggregations the writers use in
-- telemetry-worker/ (the ingest path and the nightly cron respectively), so the
-- fixture can never drift into a state production could not produce.
--
-- The machines, and what each one does:
--
-- id first os arch ver ci installs indexes on uninstalls
-- m01 07-01 darwin arm64 1.4.0 0 local 07-01, 07-02, 07-04
-- m02 07-01 darwin arm64 1.4.0 0 global 07-01
-- m03 07-01 linux x64 1.4.0 0 local 07-03
-- m04 07-01 win32 x64 1.4.0 0 local never 07-06
-- m05 07-02 darwin arm64 1.4.0 0 local 07-02
-- m06 07-02 linux x64 1.4.1 0 local never 07-07
-- m07 07-03 darwin x64 1.4.1 0 local 07-03
-- m08 07-05 linux arm64 1.5.0 0 global 07-06
-- m09 07-05 win32 x64 1.5.0 0 local 07-05, 07-07
-- m10 07-08 darwin arm64 1.5.0 0 local 07-08
-- m11 07-09 linux x64 1.5.0 0 local 07-10
-- m12 07-09 linux x64 1.5.0 1 global 07-09 (CI runner)
--
-- m04 and m06 never index: they are the two machines the activation funnel is
-- supposed to lose (12 installs → 10 activated → 83.3%). m12 is the one CI
-- machine, so "production users" is 11 where "active machines" is 12.
DELETE FROM daily_dim_counts;
DELETE FROM daily_event_counts;
DELETE FROM daily_machines;
DELETE FROM machine_days;
DELETE FROM machine_first_seen;
DELETE FROM events;
-- ---------------------------------------------------------------------------
-- install — 12, one per machine on its first day
-- ---------------------------------------------------------------------------
INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
VALUES
('2026-07-01T09:00:00Z','2026-07-01T09:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","cursor"]}'),
('2026-07-01T09:05:00Z','2026-07-01T09:05:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude"]}'),
('2026-07-01T10:00:00Z','2026-07-01T10:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["codex"]}'),
('2026-07-01T11:00:00Z','2026-07-01T11:00:00Z','2026-07-01','install','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude","opencode"]}'),
('2026-07-02T09:00:00Z','2026-07-02T09:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
('2026-07-02T14:00:00Z','2026-07-02T14:00:00Z','2026-07-02','install','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'),
('2026-07-03T08:00:00Z','2026-07-03T08:00:00Z','2026-07-03','install','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"scope":"local","kind":"upgrade","targets":["claude"]}'),
('2026-07-05T08:00:00Z','2026-07-05T08:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"scope":"global","kind":"fresh","targets":["claude","codex"]}'),
('2026-07-05T09:00:00Z','2026-07-05T09:00:00Z','2026-07-05','install','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
('2026-07-08T08:00:00Z','2026-07-08T08:00:00Z','2026-07-08','install','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"scope":"local","kind":"fresh","targets":["cursor"]}'),
('2026-07-09T08:00:00Z','2026-07-09T08:00:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"scope":"local","kind":"fresh","targets":["claude"]}'),
('2026-07-09T08:30:00Z','2026-07-09T08:30:00Z','2026-07-09','install','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"scope":"global","kind":"fresh","targets":["claude"]}');
-- ---------------------------------------------------------------------------
-- index — 13 runs
-- languages typescript 7 · javascript 2 · python 2 · go 2 · rust 2 · csharp 2 · java 1 (18 rows)
-- file_count_bucket <100 2 · 100-1k 5 · 1k-10k 4 · 10k+ 2
-- duration_bucket <10s 5 · 10-60s 4 · 1-5m 2 · 5m+ 2
-- ---------------------------------------------------------------------------
INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
VALUES
('2026-07-01T09:10:00Z','2026-07-01T09:10:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
('2026-07-01T09:20:00Z','2026-07-01T09:20:00Z','2026-07-01','index','00000000-0000-4000-8000-000000000002','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'),
('2026-07-02T10:00:00Z','2026-07-02T10:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript","javascript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}'),
('2026-07-02T11:00:00Z','2026-07-02T11:00:00Z','2026-07-02','index','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"languages":["python"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'),
('2026-07-03T09:00:00Z','2026-07-03T09:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000003','1.4.0','linux','x64',20,0,2,'{"languages":["go"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
('2026-07-03T10:00:00Z','2026-07-03T10:00:00Z','2026-07-03','index','00000000-0000-4000-8000-000000000007','1.4.1','darwin','x64',22,0,2,'{"languages":["typescript","rust"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'),
('2026-07-04T10:00:00Z','2026-07-04T10:00:00Z','2026-07-04','index','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"100-1k","duration_bucket":"<10s"}'),
('2026-07-05T09:30:00Z','2026-07-05T09:30:00Z','2026-07-05','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'),
('2026-07-06T09:00:00Z','2026-07-06T09:00:00Z','2026-07-06','index','00000000-0000-4000-8000-000000000008','1.5.0','linux','arm64',22,0,2,'{"languages":["rust","go"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}'),
('2026-07-07T09:00:00Z','2026-07-07T09:00:00Z','2026-07-07','index','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"languages":["csharp"],"file_count_bucket":"1k-10k","duration_bucket":"10-60s"}'),
('2026-07-08T08:10:00Z','2026-07-08T08:10:00Z','2026-07-08','index','00000000-0000-4000-8000-000000000010','1.5.0','darwin','arm64',22,0,2,'{"languages":["typescript"],"file_count_bucket":"<100","duration_bucket":"<10s"}'),
('2026-07-09T09:00:00Z','2026-07-09T09:00:00Z','2026-07-09','index','00000000-0000-4000-8000-000000000012','1.5.0','linux','x64',22,1,2,'{"languages":["java"],"file_count_bucket":"10k+","duration_bucket":"5m+"}'),
('2026-07-10T09:00:00Z','2026-07-10T09:00:00Z','2026-07-10','index','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"languages":["python","typescript"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}');
-- ---------------------------------------------------------------------------
-- uninstall — 2
-- ---------------------------------------------------------------------------
INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
VALUES
('2026-07-06T12:00:00Z','2026-07-06T12:00:00Z','2026-07-06','uninstall','00000000-0000-4000-8000-000000000004','1.4.0','win32','x64',22,0,2,'{"targets":["claude","opencode"]}'),
('2026-07-07T12:00:00Z','2026-07-07T12:00:00Z','2026-07-07','uninstall','00000000-0000-4000-8000-000000000006','1.4.1','linux','x64',20,0,2,'{"targets":["cursor"]}');
-- ---------------------------------------------------------------------------
-- usage_rollup — 5 rows, 85 calls (the `count` prop is summed, never the rows)
-- codegraph_explore 82 · index 3 | Claude Code 70 · Cursor 12
-- ---------------------------------------------------------------------------
INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
VALUES
('2026-07-03T02:00:00Z','2026-07-02T12:00:00Z','2026-07-02','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":40,"error_count":1,"client_name":"Claude Code"}'),
('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000001','1.4.0','darwin','arm64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":25,"client_name":"Claude Code"}'),
('2026-07-04T02:00:00Z','2026-07-03T12:00:00Z','2026-07-03','usage_rollup','00000000-0000-4000-8000-000000000005','1.4.0','darwin','arm64',22,0,2,'{"kind":"cli_command","name":"index","count":3}'),
('2026-07-07T02:00:00Z','2026-07-06T12:00:00Z','2026-07-06','usage_rollup','00000000-0000-4000-8000-000000000009','1.5.0','win32','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Cursor"}'),
('2026-07-11T02:00:00Z','2026-07-10T12:00:00Z','2026-07-10','usage_rollup','00000000-0000-4000-8000-000000000011','1.5.0','linux','x64',22,0,2,'{"kind":"mcp_tool","name":"codegraph_explore","count":5,"client_name":"Claude Code"}');
-- ---------------------------------------------------------------------------
-- Derived: what the ingest worker writes on every batch
-- ---------------------------------------------------------------------------
-- prod is 0 only when EVERY event a machine sent that day carried ci = 1, which
-- is what makes m12 the only non-production machine-day.
INSERT INTO machine_days (machine_id, day, prod)
SELECT machine_id, day, max(CASE WHEN ci = 1 THEN 0 ELSE 1 END) FROM events GROUP BY machine_id, day;
INSERT INTO machine_first_seen (machine_id, first_day)
SELECT machine_id, min(day) FROM events GROUP BY machine_id;
-- ---------------------------------------------------------------------------
-- Derived: what the nightly cron writes
-- ---------------------------------------------------------------------------
-- These mirror ROLLUP_STATEMENTS in telemetry-worker/src/rollup.ts, with the
-- single-day filter dropped so one pass seeds the whole fixture range.
INSERT INTO daily_machines (day, machines, prod_machines)
SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days GROUP BY day;
INSERT INTO daily_event_counts (day, event, count, machines)
SELECT day, event,
CASE WHEN event = 'usage_rollup'
THEN sum(coalesce(json_extract(props, '$.count'), 0))
ELSE count(*) END,
count(DISTINCT machine_id)
FROM events GROUP BY day, event;
-- Envelope dimensions — carried by every event.
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'os', CAST(os AS TEXT),
CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
count(DISTINCT machine_id)
FROM events WHERE os IS NOT NULL AND os <> '' GROUP BY day, event, os;
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'arch', CAST(arch AS TEXT),
CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
count(DISTINCT machine_id)
FROM events WHERE arch IS NOT NULL AND arch <> '' GROUP BY day, event, arch;
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'codegraph_version', CAST(codegraph_version AS TEXT),
CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
count(DISTINCT machine_id)
FROM events WHERE codegraph_version IS NOT NULL AND codegraph_version <> '' GROUP BY day, event, codegraph_version;
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'node_major', CAST(node_major AS TEXT),
CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
count(DISTINCT machine_id)
FROM events WHERE node_major IS NOT NULL GROUP BY day, event, node_major;
-- Event-specific scalar props.
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'file_count_bucket', CAST(json_extract(props, '$.file_count_bucket') AS TEXT), count(*), count(DISTINCT machine_id)
FROM events WHERE event = 'index' AND json_extract(props, '$.file_count_bucket') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.file_count_bucket');
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'duration_bucket', CAST(json_extract(props, '$.duration_bucket') AS TEXT), count(*), count(DISTINCT machine_id)
FROM events WHERE event = 'index' AND json_extract(props, '$.duration_bucket') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.duration_bucket');
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'scope', CAST(json_extract(props, '$.scope') AS TEXT), count(*), count(DISTINCT machine_id)
FROM events WHERE event = 'install' AND json_extract(props, '$.scope') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.scope');
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'kind', CAST(json_extract(props, '$.kind') AS TEXT),
CASE WHEN event = 'usage_rollup' THEN sum(coalesce(json_extract(props, '$.count'), 0)) ELSE count(*) END,
count(DISTINCT machine_id)
FROM events WHERE event IN ('install', 'usage_rollup') AND json_extract(props, '$.kind') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.kind');
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'name', CAST(json_extract(props, '$.name') AS TEXT),
sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id)
FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.name');
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'client_name', CAST(json_extract(props, '$.client_name') AS TEXT),
sum(coalesce(json_extract(props, '$.count'), 0)), count(DISTINCT machine_id)
FROM events WHERE event = 'usage_rollup' AND json_extract(props, '$.client_name') IS NOT NULL
GROUP BY day, event, json_extract(props, '$.client_name');
-- Array props — one row per element, so a TypeScript+Go repo counts under both.
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT e.day, e.event, 'language', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
FROM events e, json_each(e.props, '$.languages') j
WHERE e.event = 'index' AND j.value <> ''
GROUP BY e.day, e.event, j.value;
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT e.day, e.event, 'target', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
FROM events e, json_each(e.props, '$.targets') j
WHERE e.event IN ('install', 'uninstall') AND j.value <> ''
GROUP BY e.day, e.event, j.value;
-- Errors per tool: count is errors, machines is the machines that saw one.
INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT day, event, 'name_error', CAST(json_extract(props, '$.name') AS TEXT),
sum(json_extract(props, '$.error_count')), count(DISTINCT machine_id)
FROM events
WHERE event = 'usage_rollup' AND json_extract(props, '$.name') IS NOT NULL
AND coalesce(json_extract(props, '$.error_count'), 0) > 0
GROUP BY day, event, json_extract(props, '$.name');
@@ -0,0 +1,465 @@
#!/usr/bin/env node
/**
* Renders the dashboard in a real browser against the fixture and checks that
* every panel drew, and drew the numbers the API returned.
*
* smoke-api.sh proves the SQL; this proves the other half that each panel is
* wired to the right endpoint and plots it without mangling it. It reads the
* Chart.js instance off each canvas and compares its dataset arrays against the
* same endpoint fetched straight from Node, so a panel pointed at the wrong dim
* fails here even though both halves are individually fine.
*
* node scripts/render-check.mjs (or: npm run smoke:render)
*
* Zero new dependencies: it drives whatever Chromium is already on the machine
* over the DevTools protocol (Node 22 has WebSocket built in). With no browser
* installed it SKIPS rather than fails the shell smoke suites stay the
* portable floor, and this is the deeper check where a browser exists.
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const PORT = Number(process.env.DASH_PORT ?? 8790);
const BASE = `http://127.0.0.1:${PORT}`;
/** The fixture's own window — see scripts/fixture.sql. */
const FROM = '2026-07-01';
const TO = '2026-07-10';
let pass = 0;
let fail = 0;
const ok = (what) => {
console.log(` ok ${what}`);
pass++;
};
const bad = (what, detail) => {
console.log(` FAIL ${what}${detail ? ` (${detail})` : ''}`);
fail++;
};
const check = (what, condition, detail) => (condition ? ok(what) : bad(what, detail));
const same = (what, expected, actual) =>
check(
what,
JSON.stringify(expected) === JSON.stringify(actual),
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// ---------------------------------------------------------------------------
// Finding a browser
// ---------------------------------------------------------------------------
/** Expands one `*` in a path segment, newest match first. */
function glob(pattern) {
const [head, ...rest] = pattern.split('*');
const base = dirname(head);
const prefix = head.slice(base.length + 1);
if (!existsSync(base)) return [];
return readdirSync(base)
.filter((name) => name.startsWith(prefix))
.sort()
.reverse()
.map((name) => join(base, name) + rest.join('*'));
}
function findBrowser() {
const home = process.env.HOME ?? '';
const candidates = [
process.env.CHROME_BIN,
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell`),
...glob(`${home}/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-x64/chrome-headless-shell`),
...glob(`${home}/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux/chrome-headless-shell`),
...glob(`${home}/.cache/ms-playwright/chromium-*/chrome-linux/chrome`),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/google-chrome',
];
return candidates.find((path) => path && existsSync(path)) ?? null;
}
// ---------------------------------------------------------------------------
// A minimal DevTools-protocol client
// ---------------------------------------------------------------------------
class CDP {
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.events = [];
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.id !== undefined) {
const waiter = this.pending.get(message.id);
if (!waiter) return;
this.pending.delete(message.id);
if (message.error) waiter.reject(new Error(message.error.message));
else waiter.resolve(message.result);
} else {
this.events.push(message);
}
});
}
static async connect(url) {
const socket = new WebSocket(url);
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true });
socket.addEventListener('error', () => reject(new Error(`cannot reach ${url}`)), { once: true });
});
return new CDP(socket);
}
send(method, params = {}, sessionId) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
});
}
/** Runs an expression in the page and returns its value, awaiting promises. */
async evaluate(sessionId, expression) {
const result = await this.send(
'Runtime.evaluate',
{ expression, returnByValue: true, awaitPromise: true },
sessionId,
);
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description ?? 'page threw');
}
return result.result.value;
}
}
// ---------------------------------------------------------------------------
// The page probe
// ---------------------------------------------------------------------------
/**
* Runs inside the page. Reads what each panel actually rendered including the
* live Chart.js instance behind each canvas rather than trusting that a
* fetch resolved.
*/
const PROBE = `(() => {
const panels = [...document.querySelectorAll('[data-panel]')].map((section) => {
const canvas = section.querySelector('canvas');
const chart = canvas && window.Chart ? window.Chart.getChart(canvas) : null;
return {
id: section.dataset.panel,
state: section.dataset.state,
stale: section.dataset.stale,
title: section.querySelector('h2').textContent,
figure: section.querySelector('[data-role="figure"]').textContent,
note: section.querySelector('.panel-note')?.textContent ?? '',
message: section.querySelector('[data-role="state"]').textContent,
stat: section.querySelector('.stat-value')?.textContent ?? null,
funnelValues: [...section.querySelectorAll('.funnel-value')].map((n) => n.textContent),
funnelWidths: [...section.querySelectorAll('.funnel-fill')].map((n) => n.style.width),
chart: chart && {
type: chart.config.type,
labels: chart.data.labels,
datasets: chart.data.datasets.map((d) => ({ label: d.label, data: d.data })),
legend: chart.options.plugins?.legend?.display !== false,
},
tableRows: section.querySelectorAll('[data-role="table"] tbody tr').length,
tableCols: section.querySelectorAll('[data-role="table"] thead th').length,
tableHidden: section.querySelector('[data-role="table"]').hidden,
};
});
return {
ready: document.body.dataset.ready === 'true',
range: document.getElementById('range-summary').textContent,
dataThrough: document.getElementById('data-through').textContent,
refreshed: document.getElementById('refreshed-at').textContent,
selectedPreset: document.querySelector('button.range.is-selected')?.textContent ?? null,
panels,
};
})()`;
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
const children = [];
let profileDir = null;
function cleanup() {
for (const child of children) {
try {
child.kill('SIGTERM');
} catch {
/* already gone */
}
}
if (profileDir) rmSync(profileDir, { recursive: true, force: true });
}
process.on('exit', cleanup);
process.on('SIGINT', () => process.exit(130));
function run(command, args, options = {}) {
const child = spawn(command, args, { cwd: root, stdio: 'ignore', ...options });
children.push(child);
return child;
}
async function waitFor(what, probe, attempts = 90) {
for (let i = 0; i < attempts; i++) {
try {
if (await probe()) return true;
} catch {
/* not up yet */
}
await sleep(1000);
}
throw new Error(`timed out waiting for ${what}`);
}
async function main() {
const browserPath = findBrowser();
if (!browserPath) {
console.log('render-check: no Chromium found — skipping.');
console.log(' Set CHROME_BIN, or install Chrome; the shell smoke suites cover the rest.');
return 0;
}
console.log(`Browser: ${browserPath}`);
console.log('Seeding the local D1 fixture…');
const seed = run('./scripts/seed-fixture.sh', [], { stdio: 'inherit' });
const seeded = await new Promise((resolve) => seed.on('exit', resolve));
if (seeded !== 0) throw new Error('seeding failed');
console.log(`Starting wrangler dev on :${PORT}`);
run('npx', ['wrangler', 'dev', '--port', String(PORT), '--ip', '127.0.0.1']);
await waitFor('wrangler dev', async () => (await fetch(`${BASE}/robots.txt`)).ok);
const password = readFileSync(join(root, '.dev.vars'), 'utf8').match(/^ADMIN_PASSWORD="(.*)"$/m)?.[1];
if (!password) throw new Error('no ADMIN_PASSWORD in .dev.vars');
const login = await fetch(`${BASE}/login`, {
method: 'POST',
body: new URLSearchParams({ password }),
redirect: 'manual',
});
const cookie = login.headers.getSetCookie().find((c) => c.startsWith('cg_admin_session='));
if (!cookie) throw new Error('login did not set a session cookie');
const [name, value] = cookie.split(';')[0].split('=');
profileDir = mkdtempSync(join(tmpdir(), 'cg-dash-profile-'));
// chrome-headless-shell is headless by construction and rejects the flag;
// a full Chrome needs it.
const headlessFlag = browserPath.includes('headless') ? [] : ['--headless=new'];
run(browserPath, [
...headlessFlag,
'--disable-gpu',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=0',
`--user-data-dir=${profileDir}`,
'about:blank',
]);
let devtoolsPort = null;
await waitFor('the browser', () => {
const portFile = join(profileDir, 'DevToolsActivePort');
if (!existsSync(portFile)) return false;
devtoolsPort = Number(readFileSync(portFile, 'utf8').split('\n')[0]);
return Number.isFinite(devtoolsPort) && devtoolsPort > 0;
}, 30);
const version = await (await fetch(`http://127.0.0.1:${devtoolsPort}/json/version`)).json();
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
await cdp.send('Page.enable', {}, sessionId);
await cdp.send('Runtime.enable', {}, sessionId);
await cdp.send('Log.enable', {}, sessionId);
await cdp.send('Network.enable', {}, sessionId);
await cdp.send('Network.setCookie', { url: BASE, name, value, path: '/', httpOnly: true }, sessionId);
await cdp.send('Page.navigate', { url: `${BASE}/` }, sessionId);
await waitFor('the dashboard to finish rendering', async () => {
const view = await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"');
return view === true;
}, 60);
let view = await cdp.evaluate(sessionId, PROBE);
// -- what loaded ---------------------------------------------------------
console.log('\nThe page renders');
// The very same registry the page just rendered from, imported here so the
// expectations cannot drift from the panels under test.
const { PANELS } = await import(pathToFileURL(join(root, 'public', 'panels.js')).href);
check(`all ${PANELS.length} panels are on the page`, view.panels.length === PANELS.length, `got ${view.panels.length}`);
const broken = view.panels.filter((p) => p.state !== 'ready');
check(
'every panel reached its ready state',
broken.length === 0,
broken.map((p) => `${p.id}: ${p.state} ${p.message}`).join(' | '),
);
check('the default range is the 30-day preset', view.selectedPreset === 'Last 30 days', view.selectedPreset);
check('the range is stated in the filter row', /Jun|Jul/.test(view.range), view.range);
check('the data horizon is stated', view.dataThrough.includes('Jul 10'), view.dataThrough);
check('the refresh time is stated', view.refreshed.startsWith('Last refreshed'), view.refreshed);
// A CSP violation surfaces here as a `security` log entry, which is the point
// of the check: the page must work under `script-src 'self'` with no inline
// styles at all. The favicon 404 is expected — there isn't one — and is the
// only network noise allowed through.
const errors = cdp.events.filter(
(e) =>
(e.method === 'Log.entryAdded' &&
e.params.entry.level === 'error' &&
!/favicon/.test(e.params.entry.url ?? '')) ||
e.method === 'Runtime.exceptionThrown',
);
check(
'no console errors — the strict CSP allows everything the page needs',
errors.length === 0,
errors.map((e) => e.params.entry?.text ?? e.params.exceptionDetails?.text).join(' | '),
);
// -- the range picker really re-queries ----------------------------------
console.log('\nChanging the range re-queries every panel');
await cdp.evaluate(
sessionId,
`document.body.dataset.ready = "";
[...document.querySelectorAll('button.range')].find((b) => b.textContent === 'Last 7 days').click();`,
);
await waitFor('the 7-day render', async () =>
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
);
view = await cdp.evaluate(sessionId, PROBE);
const weekly = view.panels.find((p) => p.id === 'daily-production-users');
check('a daily line now holds 7 points', weekly.chart?.labels.length === 7, `${weekly.chart?.labels.length}`);
check('the 7-day preset is marked selected', view.selectedPreset === 'Last 7 days', view.selectedPreset);
check('every panel re-rendered cleanly', view.panels.every((p) => p.state === 'ready'));
console.log('\nA custom range works the same way');
await cdp.evaluate(
sessionId,
`document.body.dataset.ready = "";
document.querySelector('[data-role="custom-from"]').value = "${FROM}";
document.querySelector('[data-role="custom-to"]').value = "${TO}";
document.querySelector('[data-role="custom-apply"]').click();`,
);
await waitFor('the custom-range render', async () =>
(await cdp.evaluate(sessionId, 'document.body.dataset.ready === "true"')) === true,
);
view = await cdp.evaluate(sessionId, PROBE);
check('the fixture window is 10 days', view.panels.find((p) => p.id === 'daily-production-users').chart?.labels.length === 10);
check('no preset stays highlighted', view.selectedPreset === null, view.selectedPreset);
// -- every panel plots what the API returned ------------------------------
console.log('\nEvery panel plots the APIs own numbers');
const query = `from=${FROM}&to=${TO}`;
const fetched = new Map();
const apiGet = async (path) => {
if (!fetched.has(path)) {
fetched.set(
path,
fetch(`${BASE}${path}`, { headers: { cookie: `${name}=${value}` } }).then((r) => r.json()),
);
}
return fetched.get(path);
};
for (const panel of PANELS) {
const rendered = view.panels.find((p) => p.id === panel.id);
const data = await apiGet(panel.source(query));
if (panel.kind === 'chart') {
const plotted = rendered.chart?.datasets.map((d) => d.data);
same(`${panel.id}: plots the endpoint's series`, data.datasets.map((d) => d.data), plotted);
// A legend is owed wherever colour carries identity: any multi-series
// chart, and every pie (whose slices are identities inside one dataset).
// A single line needs none — the panel title already names it.
const owed = rendered.chart.type === 'pie' || data.datasets.length > 1;
check(
`${panel.id}: a legend exactly where colour carries identity`,
rendered.chart.legend === owed,
`legend ${rendered.chart.legend}, expected ${owed}`,
);
} else if (panel.kind === 'stat') {
same(`${panel.id}: shows the endpoint's number`, panel.stat(data).value, rendered.stat);
} else if (panel.kind === 'funnel') {
same(
`${panel.id}: shows both funnel stages`,
panel.funnel(data).stages.map((s) => s.value.toLocaleString('en-US')),
rendered.funnelValues,
);
}
const table = panel.table(data);
check(
`${panel.id}: the table twin carries every row`,
rendered.tableRows === table.rows.length && rendered.tableCols === table.columns.length,
`${rendered.tableRows}×${rendered.tableCols} vs ${table.rows.length}×${table.columns.length}`,
);
}
// -- a few numbers checked against the fixture by hand --------------------
console.log('\nSpot checks against the fixture, worked out by hand');
const byId = Object.fromEntries(view.panels.map((p) => [p.id, p]));
same('production users is 11 (m12 is the CI machine)', '11', byId['production-users'].stat);
same('installs is 12', '12', byId['installs'].stat);
same('uninstalls is 2', '2', byId['uninstalls'].stat);
same('indexing runs is 13', '13', byId['indexing-runs'].stat);
same('the funnel loses m04 and m06', ['12', '10'], byId['activation-funnel'].funnelValues);
const widths = byId['activation-funnel'].funnelWidths;
check(
'…and draws the drop as a shorter bar',
widths[0] === '100%' && widths[1].startsWith('83.3'),
widths.join(' / '),
);
same('the OS pie is machine-days', ['linux', 'darwin', 'win32'], byId.os.chart.labels);
same('…and its slices are 9 / 8 / 4', [[9, 8, 4]], byId.os.chart.datasets.map((d) => d.data));
check('…with the honest metric named under the title', byId.os.figure === '21 machine-days', byId.os.figure);
same('run length keeps its bucket order', ['<10s', '10-60s', '1-5m', '5m+'], byId['run-length'].chart.labels);
same('languages lead with typescript', 'typescript', byId.languages.chart.labels[0]);
check('retention starts at 100%', byId.retention.chart.datasets[0].data[0] === 100);
// Colour, spacing and label collisions are not things an assertion catches.
// RENDER_SHOT=/tmp/dash.png npm run smoke:render → look at it.
if (process.env.RENDER_SHOT) {
await cdp.send(
'Emulation.setDeviceMetricsOverride',
{ width: 1440, height: 900, deviceScaleFactor: 2, mobile: false },
sessionId,
);
await sleep(500);
const shot = await cdp.send(
'Page.captureScreenshot',
{ format: 'png', captureBeyondViewport: true },
sessionId,
);
writeFileSync(process.env.RENDER_SHOT, Buffer.from(shot.data, 'base64'));
console.log(`\nScreenshot written to ${process.env.RENDER_SHOT}`);
}
console.log('\nPanel copy follows the house rules');
const capsy = view.panels.filter((p) => /^[A-Z0-9 ]{4,}$/.test(p.title));
check('no shouty panel titles', capsy.length === 0, capsy.map((p) => p.title).join(', '));
check('every panel says what it is counting', view.panels.every((p) => p.note.length > 20));
check('tables start closed', view.panels.every((p) => p.tableHidden));
return fail;
}
try {
const failures = await main();
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(failures === 0 ? 0 : 1);
} catch (err) {
console.error(`\nrender-check: ${err.message}`);
process.exit(1);
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Loads scripts/fixture.sql into the LOCAL .wrangler D1 (never the remote one:
# --local is on every command here, and nothing in this repo writes production).
#
# The schema comes from the writer, telemetry-worker/migrations/, because that
# is where it belongs — D1 is read-only from this worker.
#
# ./scripts/seed-fixture.sh (or: npm run seed)
set -uo pipefail
cd "$(dirname "$0")/.."
DB=codegraph-telemetry
MIGRATION=../telemetry-worker/migrations/0001_init.sql
if [[ ! -f "$MIGRATION" ]]; then
echo "seed: cannot find $MIGRATION — run this from a full checkout" >&2
exit 1
fi
# The migration is plain CREATE TABLE, so a second run fails on "table already
# exists". That is the expected steady state here, hence the swallowed output —
# the fixture load below is the step whose failure actually matters.
npx wrangler d1 execute "$DB" --local --file="$MIGRATION" >/dev/null 2>&1
if ! npx wrangler d1 execute "$DB" --local --file=scripts/fixture.sql >/dev/null; then
echo "seed: loading scripts/fixture.sql failed" >&2
exit 1
fi
echo "seed: fixture loaded into the local $DB (12 machines, 2026-07-01 … 2026-07-10)"
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# End-to-end check of the chart API against the committed fixture.
#
# Every expected number below is worked out by hand from scripts/fixture.sql —
# the header comment there lists all twelve machines and what each one does — so
# a failure here means the SQL changed its mind, not that a golden file drifted.
#
# ./scripts/smoke-api.sh (or: npm run smoke:api)
set -uo pipefail
cd "$(dirname "$0")/.."
# Deliberately NOT $PORT — see smoke-auth.sh.
DASH_PORT="${DASH_PORT:-8789}"
BASE="http://127.0.0.1:${DASH_PORT}"
PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
JAR="$(mktemp -t cg-api-jar)"
LOG="$(mktemp -t cg-api-log)"
PASS=0
FAIL=0
# The fixture's own window. Every assertion is scoped to it, so a later fixture
# row outside these days cannot silently change an expected number.
FROM=2026-07-01
TO=2026-07-10
RANGE="from=$FROM&to=$TO"
cleanup() {
[[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
rm -f "$JAR" "$LOG"
}
trap cleanup EXIT
status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
get() { curl -s -b "$JAR" "$BASE$1"; }
# Resolves a dotted path through the JSON. Numeric segments index arrays, so
# `datasets.0.data` works. Node rather than jq: this is a Node project, jq is not.
jget() {
node -e '
let v = JSON.parse(process.argv[1]);
for (const key of process.argv[2].split(".")) v = v?.[key];
console.log(v === undefined ? "<missing>" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
' "$1" "$2"
}
check() { # check <description> <expected> <actual>
if [[ "$2" == "$3" ]]; then
printf ' ok %s\n' "$1"
PASS=$((PASS + 1))
else
printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3"
FAIL=$((FAIL + 1))
fi
}
field() { # field <description> <path> <expected> <json>
check "$1" "$3" "$(jget "$4" "$2")"
}
echo "Seeding the local D1 fixture…"
./scripts/seed-fixture.sh || exit 1
echo "Starting wrangler dev on :${DASH_PORT}"
npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
DEV_PID=$!
READY=""
for _ in $(seq 1 90); do
if [[ "$(curl -s "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
sleep 1
done
if [[ -z "$READY" ]]; then
echo "wrangler dev never came up on :${DASH_PORT} — log follows"
cat "$LOG"
exit 1
fi
echo
echo "The gate still holds on every new endpoint"
for path in summary meta timeseries breakdown activation retention; do
check "GET /api/$path without a cookie → 401" 401 "$(status "$BASE/api/$path")"
done
curl -s -o /dev/null -c "$JAR" -X POST -d "password=$PASSWORD" "$BASE/login"
check "signed in" 200 "$(status -b "$JAR" "$BASE/api/session")"
echo
echo "Caching"
check "chart data is privately cacheable" "private, max-age=300" \
"$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/summary" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
check "health stays uncached" "no-store" \
"$(curl -sD - -o /dev/null -b "$JAR" "$BASE/api/health" | grep -i '^cache-control:' | cut -d' ' -f2- | tr -d '\r')"
echo
echo "/api/meta — what the range picker anchors on"
META="$(get "/api/meta")"
field "latest day" latest_day 2026-07-10 "$META"
field "earliest day" earliest_day 2026-07-01 "$META"
field "raw events start" earliest_raw_day 2026-07-01 "$META"
field "retention window" retention_days 14 "$META"
echo
echo "/api/summary — the big numbers (12 machines, one of them CI)"
SUMMARY="$(get "/api/summary?$RANGE")"
field "production users (m12 is CI)" production_users 11 "$SUMMARY"
field "active machines" active_machines 12 "$SUMMARY"
field "new machines" new_machines 12 "$SUMMARY"
field "installs" installs 12 "$SUMMARY"
field "uninstalls" uninstalls 2 "$SUMMARY"
field "indexing runs" index_runs 13 "$SUMMARY"
field "tool calls (SUM of count)" tool_calls 85 "$SUMMARY"
field "range echoed back" range.days 10 "$SUMMARY"
echo
echo "/api/timeseries — one dense point per day, zeros where nothing happened"
TS="$(get "/api/timeseries?metric=installs_uninstalls&$RANGE")"
field "10 labels" labels.0 2026-07-01 "$TS"
field "installs" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS"
field "uninstalls" datasets.1.data '[0,0,0,0,0,1,1,0,0,0]' "$TS"
field "legend labels" datasets.1.label Uninstalls "$TS"
TS="$(get "/api/timeseries?metric=new_installs&$RANGE")"
field "new installs by first-seen day" datasets.0.data '[4,2,1,0,2,0,0,1,2,0]' "$TS"
TS="$(get "/api/timeseries?metric=production_users&$RANGE")"
field "daily production users" datasets.0.data '[4,3,4,1,2,3,2,1,1,1]' "$TS"
TS="$(get "/api/timeseries?metric=indexing_activity&$RANGE")"
field "indexing runs" datasets.0.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
field "machines indexing" datasets.1.data '[2,2,2,1,1,1,1,1,1,1]' "$TS"
TS="$(get "/api/timeseries?metric=tool_calls&$RANGE")"
field "calls per day" datasets.0.data '[0,40,28,0,0,12,0,0,0,5]' "$TS"
field "machines per day" datasets.1.data '[0,1,2,0,0,1,0,0,0,1]' "$TS"
TS="$(get "/api/timeseries?metric=duration_buckets&$RANGE")"
field "bucket order is the scale" datasets.0.label '<10s' "$TS"
field "…and ends at the longest" datasets.3.label '5m+' "$TS"
field "<10s over time" datasets.0.data '[2,0,1,1,0,0,0,1,0,0]' "$TS"
field "10-60s over time" datasets.1.data '[0,2,0,0,0,0,1,0,0,1]' "$TS"
field "1-5m over time" datasets.2.data '[0,0,0,0,1,1,0,0,0,0]' "$TS"
field "5m+ over time" datasets.3.data '[0,0,1,0,0,0,0,0,1,0]' "$TS"
echo
echo "/api/breakdown — bars and pies"
# machine-days, taking the largest per-event count per day so one machine's
# install + index + usage_rollup on one day is not counted three times.
BD="$(get "/api/breakdown?dim=os&$RANGE")"
field "os labels" labels '["linux","darwin","win32"]' "$BD"
field "os machine-days" datasets.0.data '[9,8,4]' "$BD"
field "os metric named" datasets.0.label 'Machine-days' "$BD"
field "os total" total 21 "$BD"
BD="$(get "/api/breakdown?dim=os&metric=count&$RANGE")"
field "os by events sums every event" total 112 "$BD"
BD="$(get "/api/breakdown?dim=language&$RANGE")"
field "languages, most-indexed first" labels '["typescript","csharp","go","javascript","python","rust","java"]' "$BD"
field "language counts" datasets.0.data '[7,2,2,2,2,2,1]' "$BD"
field "language rows total" total 18 "$BD"
BD="$(get "/api/breakdown?dim=file_count_bucket&$RANGE")"
field "codebase size keeps bucket order" labels '["<100","100-1k","1k-10k","10k+"]' "$BD"
field "codebase size counts" datasets.0.data '[2,5,4,2]' "$BD"
BD="$(get "/api/breakdown?dim=duration_bucket&$RANGE")"
field "run length keeps bucket order" labels '["<10s","10-60s","1-5m","5m+"]' "$BD"
field "run length counts" datasets.0.data '[5,4,2,2]' "$BD"
field "run length total = index runs" total 13 "$BD"
BD="$(get "/api/breakdown?dim=target&$RANGE")"
field "agent targets are the installs" event install "$BD"
field "agent target labels" labels '["claude","cursor","codex","opencode"]' "$BD"
field "agent target counts" datasets.0.data '[9,3,2,1]' "$BD"
BD="$(get "/api/breakdown?dim=codegraph_version&$RANGE")"
field "versions sort newest first" labels '["1.5.0","1.4.1","1.4.0"]' "$BD"
field "version machine-days" datasets.0.data '[8,3,10]' "$BD"
BD="$(get "/api/breakdown?dim=name&$RANGE")"
field "tool names by call volume" labels '["codegraph_explore","index"]' "$BD"
field "tool call counts" datasets.0.data '[82,3]' "$BD"
BD="$(get "/api/breakdown?dim=client_name&$RANGE")"
field "agents by call volume" labels '["Claude Code","Cursor"]' "$BD"
field "agent call counts" datasets.0.data '[70,12]' "$BD"
BD="$(get "/api/breakdown?dim=kind&$RANGE")"
field "install kinds" labels '["fresh","upgrade"]' "$BD"
field "install kind counts" datasets.0.data '[11,1]' "$BD"
BD="$(get "/api/breakdown?dim=scope&$RANGE")"
field "install scopes" datasets.0.data '[9,3]' "$BD"
BD="$(get "/api/breakdown?dim=name_error&$RANGE")"
field "errors by tool" datasets.0.data '[1]' "$BD"
BD="$(get "/api/breakdown?dim=language&limit=2&$RANGE")"
field "the tail folds into Other, never truncates" labels '["typescript","csharp","Other"]' "$BD"
field "Other keeps the total honest" total 18 "$BD"
field "truncation is declared" truncated true "$BD"
echo
echo "/api/activation — install → first index within 7 days"
ACT="$(get "/api/activation?$RANGE")"
field "cohort is every machine first seen" installs 12 "$ACT"
field "m04 and m06 never indexed" activated 10 "$ACT"
field "…so two dropped" dropped 2 "$ACT"
field "window" window_days 7 "$ACT"
field "daily rate, null where no cohort" datasets.0.data '[75,50,100,null,100,null,null,100,100,null]' "$ACT"
field "recent cohorts flagged incomplete" incomplete_from 2026-07-04 "$ACT"
field "…and the completed ones are not" rows.2.complete true "$ACT"
field "…while the last week is" rows.8.complete false "$ACT"
# Narrowing the window drops m03 alone: it installed on 07-01 and did not index
# until 07-03. Everyone else who ever indexed did it on day 0 or day 1.
ACT="$(get "/api/activation?window=1&$RANGE")"
field "a 1-day window converts fewer" activated 9 "$ACT"
echo
echo "/api/retention — day 014, denominator per day"
RET="$(get "/api/retention?$RANGE")"
field "cohort size" cohort 12 "$RET"
field "15 points" labels.14 'Day 14' "$RET"
# Day 2 divides by 10, not 12: m11/m12 arrived on 07-09 and cannot have a day-2
# data point yet. Day 10+ is null — nobody in the cohort is old enough at all.
field "retention curve" datasets.0.data \
'[100,41.7,30,11.1,0,22.2,0,0,0,0,null,null,null,null,null]' "$RET"
field "day 2 eligible excludes the newest cohorts" rows.2.eligible 10 "$RET"
field "day 10 has nobody old enough" rows.10.eligible 0 "$RET"
echo
echo "Bad input is rejected, never guessed at"
check "unknown dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=machine_id")"
check "missing dim → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown")"
check "unknown metric → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&metric=secrets")"
check "unknown series → 400" 400 "$(status -b "$JAR" "$BASE/api/timeseries?metric=everything")"
check "limit out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/breakdown?dim=os&limit=0")"
check "impossible date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-02-31&to=2026-07-10")"
check "malformed date → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=yesterday")"
check "backwards range → 400" 400 "$(status -b "$JAR" "$BASE/api/summary?from=2026-07-10&to=2026-07-01")"
check "window out of range → 400" 400 "$(status -b "$JAR" "$BASE/api/activation?window=99")"
check "unknown endpoint → 404" 404 "$(status -b "$JAR" "$BASE/api/everything")"
check "event name is a closed shape → 400" 400 \
"$(status -b "$JAR" "$BASE/api/breakdown?dim=os&event=install%27%20OR%201=1")"
CLAMPED="$(get "/api/breakdown?dim=os&from=2019-01-01&to=$TO")"
field "a decade-wide range clamps to a year" range.days 366 "$CLAMPED"
field "…and says so" range.clamped true "$CLAMPED"
field "…kept against the recent end" range.from 2025-07-10 "$CLAMPED"
echo
echo "An empty range renders as empty, not as an error"
EMPTY="$(get "/api/summary?from=2025-01-01&to=2025-01-07")"
field "no machines" production_users 0 "$EMPTY"
field "no installs" installs 0 "$EMPTY"
EMPTY="$(get "/api/breakdown?dim=os&from=2025-01-01&to=2025-01-07")"
field "no bars" labels '[]' "$EMPTY"
EMPTY="$(get "/api/timeseries?metric=production_users&from=2025-01-01&to=2025-01-03")"
field "still a dense axis" datasets.0.data '[0,0,0]' "$EMPTY"
echo
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env bash
# End-to-end check of the auth gate against a local `wrangler dev`.
#
# Verifies the acceptance criteria for the gate: unauthenticated requests reach
# nothing (pages, API, or static assets), a valid cookie reaches everything, and
# a tampered cookie is rejected. Run it after touching src/auth.ts or the route
# table in src/index.ts.
#
# ./scripts/smoke-auth.sh
set -uo pipefail
cd "$(dirname "$0")/.."
# Deliberately NOT $PORT: that is commonly already set to some other local dev
# server, and the whole suite would then silently test the wrong app.
DASH_PORT="${DASH_PORT:-8788}"
BASE="http://127.0.0.1:${DASH_PORT}"
PASSWORD="$(grep '^ADMIN_PASSWORD=' .dev.vars | cut -d'"' -f2)"
JAR="$(mktemp -t cg-dash-jar)"
LOG="$(mktemp -t cg-dash-log)"
DEV_VARS_BACKUP="$(mktemp -t cg-dash-vars)"
PASS=0
FAIL=0
cleanup() {
[[ -n "${DEV_PID:-}" ]] && kill "$DEV_PID" 2>/dev/null
# The rotation phase rewrites .dev.vars; always put the original back.
[[ -s "$DEV_VARS_BACKUP" ]] && cp "$DEV_VARS_BACKUP" .dev.vars
rm -f "$JAR" "$LOG" "$DEV_VARS_BACKUP"
}
trap cleanup EXIT
# `curl -o /dev/null -w '%{http_code}'` plus the headers we care about.
status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
body() { curl -s "$@"; }
check() { # check <description> <expected> <actual>
if [[ "$2" == "$3" ]]; then
printf ' ok %s\n' "$1"
PASS=$((PASS + 1))
else
printf ' FAIL %s (expected %s, got %s)\n' "$1" "$2" "$3"
FAIL=$((FAIL + 1))
fi
}
contains() { # contains <description> <needle> <haystack>
if [[ "$3" == *"$2"* ]]; then
printf ' ok %s\n' "$1"
PASS=$((PASS + 1))
else
printf ' FAIL %s (missing %q in %.200q…)\n' "$1" "$2" "$3"
FAIL=$((FAIL + 1))
fi
}
lacks() { # lacks <description> <needle> <haystack>
if [[ "$3" != *"$2"* ]]; then
printf ' ok %s\n' "$1"
PASS=$((PASS + 1))
else
printf ' FAIL %s (found %q)\n' "$1" "$2"
FAIL=$((FAIL + 1))
fi
}
echo "Seeding local D1 from the ingest worker's migration…"
npx wrangler d1 execute codegraph-telemetry --local \
--file=../telemetry-worker/migrations/0001_init.sql >/dev/null 2>&1
echo "Starting wrangler dev on :${DASH_PORT}"
npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
DEV_PID=$!
READY=""
for _ in $(seq 1 90); do
if [[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]]; then READY=1; break; fi
sleep 1
done
if [[ -z "$READY" ]]; then
echo "wrangler dev never came up on :${DASH_PORT} — log follows"
cat "$LOG"
exit 1
fi
echo
echo "Unauthenticated — nothing but the login page and robots.txt"
check "GET / → 302 to login" 302 "$(status "$BASE/")"
check "GET /index.html → 302 to login" 302 "$(status "$BASE/index.html")"
check "GET /styles.css → 302 to login" 302 "$(status "$BASE/styles.css")"
check "GET /app.js → 302 to login" 302 "$(status "$BASE/app.js")"
check "GET /vendor/chart → 302 to login" 302 "$(status "$BASE/vendor/chart.umd.js")"
check "GET /api/health → 401" 401 "$(status "$BASE/api/health")"
check "GET /api/session → 401" 401 "$(status "$BASE/api/session")"
check "GET /api/anything → 401" 401 "$(status "$BASE/api/whatever")"
check "GET /login → 200" 200 "$(status "$BASE/login")"
check "GET /robots.txt → 200" 200 "$(status "$BASE/robots.txt")"
contains "no data leaks in the 401 body" '"unauthorized"' "$(body "$BASE/api/health")"
echo
echo "Login page"
LOGIN_HTML="$(body "$BASE/login")"
contains "sentence-case heading" "codegraph telemetry" "$LOGIN_HTML"
contains "sentence-case label" ">Password<" "$LOGIN_HTML"
contains "sentence-case button" ">Sign in<" "$LOGIN_HTML"
lacks "no uppercased labels" "uppercase" "$LOGIN_HTML"
lacks "no tracked-out labels" "letter-spacing" "$LOGIN_HTML"
contains "label is normal size" "font-size: 16px" "$LOGIN_HTML"
check "open redirect refused" "/" \
"$(body "$BASE/login?next=%2F%2Fevil.example" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
check "same-origin next kept" "/api/health" \
"$(body "$BASE/login?next=%2Fapi%2Fhealth" | sed -n 's/.*name="next" value="\([^"]*\)".*/\1/p')"
echo
echo "Sign-in"
check "wrong password → 401" 401 \
"$(status -X POST "$BASE/login" -d "password=definitely-not-it" -d "next=/")"
check "wrong password sets no cookie" "" \
"$(curl -s -D - -o /dev/null -X POST "$BASE/login" -d "password=nope" | grep -ci 'set-cookie' | sed 's/^0$//')"
check "empty password → 400" 400 "$(status -X POST "$BASE/login" -d "password=")"
check "cross-origin post → 400" 400 \
"$(status -X POST "$BASE/login" -H 'Origin: https://evil.example' -d "password=${PASSWORD}")"
# One sign-in, then every cookie assertion reads the captured headers. Doing a
# fresh POST per assertion would burn the login rate limit and 429 halfway down.
SIGNIN="$(curl -s -D - -o /dev/null -c "$JAR" -X POST "$BASE/login" -d "password=${PASSWORD}" -d "next=/")"
check "correct password → 302" "302" "$(printf '%s' "$SIGNIN" | head -1 | awk '{print $2}')"
contains "cookie is HttpOnly" "HttpOnly" "$SIGNIN"
contains "cookie is Secure" "Secure" "$SIGNIN"
contains "cookie is SameSite=Lax" "SameSite=Lax" "$SIGNIN"
contains "cookie is ~1 year" "Max-Age=31536000" "$SIGNIN"
contains "cookie is site-wide" "Path=/" "$SIGNIN"
COOKIE="$(grep cg_admin_session "$JAR" | awk '{print $NF}')"
PAYLOAD="${COOKIE%%.*}"
SIG="${COOKIE#*.}"
# A persistent cookie carries a real expiry in the jar; a session cookie (gone
# on browser restart) carries 0. This is the "survives a restart" criterion.
JAR_EXPIRY="$(grep cg_admin_session "$JAR" | awk '{print $5}')"
if [[ "$JAR_EXPIRY" -gt "$(( $(date +%s) + 300 * 86400 ))" ]]; then
check "cookie persists across browser restarts" "persistent" "persistent"
else
check "cookie persists across browser restarts" "persistent" "session-only (expiry ${JAR_EXPIRY})"
fi
echo
echo "Authenticated — the whole app"
check "GET / → 200" 200 "$(status -b "$JAR" "$BASE/")"
check "GET /styles.css → 200" 200 "$(status -b "$JAR" "$BASE/styles.css")"
check "GET /app.js → 200" 200 "$(status -b "$JAR" "$BASE/app.js")"
check "GET /vendor/chart→ 200" 200 "$(status -b "$JAR" "$BASE/vendor/chart.umd.js")"
check "GET /api/session → 200" 200 "$(status -b "$JAR" "$BASE/api/session")"
check "GET /api/health → 200" 200 "$(status -b "$JAR" "$BASE/api/health")"
contains "health reads D1" '"ok":true' "$(body -b "$JAR" "$BASE/api/health")"
check "GET /login while signed in → 302" 302 "$(status -b "$JAR" "$BASE/login")"
check "unknown API route → 404" 404 "$(status -b "$JAR" "$BASE/api/nope")"
check "POST to an API route → 405" 405 "$(status -b "$JAR" -X POST "$BASE/api/health")"
echo
echo "Tampering"
# Mutate the FIRST signature character, not the last: base64url's final
# character of a 32-byte tag carries only 4 significant bits, so flipping it is
# sometimes a no-op on the decoded bytes and the test would pass vacuously.
FLIPPED="${PAYLOAD}.$([[ "${SIG:0:1}" == 'A' ]] && echo B || echo A)${SIG:1}"
check "flipped signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/api/health")"
check "truncated signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}.${SIG:0:40}" "$BASE/api/health")"
check "swapped payload → 401" 401 \
"$(status -H "Cookie: cg_admin_session=$(printf '%s' '{"v":1,"iat":0,"exp":9999999999,"pw":"x"}' | base64 | tr -d '=' | tr '+/' '-_').${SIG}" "$BASE/api/health")"
check "no signature → 401" 401 "$(status -H "Cookie: cg_admin_session=${PAYLOAD}" "$BASE/api/health")"
check "garbage cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=not-a-token' "$BASE/api/health")"
check "empty cookie → 401" 401 "$(status -H 'Cookie: cg_admin_session=' "$BASE/api/health")"
check "tampered cookie on a page → 302 to login" 302 \
"$(status -H "Cookie: cg_admin_session=${FLIPPED}" "$BASE/")"
echo
echo "Sign-out"
check "POST /logout → 302" 302 "$(status -X POST "$BASE/logout")"
contains "logout clears the cookie" "Max-Age=0" \
"$(curl -s -D - -o /dev/null -X POST "$BASE/logout")"
check "GET /logout → 405" 405 "$(status "$BASE/logout")"
echo
echo "Rate limiting (6 attempts in a minute; the 6th should be capped)"
LAST=""
for _ in 1 2 3 4 5 6 7; do
LAST="$(status -X POST "$BASE/login" -d 'password=guess')"
done
check "brute force capped → 429" 429 "$LAST"
echo
echo "Password rotation (restarting with a different ADMIN_PASSWORD)"
cp .dev.vars "$DEV_VARS_BACKUP"
sed 's/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD="rotated-password"/' "$DEV_VARS_BACKUP" >.dev.vars
kill "$DEV_PID" 2>/dev/null
wait "$DEV_PID" 2>/dev/null
npx wrangler dev --port "$DASH_PORT" --ip 127.0.0.1 >"$LOG" 2>&1 &
DEV_PID=$!
for _ in $(seq 1 90); do
[[ "$(body "$BASE/robots.txt")" == "User-agent: *"* ]] && break
sleep 1
done
check "cookie from the old password → 401" 401 \
"$(status -H "Cookie: cg_admin_session=${COOKIE}" "$BASE/api/health")"
check "old password no longer signs in → 401" 401 \
"$(status -X POST "$BASE/login" -d "password=${PASSWORD}")"
check "new password signs in → 302" 302 \
"$(status -X POST "$BASE/login" -d "password=rotated-password")"
echo
printf '%s\n' "-----"
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
[[ "$FAIL" -eq 0 ]]
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Copies third-party browser libraries out of node_modules into public/vendor/.
*
* Workers Static Assets are served verbatim nothing in public/ goes through a
* bundler so a library from npm has to be physically present there. Keeping
* it a copy step (rather than a checked-in blob or a CDN <script>) means the
* version is pinned by package.json, there is no third-party origin at runtime,
* and the CSP can stay `script-src 'self'`.
*
* public/vendor/ is gitignored; `npm run dev` and `npm run deploy` both run this
* first, so it is always present and always matches the lockfile.
*/
import { copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const vendorDir = join(root, 'public', 'vendor');
const FILES = [
['node_modules/chart.js/dist/chart.umd.js', 'chart.umd.js'],
['node_modules/chart.js/LICENSE.md', 'chart.js-LICENSE.md'],
];
mkdirSync(vendorDir, { recursive: true });
for (const [from, to] of FILES) {
const source = join(root, from);
if (!existsSync(source)) {
console.error(`vendor-assets: missing ${from} — run \`npm install\` first`);
process.exit(1);
}
copyFileSync(source, join(vendorDir, to));
}
console.log(`vendor-assets: copied ${FILES.length} file(s) into public/vendor/`);
+827
View File
@@ -0,0 +1,827 @@
/**
* The dashboard's read API: one JSON endpoint per chart shape, all of them
* scoped by the same `?from=&to=` range the picker drives.
*
* Rules this file keeps:
* - **Rollups first.** Every panel is answered from `daily_*` / `machine_days`,
* which are kept forever. Only the activation funnel touches raw `events`,
* because "did this machine ever run an index" is not a daily aggregate and
* that is also the only endpoint with a horizon (the retention window).
* - **Parameterized, always.** No value from the query string is ever
* concatenated into SQL. Dimensions and metrics are looked up in the tables
* below and rejected with a 400 if they are not there, so even the column
* *names* a caller can reach are a closed set.
* - **Chart-shaped.** Responses come back as `labels[] + datasets[]` so the
* frontend does no arithmetic; every response also carries `rows` in its
* natural shape, which is what the per-panel table view renders.
* - **No Response objects.** Handlers return plain data and let src/index.ts
* apply the security headers, so there is exactly one place where headers on
* an authenticated response are decided.
*/
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 86_400_000;
/** A year of daily points is already more than the charts can draw legibly. */
const MAX_RANGE_DAYS = 366;
const DEFAULT_RANGE_DAYS = 30;
/**
* Retention curve length. Two weeks covers the day-1 and day-7 cliffs where nearly
* all churn happens, and matches the window the previous analytics dashboard drew,
* so the numbers stay comparable across the cutover.
*/
const RETENTION_DAYS = 14;
/** Days a machine gets to run its first index before it counts as churned. */
const DEFAULT_ACTIVATION_WINDOW = 7;
const MAX_ACTIVATION_WINDOW = 30;
/** Bars past this fold into "Other" — see the note on `Other` in breakdown(). */
const DEFAULT_BREAKDOWN_LIMIT = 12;
const MAX_BREAKDOWN_LIMIT = 50;
/** Panels read at most every few minutes; the underlying data moves once a night. */
const CACHE_CONTROL = 'private, max-age=300';
export interface ApiResult {
body: unknown;
status?: number;
/** Omitted ⇒ src/index.ts keeps its `no-store` default. */
cacheControl?: string;
}
const fail = (error: string, status = 400): ApiResult => ({ body: { error }, status });
/**
* `noUncheckedIndexedAccess` types every slot of a `batch()` result as possibly
* undefined. These two keep that out of the query code, which reads better for
* being about rows rather than about array bounds.
*/
const rowsOf = <T>(result: D1Result | undefined): T[] => (result?.results ?? []) as unknown as T[];
const firstOf = <T>(result: D1Result | undefined): T | undefined => rowsOf<T>(result)[0];
// ---------------------------------------------------------------------------
// Days
// ---------------------------------------------------------------------------
const utcDay = (atMs: number): string => new Date(atMs).toISOString().slice(0, 10);
/** Rejects the wrong shape and impossible dates alike (`2026-02-31` round-trips as March). */
function isValidDay(day: string): boolean {
if (!DAY_RE.test(day)) return false;
const t = Date.parse(`${day}T00:00:00Z`);
return Number.isFinite(t) && utcDay(t) === day;
}
const dayMs = (day: string): number => Date.parse(`${day}T00:00:00Z`);
const addDays = (day: string, delta: number): string => utcDay(dayMs(day) + delta * DAY_MS);
const daysApart = (from: string, to: string): number => Math.round((dayMs(to) - dayMs(from)) / DAY_MS);
export interface Range {
from: string;
to: string;
/** Inclusive length. */
days: number;
/** The request asked for more than MAX_RANGE_DAYS and `from` was moved up. */
clamped: boolean;
}
/**
* The range every endpoint shares. Absent params default to the last 30 days
* ending today so a bare `curl /api/summary` still answers something sensible;
* the dashboard itself always sends both, anchored on /api/meta's latest day so
* no chart ends on a day the nightly rollup has not written yet.
*/
function parseRange(url: URL): Range | ApiResult {
const rawTo = url.searchParams.get('to');
const rawFrom = url.searchParams.get('from');
if (rawTo !== null && !isValidDay(rawTo)) return fail('to must be YYYY-MM-DD');
if (rawFrom !== null && !isValidDay(rawFrom)) return fail('from must be YYYY-MM-DD');
const to = rawTo ?? utcDay(Date.now());
const from = rawFrom ?? addDays(to, -(DEFAULT_RANGE_DAYS - 1));
if (from > to) return fail('from must not be after to');
const requested = daysApart(from, to) + 1;
const clamped = requested > MAX_RANGE_DAYS;
return {
from: clamped ? addDays(to, -(MAX_RANGE_DAYS - 1)) : from,
to,
days: clamped ? MAX_RANGE_DAYS : requested,
clamped,
};
}
const isApiResult = (v: Range | ApiResult): v is ApiResult => 'body' in v;
/** Every day in the range, so a chart's x-axis has no holes where nothing happened. */
function dayList(range: Range): string[] {
const days: string[] = [];
for (let i = 0; i < range.days; i++) days.push(addDays(range.from, i));
return days;
}
/** Turns day-keyed rows into a dense series aligned to `labels`. */
function densify(labels: string[], byDay: Map<string, number>): number[] {
return labels.map((day) => byDay.get(day) ?? 0);
}
// ---------------------------------------------------------------------------
// Dimensions
// ---------------------------------------------------------------------------
type Order = 'value_desc' | 'bucket' | 'version_desc';
interface DimSpec {
/** Axis/legend label for the values of this dimension. */
label: string;
/** Which number the chart plots when the caller does not say. */
metric: 'machines' | 'count';
/**
* Restrict to one event type. Set wherever the same dim is emitted by more
* than one event and the panel means a specific one `target` rides both
* install and uninstall, and "AI agent targets" means the installs.
*/
event?: string;
order: Order;
/** Fixed display order for bucket dims, whose meaning IS their order. */
buckets?: readonly string[];
}
const FILE_COUNT_BUCKETS = ['<100', '100-1k', '1k-10k', '10k+'] as const;
const DURATION_BUCKETS = ['<10s', '10-60s', '1-5m', '5m+'] as const;
/**
* The closed set of breakdowns. A dim not in here is a 400, which is what keeps
* `?dim=` from being a way to ask the database questions of the caller's own design.
* Values mirror the cron's dimension list (telemetry-worker/src/rollup.ts).
*/
const DIMS: Record<string, DimSpec> = {
os: { label: 'Operating system', metric: 'machines', order: 'value_desc' },
arch: { label: 'Architecture', metric: 'machines', order: 'value_desc' },
codegraph_version: { label: 'Version', metric: 'machines', order: 'version_desc' },
node_major: { label: 'Node major', metric: 'machines', order: 'version_desc' },
language: { label: 'Language', metric: 'count', order: 'value_desc' },
file_count_bucket: {
label: 'Files in project',
metric: 'count',
order: 'bucket',
buckets: FILE_COUNT_BUCKETS,
},
duration_bucket: {
label: 'Indexing run length',
metric: 'count',
order: 'bucket',
buckets: DURATION_BUCKETS,
},
target: { label: 'Agent target', metric: 'count', event: 'install', order: 'value_desc' },
scope: { label: 'Install scope', metric: 'count', event: 'install', order: 'value_desc' },
kind: { label: 'Install kind', metric: 'count', event: 'install', order: 'value_desc' },
name: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
client_name: { label: 'Agent', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
name_error: { label: 'Tool or command', metric: 'count', event: 'usage_rollup', order: 'value_desc' },
};
/** Newest first, numerically per segment, so 1.10.0 sorts above 1.9.0. */
function compareVersionsDesc(a: string, b: string): number {
const pa = a.split(/[.-]/);
const pb = b.split(/[.-]/);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = Number(pa[i]);
const nb = Number(pb[i]);
if (Number.isFinite(na) && Number.isFinite(nb)) {
if (na !== nb) return nb - na;
} else {
const sa = pa[i] ?? '';
const sb = pb[i] ?? '';
if (sa !== sb) return sb.localeCompare(sa);
}
}
return 0;
}
// ---------------------------------------------------------------------------
// /api/meta
// ---------------------------------------------------------------------------
interface MetaRow {
latest_rollup_day: string | null;
earliest_rollup_day: string | null;
latest_active_day: string | null;
earliest_active_day: string | null;
earliest_raw_day: string | null;
latest_raw_day: string | null;
}
/**
* What the picker anchors on. The dashboard asks for this first and ends every
* default range on `latest_day`, because the nightly cron has not rolled up
* today yet anchoring on the wall clock would put a phantom zero on the right
* edge of every line chart.
*/
async function meta(env: Env): Promise<ApiResult> {
const row = await env.DB.prepare(
`SELECT (SELECT max(day) FROM daily_event_counts) AS latest_rollup_day,
(SELECT min(day) FROM daily_event_counts) AS earliest_rollup_day,
(SELECT max(day) FROM machine_days) AS latest_active_day,
(SELECT min(day) FROM machine_days) AS earliest_active_day,
(SELECT min(day) FROM events) AS earliest_raw_day,
(SELECT max(day) FROM events) AS latest_raw_day`,
).first<MetaRow>();
const latest = row?.latest_rollup_day ?? row?.latest_active_day ?? null;
const earliest = row?.earliest_rollup_day ?? row?.earliest_active_day ?? null;
return {
body: {
latest_day: latest,
earliest_day: earliest,
latest_rollup_day: row?.latest_rollup_day ?? null,
latest_active_day: row?.latest_active_day ?? null,
/** Below this day the activation funnel is blind — raw events are purged. */
earliest_raw_day: row?.earliest_raw_day ?? null,
latest_raw_day: row?.latest_raw_day ?? null,
max_range_days: MAX_RANGE_DAYS,
retention_days: RETENTION_DAYS,
generated_at: new Date().toISOString(),
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/summary — the big numbers
// ---------------------------------------------------------------------------
/**
* One D1 batch, one round trip. Distinct-machine numbers come from
* `machine_days` rather than by summing `daily_machines`: a machine active on
* five days is one user, and summing the daily counts would call it five.
*/
async function summary(env: Env, range: Range): Promise<ApiResult> {
const { from, to } = range;
const batch = await env.DB.batch([
env.DB.prepare(
`SELECT count(DISTINCT machine_id) AS n FROM machine_days
WHERE day BETWEEN ? AND ? AND prod = 1`,
).bind(from, to),
env.DB.prepare(
`SELECT count(DISTINCT machine_id) AS n FROM machine_days WHERE day BETWEEN ? AND ?`,
).bind(from, to),
env.DB.prepare(
`SELECT count(*) AS n FROM machine_first_seen WHERE first_day BETWEEN ? AND ?`,
).bind(from, to),
env.DB.prepare(
`SELECT event, sum(count) AS events, sum(machines) AS machines
FROM daily_event_counts WHERE day BETWEEN ? AND ? GROUP BY event`,
).bind(from, to),
]);
const byEvent = new Map<string, number>();
for (const row of rowsOf<{ event: string; events: number }>(batch[3])) {
byEvent.set(row.event, row.events ?? 0);
}
const eventCount = (name: string): number => byEvent.get(name) ?? 0;
return {
body: {
range,
production_users: firstOf<{ n: number }>(batch[0])?.n ?? 0,
active_machines: firstOf<{ n: number }>(batch[1])?.n ?? 0,
new_machines: firstOf<{ n: number }>(batch[2])?.n ?? 0,
installs: eventCount('install'),
uninstalls: eventCount('uninstall'),
index_runs: eventCount('index'),
tool_calls: eventCount('usage_rollup'),
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/timeseries — the line charts
// ---------------------------------------------------------------------------
interface DayValueRow {
day: string;
a: number | null;
b: number | null;
}
interface SeriesSpec {
title: string;
/** Series labels, in the order their data lands in `datasets`. */
labels: [string] | [string, string];
sql: string;
binds: (range: Range) => (string | number)[];
}
/**
* Every metric here reads a rollup table, so a line stays correct for days whose
* raw events are long gone. Each query returns (day, a[, b]) and is densified
* against the full day list, because a day with no rows means zero, not a gap.
*/
const SERIES: Record<string, SeriesSpec> = {
installs_uninstalls: {
title: 'Installs and uninstalls',
labels: ['Installs', 'Uninstalls'],
sql: `SELECT day,
sum(CASE WHEN event = 'install' THEN count ELSE 0 END) AS a,
sum(CASE WHEN event = 'uninstall' THEN count ELSE 0 END) AS b
FROM daily_event_counts
WHERE day BETWEEN ? AND ? AND event IN ('install', 'uninstall')
GROUP BY day`,
binds: (r) => [r.from, r.to],
},
new_installs: {
title: 'New installs',
labels: ['New machines'],
sql: `SELECT first_day AS day, count(*) AS a, NULL AS b
FROM machine_first_seen
WHERE first_day BETWEEN ? AND ?
GROUP BY first_day`,
binds: (r) => [r.from, r.to],
},
production_users: {
title: 'Daily production users',
labels: ['Production users'],
sql: `SELECT day, prod_machines AS a, NULL AS b
FROM daily_machines WHERE day BETWEEN ? AND ?`,
binds: (r) => [r.from, r.to],
},
indexing_activity: {
title: 'Daily indexing activity',
labels: ['Indexing runs', 'Machines indexing'],
sql: `SELECT day, count AS a, machines AS b
FROM daily_event_counts
WHERE day BETWEEN ? AND ? AND event = 'index'`,
binds: (r) => [r.from, r.to],
},
tool_calls: {
title: 'Daily tool and command calls',
labels: ['Calls', 'Machines'],
sql: `SELECT day, count AS a, machines AS b
FROM daily_event_counts
WHERE day BETWEEN ? AND ? AND event = 'usage_rollup'`,
binds: (r) => [r.from, r.to],
},
};
async function timeseries(env: Env, url: URL, range: Range): Promise<ApiResult> {
const metric = url.searchParams.get('metric') ?? 'installs_uninstalls';
// The one metric whose series are data-driven rather than fixed: one line per
// duration bucket, in bucket order (an ordered scale, so the order is meaning).
if (metric === 'duration_buckets') return durationBucketSeries(env, range);
const spec = SERIES[metric];
if (!spec) {
return fail(`unknown metric — one of: ${[...Object.keys(SERIES), 'duration_buckets'].join(', ')}`);
}
const { results } = await env.DB.prepare(spec.sql)
.bind(...spec.binds(range))
.all<DayValueRow>();
const labels = dayList(range);
const a = new Map<string, number>();
const b = new Map<string, number>();
for (const row of results) {
a.set(row.day, row.a ?? 0);
b.set(row.day, row.b ?? 0);
}
const datasets = [{ label: spec.labels[0], data: densify(labels, a) }];
if (spec.labels.length === 2) datasets.push({ label: spec.labels[1], data: densify(labels, b) });
return {
body: {
range,
metric,
title: spec.title,
labels,
datasets,
rows: labels.map((day, i) => ({
day,
...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
})),
},
cacheControl: CACHE_CONTROL,
};
}
/** "Session run length over time": one series per duration bucket, bucket-ordered. */
async function durationBucketSeries(env: Env, range: Range): Promise<ApiResult> {
const { results } = await env.DB.prepare(
`SELECT day, value, sum(count) AS n
FROM daily_dim_counts
WHERE dim = 'duration_bucket' AND event = 'index' AND day BETWEEN ? AND ?
GROUP BY day, value`,
)
.bind(range.from, range.to)
.all<{ day: string; value: string; n: number }>();
const labels = dayList(range);
const perBucket = new Map<string, Map<string, number>>();
for (const row of results) {
let series = perBucket.get(row.value);
if (!series) perBucket.set(row.value, (series = new Map()));
series.set(row.day, row.n ?? 0);
}
// Fixed buckets first and always present (a bucket with no runs is a real zero,
// and dropping it would silently renumber the ordinal colour ramp); anything
// unexpected from an older client is appended rather than hidden.
const extra = [...perBucket.keys()].filter((v) => !DURATION_BUCKETS.includes(v as never)).sort();
const order = [...DURATION_BUCKETS, ...extra];
const datasets = order.map((bucket) => ({
label: bucket,
data: densify(labels, perBucket.get(bucket) ?? new Map()),
}));
return {
body: {
range,
metric: 'duration_buckets',
title: 'Indexing run length over time',
labels,
datasets,
rows: labels.map((day, i) => ({
day,
...Object.fromEntries(datasets.map((d) => [d.label, d.data[i] ?? 0])),
})),
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/breakdown — the bars and pies
// ---------------------------------------------------------------------------
interface BreakdownRow {
value: string;
count: number;
machines: number;
}
/**
* Sums one dimension over the range.
*
* On the `machines` metric: `daily_dim_counts.machines` is per day, so summing
* it over a range gives **machine-days**, not distinct machines a machine
* seen on ten days counts ten times. A range-wide distinct count per dimension
* value is not recoverable from the rollups at all (it would need the raw
* events, which are purged), so rather than quietly presenting one as the
* other, the number is honestly named machine-days everywhere it appears, and
* the panels that use it are share-of-total panels where the distinction does
* not move the shape.
*
* The inner `max(machines)` is the other half of that honesty: the same machine
* emits install *and* index *and* usage_rollup on one day, each carrying `os`,
* so summing `machines` across event types would triple-count it. Taking the
* largest single-event count for the day is the closest lower bound the rollups
* can give. When a dim belongs to exactly one event (or `?event=` pins it) the
* `max` is over a single row and the question does not arise.
*/
async function breakdown(env: Env, url: URL, range: Range): Promise<ApiResult> {
const dim = url.searchParams.get('dim') ?? '';
const spec = DIMS[dim];
if (!spec) return fail(`unknown dim — one of: ${Object.keys(DIMS).join(', ')}`);
const requestedMetric = url.searchParams.get('metric');
if (requestedMetric !== null && requestedMetric !== 'count' && requestedMetric !== 'machines') {
return fail('metric must be count or machines');
}
const metric = requestedMetric ?? spec.metric;
const rawLimit = url.searchParams.get('limit');
const limit = rawLimit === null ? DEFAULT_BREAKDOWN_LIMIT : Number(rawLimit);
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_BREAKDOWN_LIMIT) {
return fail(`limit must be an integer between 1 and ${MAX_BREAKDOWN_LIMIT}`);
}
const event = url.searchParams.get('event') ?? spec.event ?? null;
if (event !== null && !/^[a-z_]{1,32}$/.test(event)) return fail('event must be a bare event name');
const binds: (string | number)[] = [dim, range.from, range.to];
if (event !== null) binds.push(event);
const { results } = await env.DB.prepare(
`SELECT value, sum(day_count) AS count, sum(day_machines) AS machines
FROM (SELECT day, value, sum(count) AS day_count, max(machines) AS day_machines
FROM daily_dim_counts
WHERE dim = ? AND day BETWEEN ? AND ?${event !== null ? ' AND event = ?' : ''}
GROUP BY day, value)
GROUP BY value`,
)
.bind(...binds)
.all<BreakdownRow>();
const rows = results.map((r) => ({
value: r.value,
count: r.count ?? 0,
machines: r.machines ?? 0,
}));
const pick = (r: BreakdownRow): number => (metric === 'machines' ? r.machines : r.count);
let ordered: BreakdownRow[];
let truncated = false;
if (spec.order === 'bucket' && spec.buckets) {
// An ordered scale: the buckets keep their own order and all of them show,
// including empty ones, so the ordinal colour ramp always means the same thing.
const found = new Map(rows.map((r) => [r.value, r]));
const extra = rows.filter((r) => !spec.buckets?.includes(r.value)).sort((x, y) => pick(y) - pick(x));
ordered = [
...spec.buckets.map((b) => found.get(b) ?? { value: b, count: 0, machines: 0 }),
...extra,
];
} else {
const sorted = [...rows].sort(
spec.order === 'version_desc'
? (x, y) => compareVersionsDesc(x.value, y.value)
: (x, y) => pick(y) - pick(x) || x.value.localeCompare(y.value),
);
if (sorted.length > limit) {
// Fold rather than truncate: a chopped bar chart quietly changes what the
// total means, and "Other" keeps the panel's total honest.
const head = sorted.slice(0, limit);
const tail = sorted.slice(limit);
ordered = [
...head,
{
value: 'Other',
count: tail.reduce((n, r) => n + r.count, 0),
machines: tail.reduce((n, r) => n + r.machines, 0),
},
];
truncated = true;
} else {
ordered = sorted;
}
}
const data = ordered.map(pick);
return {
body: {
range,
dim,
event,
metric,
title: spec.label,
labels: ordered.map((r) => r.value),
datasets: [{ label: metric === 'machines' ? 'Machine-days' : 'Events', data }],
rows: ordered,
total: data.reduce((n, v) => n + v, 0),
/** True when the tail was folded into an "Other" bar. */
truncated,
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/activation — install → first index
// ---------------------------------------------------------------------------
interface ActivationRow {
day: string;
installs: number;
activated: number;
}
/**
* Of the machines whose FIRST day falls in the range, how many ran an index
* within `window` days of it.
*
* The cohort key is `machine_first_seen`, not install events: a machine that
* reinstalls does not re-enter the funnel, which is what makes this a
* conversion rate rather than an install-event ratio.
*
* The LEFT JOIN rides events_machine_day (machine_id, day) and `count(DISTINCT)`
* absorbs the fan-out from a machine that indexed many times. This is the one
* endpoint that reads raw `events`, so it is bounded by the retention window
* `raw_events_from` tells the caller where the data actually starts, and the UI
* says so rather than drawing a cliff and calling it a drop in conversion.
*/
async function activation(env: Env, url: URL, range: Range): Promise<ApiResult> {
const rawWindow = url.searchParams.get('window');
const window = rawWindow === null ? DEFAULT_ACTIVATION_WINDOW : Number(rawWindow);
if (!Number.isInteger(window) || window < 1 || window > MAX_ACTIVATION_WINDOW) {
return fail(`window must be an integer between 1 and ${MAX_ACTIVATION_WINDOW}`);
}
const batch = await env.DB.batch([
env.DB.prepare(
`SELECT f.first_day AS day,
count(DISTINCT f.machine_id) AS installs,
count(DISTINCT CASE WHEN e.machine_id IS NOT NULL THEN f.machine_id END) AS activated
FROM machine_first_seen f
LEFT JOIN events e
ON e.machine_id = f.machine_id
AND e.event = 'index'
AND e.day >= f.first_day
AND e.day <= date(f.first_day, ?)
WHERE f.first_day BETWEEN ? AND ?
GROUP BY f.first_day`,
// A bound modifier string, built from an integer this function validated —
// date() takes the modifier as data, so nothing is concatenated into SQL.
).bind(`+${window} days`, range.from, range.to),
env.DB.prepare(`SELECT min(day) AS raw_from, max(day) AS raw_to FROM events`),
]);
const rows = rowsOf<ActivationRow>(batch[0]);
const byDay = new Map(rows.map((r) => [r.day, r]));
const labels = dayList(range);
const installs = rows.reduce((n, r) => n + (r.installs ?? 0), 0);
const activated = rows.reduce((n, r) => n + (r.activated ?? 0), 0);
// Cohorts younger than the window have not finished converting yet, so their
// rate is a floor, not a result. Marked rather than dropped: hiding the last
// week of a conversion chart is its own kind of lie.
const boundsRow = firstOf<{ raw_from: string | null; raw_to: string | null }>(batch[1]);
const latestRaw = boundsRow?.raw_to ?? utcDay(Date.now());
const incompleteFrom = addDays(latestRaw, -(window - 1));
const detail = labels.map((day) => {
const row = byDay.get(day);
const dayInstalls = row?.installs ?? 0;
const dayActivated = row?.activated ?? 0;
return {
day,
installs: dayInstalls,
activated: dayActivated,
rate: dayInstalls > 0 ? dayActivated / dayInstalls : null,
complete: day < incompleteFrom,
};
});
return {
body: {
range,
window_days: window,
installs,
activated,
dropped: installs - activated,
rate: installs > 0 ? activated / installs : null,
/** Cohorts from this day on have not had the full window to convert. */
incomplete_from: incompleteFrom,
/** Raw events start here; a range reaching further back under-counts. */
raw_events_from: boundsRow?.raw_from ?? null,
labels,
datasets: [
{
label: 'Activation rate',
data: detail.map((d) => (d.rate === null ? null : Math.round(d.rate * 1000) / 10)),
},
],
rows: detail,
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/retention — day 014 cohort curve
// ---------------------------------------------------------------------------
/**
* For machines first seen in the range, the share still active k days later.
*
* Read entirely off `machine_days` + `machine_first_seen`, neither of which the
* retention purge touches, so this answers for any range in history.
*
* The denominator is per-k, not the whole cohort: a machine first seen
* yesterday cannot have a day-7 data point, and dividing by it anyway would
* bend every recent cohort's curve toward zero. So day k is measured only over
* the machines that have actually had k days to come back `eligible[k]`. The
* numerator needs no matching filter, since a machine with fewer than k days
* elapsed contributes zero to day k by construction.
*/
async function retention(env: Env, range: Range): Promise<ApiResult> {
const batch = await env.DB.batch([
env.DB.prepare(
`SELECT CAST(julianday(d.day) - julianday(f.first_day) AS INTEGER) AS k,
count(DISTINCT d.machine_id) AS machines
FROM machine_first_seen f
JOIN machine_days d ON d.machine_id = f.machine_id
WHERE f.first_day BETWEEN ? AND ?
AND d.day >= f.first_day
AND d.day <= date(f.first_day, ?)
GROUP BY k`,
).bind(range.from, range.to, `+${RETENTION_DAYS} days`),
env.DB.prepare(
`SELECT first_day AS day, count(*) AS machines
FROM machine_first_seen WHERE first_day BETWEEN ? AND ? GROUP BY first_day`,
).bind(range.from, range.to),
env.DB.prepare(`SELECT max(day) AS day FROM machine_days`),
]);
const retained = new Map(
rowsOf<{ k: number; machines: number }>(batch[0]).map((r) => [r.k, r.machines ?? 0]),
);
const cohortDays = rowsOf<{ day: string; machines: number }>(batch[1]);
const cohortSize = cohortDays.reduce((n, r) => n + (r.machines ?? 0), 0);
const latestDay = firstOf<{ day: string | null }>(batch[2])?.day ?? utcDay(Date.now());
const rows = [];
for (let k = 0; k <= RETENTION_DAYS; k++) {
// Machines whose first day is early enough that day k has already happened.
const cutoff = addDays(latestDay, -k);
const eligible = cohortDays.reduce((n, r) => (r.day <= cutoff ? n + (r.machines ?? 0) : n), 0);
const back = retained.get(k) ?? 0;
rows.push({
day: k,
eligible,
retained: back,
rate: eligible > 0 ? back / eligible : null,
});
}
return {
body: {
range,
cohort: cohortSize,
window_days: RETENTION_DAYS,
labels: rows.map((r) => `Day ${r.day}`),
datasets: [
{
label: 'Retained',
data: rows.map((r) => (r.rate === null ? null : Math.round(r.rate * 1000) / 10)),
},
],
rows,
},
cacheControl: CACHE_CONTROL,
};
}
// ---------------------------------------------------------------------------
// /api/health — liveness, and the only endpoint that is not range-scoped
// ---------------------------------------------------------------------------
async function health(env: Env): Promise<ApiResult> {
try {
const batch = await env.DB.batch<{ day: string | null }>([
env.DB.prepare('SELECT max(day) AS day FROM events'),
env.DB.prepare('SELECT max(day) AS day FROM daily_machines'),
]);
return {
body: {
ok: true,
database: {
latest_event_day: batch[0]?.results[0]?.day ?? null,
latest_rollup_day: batch[1]?.results[0]?.day ?? null,
},
},
};
} catch (err) {
console.error(JSON.stringify({ msg: 'health query failed', err: String(err) }));
return { body: { ok: false, error: 'database unavailable' }, status: 503 };
}
}
// ---------------------------------------------------------------------------
// Router
// ---------------------------------------------------------------------------
/**
* Called only for an authenticated GET src/index.ts owns the session gate and
* turns what comes back into a Response.
*/
export async function handleApi(env: Env, url: URL): Promise<ApiResult> {
if (url.pathname === '/api/session') return { body: { authenticated: true } };
if (url.pathname === '/api/health') return health(env);
if (url.pathname === '/api/meta') return meta(env);
const ranged = new Set(['/api/summary', '/api/timeseries', '/api/breakdown', '/api/activation', '/api/retention']);
if (!ranged.has(url.pathname)) return fail('not found', 404);
const range = parseRange(url);
if (isApiResult(range)) return range;
try {
switch (url.pathname) {
case '/api/summary':
return await summary(env, range);
case '/api/timeseries':
return await timeseries(env, url, range);
case '/api/breakdown':
return await breakdown(env, url, range);
case '/api/activation':
return await activation(env, url, range);
case '/api/retention':
return await retention(env, range);
default:
return fail('not found', 404);
}
} catch (err) {
// The query failed, not the caller. Log the cause, tell the page something
// it can put in the panel, and let the other panels carry on.
console.error(JSON.stringify({ msg: 'api query failed', path: url.pathname, err: String(err) }));
return { body: { error: 'query failed' }, status: 503 };
}
}
Binary file not shown.
+275
View File
@@ -0,0 +1,275 @@
/**
* codegraph telemetry dashboard stats.getcodegraph.com
*
* The private counterpart to `telemetry-worker/`: that one writes events into
* D1, this one reads them back for the two people who look at the numbers.
*
* Everything is deny-by-default. `assets.run_worker_first` is `true` in
* wrangler.jsonc, so the static-asset server never sees a request this file has
* not already authorised the only unauthenticated surface is the login page,
* which the worker renders inline, and robots.txt.
*
* D1 is read-only here. Writes belong to the ingest worker's cron.
*/
import { handleApi } from './api';
import {
checkPassword,
clearedSessionCookie,
hasValidSession,
isSameOriginPost,
issueSession,
sessionCookie,
} from './auth';
import { renderLoginPage } from './login-page';
const MAX_LOGIN_BODY_BYTES = 4 * 1024;
const ROBOTS_TXT = 'User-agent: *\nDisallow: /\n';
/**
* Security headers for every response. `styleNonce` is only passed for the
* inline-styled login page; asset-served pages link a stylesheet instead.
*/
function securityHeaders(styleNonce?: string): Record<string, string> {
const styleSrc = styleNonce ? `'self' 'nonce-${styleNonce}'` : "'self'";
return {
'content-security-policy': [
"default-src 'none'",
"script-src 'self'",
`style-src ${styleSrc}`,
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join('; '),
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
'cross-origin-opener-policy': 'same-origin',
};
}
function withSecurityHeaders(response: Response, styleNonce?: string): Response {
const out = new Response(response.body, response);
for (const [name, value] of Object.entries(securityHeaders(styleNonce))) {
out.headers.set(name, value);
}
return out;
}
/**
* Builds the response headers. Extras go through `new Headers(...)` rather than
* an object spread: spreading a `Headers` instance silently yields `{}`, and
* losing a `set-cookie` that way would be a very quiet bug.
*/
function headersWith(defaults: Record<string, string>, extra?: HeadersInit): Headers {
const headers = new Headers(defaults);
if (extra) {
for (const [name, value] of new Headers(extra)) headers.set(name, value);
}
return headers;
}
function html(body: string, init: ResponseInit & { nonce?: string } = {}): Response {
const { nonce, headers, ...rest } = init;
return withSecurityHeaders(
new Response(body, {
...rest,
headers: headersWith(
{
'content-type': 'text/html; charset=utf-8',
// Never let a page render from cache after sign-out.
'cache-control': 'no-store',
},
headers,
),
}),
nonce,
);
}
function json(body: unknown, init: ResponseInit = {}): Response {
const { headers, ...rest } = init;
return withSecurityHeaders(
new Response(JSON.stringify(body), {
...rest,
headers: headersWith(
{
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
},
headers,
),
}),
);
}
function redirect(location: string, init: ResponseInit = {}): Response {
const { headers, status, ...rest } = init;
return withSecurityHeaders(
new Response(null, {
...rest,
status: status ?? 302,
headers: headersWith({ location, 'cache-control': 'no-store' }, headers),
}),
);
}
/**
* Only same-origin absolute paths survive, so `?next=` can never become an open
* redirect. `//evil.example` and `/\evil.example` are protocol-relative URLs in
* a browser, not paths hence the second character check.
*/
function safeNextPath(candidate: string | null): string {
if (!candidate || !candidate.startsWith('/')) return '/';
if (candidate.startsWith('//') || candidate.startsWith('/\\')) return '/';
return candidate;
}
function loginRedirect(url: URL): Response {
const next = `${url.pathname}${url.search}`;
const target = next === '/' ? '/login' : `/login?next=${encodeURIComponent(next)}`;
return redirect(target);
}
function nonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
/** Best-effort brute-force cap on the shared password, keyed by client IP. */
async function loginRateLimitOk(env: Env, request: Request): Promise<boolean> {
// Note for auditors: unlike the ingest worker — which never reads the client
// IP — this admin login does, purely as a rate-limit key. It is not stored,
// logged or forwarded anywhere.
const key = request.headers.get('cf-connecting-ip') ?? 'unknown';
try {
const { success } = await env.LOGIN_RATE_LIMITER.limit({ key });
return success;
} catch (err) {
// Fail open: a rate-limiter outage must not lock the maintainer out, and
// the password is still required either way.
console.error(JSON.stringify({ msg: 'login rate limiter unavailable', err: String(err) }));
return true;
}
}
async function handleLoginPage(env: Env, request: Request, url: URL): Promise<Response> {
const next = safeNextPath(url.searchParams.get('next'));
if (await hasValidSession(env, request)) return redirect(next);
const styleNonce = nonce();
return html(renderLoginPage({ next, nonce: styleNonce }), { nonce: styleNonce });
}
async function handleLoginSubmit(env: Env, request: Request): Promise<Response> {
if (!isSameOriginPost(request)) {
return new Response('bad request\n', { status: 400 });
}
const contentLength = Number(request.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_LOGIN_BODY_BYTES) {
return new Response('payload too large\n', { status: 413 });
}
let form: FormData;
try {
form = await request.formData();
} catch {
return new Response('bad request\n', { status: 400 });
}
const next = safeNextPath(String(form.get('next') ?? '/'));
const password = form.get('password');
const styleNonce = nonce();
const fail = (error: string, status: number): Response =>
html(renderLoginPage({ next, error, nonce: styleNonce }), { status, nonce: styleNonce });
if (!(await loginRateLimitOk(env, request))) {
return fail('Too many attempts. Wait a minute and try again.', 429);
}
if (typeof password !== 'string' || password.length === 0) {
return fail('Enter the password to continue.', 400);
}
if (!(await checkPassword(env, password))) {
return fail('That password is not right.', 401);
}
return redirect(next, { headers: { 'set-cookie': sessionCookie(await issueSession(env)) } });
}
/**
* The chart endpoints live in src/api.ts and return data, not responses, so this
* file stays the single place that decides headers on an authenticated reply.
* Everything under `/api/` is behind the same session check as the pages.
*/
async function apiResponse(env: Env, url: URL): Promise<Response> {
const result = await handleApi(env, url);
return json(result.body, {
status: result.status,
// Chart data is daily-granular, so a few minutes in the browser's private
// cache saves D1 a round of identical queries on every panel re-render.
// Anything without an explicit lifetime keeps the no-store default.
headers: result.cacheControl ? { 'cache-control': result.cacheControl } : undefined,
});
}
/** Gated static assets: the dashboard shell, its JS, its CSS, the chart library. */
async function serveAsset(env: Env, request: Request): Promise<Response> {
const asset = await env.ASSETS.fetch(request);
const out = withSecurityHeaders(asset);
// Behind a session, so it must never land in a shared cache.
out.headers.set('cache-control', 'private, no-cache');
out.headers.set('vary', 'cookie');
return out;
}
export default {
async fetch(request, env): Promise<Response> {
try {
const url = new URL(request.url);
const method = request.method;
const isRead = method === 'GET' || method === 'HEAD';
// --- unauthenticated surface: exactly these three routes ---------------
if (isRead && url.pathname === '/robots.txt') {
return new Response(ROBOTS_TXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
}
if (url.pathname === '/login') {
if (isRead) return await handleLoginPage(env, request, url);
if (method === 'POST') return await handleLoginSubmit(env, request);
return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET, POST' } });
}
if (url.pathname === '/logout') {
if (method !== 'POST') {
return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
}
if (!isSameOriginPost(request)) return new Response('bad request\n', { status: 400 });
return redirect('/login', { headers: { 'set-cookie': clearedSessionCookie() } });
}
// --- everything else needs a session -----------------------------------
const isApi = url.pathname === '/api' || url.pathname.startsWith('/api/');
if (!(await hasValidSession(env, request))) {
return isApi ? json({ error: 'unauthorized' }, { status: 401 }) : loginRedirect(url);
}
if (isApi) {
if (!isRead) {
return json({ error: 'method not allowed' }, { status: 405, headers: { allow: 'GET' } });
}
return await apiResponse(env, url);
}
if (!isRead) {
return new Response('method not allowed\n', { status: 405, headers: { allow: 'GET' } });
}
return await serveAsset(env, request);
} catch (err) {
console.error(JSON.stringify({ msg: 'unhandled error', err: String(err) }));
return new Response('internal error\n', { status: 500 });
}
},
} satisfies ExportedHandler<Env>;
+120
View File
@@ -0,0 +1,120 @@
/**
* The one page the worker renders itself.
*
* It is inline rather than a static asset because it is the only thing served
* without a session keeping it here means the asset directory can stay
* entirely behind the gate, with no "is this file public?" judgement calls.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export interface LoginPageOptions {
/** Path to return to after a successful sign-in. Already validated same-origin. */
next: string;
/** Shown above the form when a previous attempt failed. */
error?: string;
/** CSP nonce for the inline stylesheet. */
nonce: string;
}
export function renderLoginPage({ next, error, nonce }: LoginPageOptions): string {
const errorBlock = error ? `\n <p class="error" role="alert">${escapeHtml(error)}</p>` : '';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sign in codegraph telemetry</title>
<style nonce="${nonce}">
:root {
--paper: #f7f6f2;
--ink: #16150f;
--oxblood: #7a201a;
--rule: #d8d5cb;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--paper);
color: var(--ink);
font-family: 'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.5;
}
main { width: 100%; max-width: 380px; }
h1 { margin: 0 0 4px; font-size: 22px; font-weight: 600; }
.subtitle { margin: 0 0 24px; color: #56534a; }
hr { border: 0; border-top: 1px solid var(--rule); margin: 0 0 24px; }
label { display: block; margin-bottom: 6px; }
input[type='password'] {
width: 100%;
padding: 9px 10px;
font: inherit;
color: var(--ink);
background: #fff;
border: 1px solid var(--rule);
border-radius: 0;
}
input[type='password']:focus {
outline: 2px solid var(--oxblood);
outline-offset: -2px;
border-color: var(--oxblood);
}
button {
margin-top: 16px;
width: 100%;
padding: 10px 12px;
font: inherit;
color: var(--paper);
background: var(--oxblood);
border: 1px solid var(--oxblood);
border-radius: 0;
cursor: pointer;
}
button:hover { background: #5f1914; border-color: #5f1914; }
.error {
margin: 0 0 16px;
padding: 9px 10px;
color: var(--oxblood);
background: #fff;
border: 1px solid var(--oxblood);
}
.footnote { margin: 24px 0 0; color: #56534a; font-size: 14px; }
</style>
</head>
<body>
<main>
<h1>codegraph telemetry</h1>
<p class="subtitle">This dashboard is private. Enter the shared password to continue.</p>
<hr />${errorBlock}
<form method="post" action="/login">
<input type="hidden" name="next" value="${escapeHtml(next)}" />
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
autofocus
/>
<button type="submit">Sign in</button>
</form>
<p class="footnote">You stay signed in on this browser for a year.</p>
</main>
</body>
</html>
`;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": []
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+51
View File
@@ -0,0 +1,51 @@
// codegraph telemetry dashboard see README.md.
// Secrets are NOT configured here: ADMIN_PASSWORD and SESSION_SECRET are set
// via `wrangler secret put`.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "codegraph-telemetry-dashboard",
"main": "src/index.ts",
"compatibility_date": "2026-07-28",
// Private admin surface. Same pattern as the ingest worker: a custom domain
// that auto-provisions DNS + cert from the getcodegraph.com zone, with
// workers.dev off so there is no second, undocumented way in.
"routes": [{ "pattern": "stats.getcodegraph.com", "custom_domain": true }],
"workers_dev": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
// `run_worker_first: true` is load-bearing. Without it Cloudflare serves a
// matching static asset BEFORE the worker runs, which would hand out the
// dashboard and its data to anyone who guesses a filename. With it, every
// request goes through src/index.ts and only an authenticated one is proxied
// on to ASSETS.
"assets": {
"directory": "./public",
"binding": "ASSETS",
"run_worker_first": true,
"html_handling": "auto-trailing-slash",
"not_found_handling": "none"
},
// The ingest worker's database, read-only from here. Migrations live with the
// writer: telemetry-worker/migrations/.
"d1_databases": [
{
"binding": "DB",
"database_name": "codegraph-telemetry",
"database_id": "5ed36dfb-d2d7-4e35-9e63-a1b99d0b1ed3"
}
],
// Brute-force cap on the shared password, keyed by client IP. Two humans sign
// in roughly once a year each; 5/min is generous for a typo and useless for a
// guessing attack.
"ratelimits": [
{
"name": "LOGIN_RATE_LIMITER",
"namespace_id": "2001",
"simple": { "limit": 5, "period": 60 }
}
]
}
+5 -4
View File
@@ -1,4 +1,5 @@
# Copy to .dev.vars for local development (`npm run dev`) and so that
# `wrangler types` includes POSTHOG_KEY in the generated Env.
# The real key lives only in the deployed secret (`wrangler secret put POSTHOG_KEY`).
POSTHOG_KEY="phc_dev_placeholder"
# Copy to .dev.vars for local development (`npm run dev`) if you want to exercise
# POST /admin/rollup — without it that route 404s, which is also how a deploy that
# never ran `wrangler secret put ADMIN_TOKEN` behaves.
# The real token lives only in the deployed secret; nothing here is ever committed.
ADMIN_TOKEN="dev_admin_token_placeholder"
+2 -1
View File
@@ -1,5 +1,6 @@
node_modules/
.wrangler/
# local secrets (ADMIN_TOKEN) — see .dev.vars.example
.dev.vars
# generated by `wrangler types` (npm run types) — includes .dev.vars keys
# generated by `wrangler types` (npm run types)
worker-configuration.d.ts
+204 -10
View File
@@ -7,9 +7,12 @@ field, and everything that is never collected) is in
[`docs/design/telemetry.md`](../docs/design/telemetry.md).
What it does, in one breath: validates incoming batches against a strict allowlist (unknown
events dropped, unknown properties stripped), never reads or forwards the client IP,
rate-limits per machine ID, and forwards to PostHog off the response path. It ships nowhere
with the npm package — the engine's `files` allowlist excludes it.
events dropped, unknown properties stripped), never reads or stores the client IP,
rate-limits per machine ID, and writes the survivors to our own D1 database off the response
path. A nightly cron rolls each finished day up into anonymous daily counts and deletes the
raw rows behind it. It makes no outbound requests — nothing is forwarded to a third-party
analytics vendor. It ships nowhere with the npm package — the engine's `files` allowlist
excludes it.
## Endpoint contract
@@ -19,6 +22,81 @@ with the npm package — the engine's `files` allowlist excludes it.
for malformed/oversized/rate-limited requests. Clients treat every response as final —
no retries.
- `GET /` — plain-text pointer to the docs and the off-switches.
- `POST /admin/rollup` — manual rollup trigger, see below. `404` unless `ADMIN_TOKEN` is set.
## Storage (Cloudflare D1)
Telemetry is stored in the `codegraph-telemetry` D1 database on the same account, bound as
`env.DB` — this database is the only place accepted events go. Each request's surviving
events are written in a single `batch()` (one implicit transaction) under `ctx.waitUntil`,
so the write is off the response path. It is deliberately **fail-silent**: a D1 error is
logged to Workers Logs (counts only, never the payload) and the client still gets its `204`,
because clients never retry — losing a datapoint beats losing availability. Alongside the
raw rows, the worker upserts `machine_days` and `machine_first_seen`; when a batch is emptied
by the allowlist, nothing at all is written, so those tables only ever describe stored events.
The complete schema is [`migrations/0001_init.sql`](migrations/0001_init.sql) —
checked in for the same reason this worker's source is public: it is the entire list of what
gets kept, with a comment on every column and on which dashboard chart each rollup table
serves. Shape: raw sanitized `events`, `daily_*` rollups recomputed nightly, and
`machine_days` / `machine_first_seen` for retention cohorts. The dashboard reads rollups; raw
events exist for drill-down and are purged past the retention window.
```bash
npm run db:migrate:local # apply to the local .wrangler state (offline, no account needed)
npm run db:migrate # apply to the remote codegraph-telemetry database
npm run db:migrations # which migrations are applied remotely
npm run db:sql "select count(*) from events"
```
Both applies bootstrap from empty and are a no-op when already current. A schema change is a
new numbered file (`npx wrangler d1 migrations create codegraph-telemetry <name>`) — never an
edit to a migration that has been applied.
Volume, at ~97k accepted POSTs/day: ≈30M D1 row writes/month against the 50M included on
Workers Paid, plus roughly as much again once the purge reaches steady state — a delete bills
like an insert, and at steady state every row written is eventually deleted, so budget ≈48M.
D1 bills a row write per index touched on top of the table row, which is why `events` carries
only two indexes; dropping `events_machine_day` is the first lever if that gets tight. Storage
is the other constraint, and it is what sets the window: raw events grow ≈74 MB/day, so 90 days
lands at ≈6.7 GB against D1's 10 GB per-database cap, while 180 days would exceed it. Full
arithmetic and the remaining levers are in the migration's footer comment.
## Rollups & retention (nightly cron)
`src/rollup.ts` runs on a Cron Trigger at **00:30 UTC** and does two things.
**Rolls up** the day that just ended into `daily_machines`, `daily_event_counts` and
`daily_dim_counts`, then re-runs the two days before it — offline clients ship completed-day
rollups late, so a day keeps growing after it ends. The aggregation is one
`INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension, so it happens inside D1 and
no event row crosses the wire. Every write overwrites the recomputed value rather than adding
to it: **re-running a day is a no-op, never a double count.** Two things the SQL is careful
about — a `usage_rollup` row is a counter the client pre-aggregated, so its `count` prop is
summed rather than the rows counted; and `index.languages` / `install.targets` are unnested
with `json_each`, one row per element. Adding a breakdown is a line in `ROLLUP_STATEMENTS`,
never a migration — that is what the generic `(dim, value)` shape buys.
**Purges** raw `events` older than `RETENTION_DAYS` (90, a var in `wrangler.jsonc`) in bounded
`DELETE` batches, and logs one line of counts. `machine_days` and `machine_first_seen` are
never purged — retention cohorts need the full history and they are two orders of magnitude
smaller. Rollups are kept forever, so shortening the window costs ad-hoc drill-back, never a
chart.
Backfill or repair without a redeploy, guarded by the `ADMIN_TOKEN` secret:
```bash
curl -X POST -H "x-admin-token: $ADMIN_TOKEN" \
'https://telemetry.getcodegraph.com/admin/rollup?day=2026-07-27' # one day
curl -X POST -H "x-admin-token: $ADMIN_TOKEN" \
'https://telemetry.getcodegraph.com/admin/rollup?day=2026-07-27&days=14' # the 14 days ending there
```
`&reset=1` drops the day's rollup rows before recomputing, for when the dimension list itself
changed and a value that no longer exists would otherwise linger. It is ignored past the
retention window, where it would delete rows and then find no events to rebuild them from —
the response says which days it refused. Keep manual ranges to a few days at production volume;
each day is a full scan of that day's events, and the request has a wall-clock budget.
## Deploy
@@ -28,20 +106,128 @@ domain route auto-provisions DNS + cert), wrangler ≥ 4.36 (the `ratelimits` bi
```bash
cd telemetry-worker
npm install
npx wrangler login # once
npx wrangler secret put POSTHOG_KEY # the phc_… project write key — never committed
npx wrangler login # once
npm run db:migrate # bring the D1 schema up to date FIRST — the worker writes on deploy
npm run deploy
npx wrangler secret put ADMIN_TOKEN # optional, see below
```
The PostHog project itself must have **"Discard client IP data"** enabled — defense in
depth on top of this worker never forwarding IPs (`$geoip_disable` is also set per event).
The worker holds no API keys — it talks to nothing but its own bound D1 database. The one
secret is `ADMIN_TOKEN`, which enables `POST /admin/rollup`; leave it unset and that route
does not exist. Generate one with `openssl rand -hex 32`, and note that rotating it takes
effect on the next request.
## Cutover from PostHog (one-time)
The replacement of PostHog by this worker's own D1 storage. It is a **hard cutover with no
backfill** — PostHog history is disposable, and the new charts start from an empty database.
**Clients are unaffected at every step:** they keep POSTing to `telemetry.getcodegraph.com`
and every response shape is unchanged, so no client can tell which storage backend is live.
The one-way door is step 6. Everything before it is reversible with `npx wrangler rollback`,
which is why the PostHog key stays put until the new path has proven itself for a day.
**Before you start:** `npm run smoke:cutover`. It runs the whole chain locally — a client
batch through the ingest worker into D1, the nightly rollup over it, then the dashboard
reading the numbers back — and is the only check that covers the seam between the two
workers. They are separate deployments that agree on a list of dimension names by
convention alone, and a mismatch there is silent: no error, no failed request, just a panel
that reads zero forever.
1. **Put the account on Workers Paid (~$5/mo).** Ingest already runs ~97k requests/day
against the free plan's 100k/day cap, so this is overdue independently of D1 — and the
included D1 quota (5 GB storage, 50M row writes/mo) comes with it. The volume arithmetic
is under [Storage](#storage-cloudflare-d1); at ~97k POSTs/day it fits, with the retention
window sized to the 10 GB per-database cap.
2. **Bring the production database up to schema.** `codegraph-telemetry`
(`5ed36dfb-d2d7-4e35-9e63-a1b99d0b1ed3`) already exists on the account and is bound in
`wrangler.jsonc`; this only applies migrations, and is a no-op if it is already current.
```bash
cd telemetry-worker
npm run db:migrate # remote; bootstraps from empty
npm run db:migrations # confirm 0001_init is listed as applied
```
3. **Deploy, and note the version you are leaving.** Print the deployment list first — the
id at the top is your rollback target for the next 24 hours.
```bash
npx wrangler deployments list # record the current version id
npm run deploy
npx wrangler secret put ADMIN_TOKEN # if not already set; enables manual rollups
```
4. **Watch for 24 hours before trusting it.** The number that matters is the daily ingest
rate: it should track the ~9597k/day PostHog was seeing. A materially lower number means
events are being dropped somewhere between the client and the table — a schema or binding
mistake, not a real change in usage.
```bash
npm run db:sql "select count(*) as rows, max(received_at) as newest from events"
npm run db:sql "select day, count(*) from events group by day order by day desc limit 3"
```
`max(received_at)` should be seconds old at any time of day. Watch Workers Logs
(`npx wrangler tail`) alongside it for a non-zero error rate — the D1 write is deliberately
fail-silent, so a broken write shows up as a log line and a flat row count, never as a
failing request.
**If anything looks wrong, stop here and `npx wrangler rollback [version-id]`.** PostHog is
still live and still holds the key, so rolling back restores the old behaviour completely.
5. **Verify the nightly rollup and the dashboard.** After the first 00:30 UTC cron has run,
the completed day must be present in the rollup tables — the dashboard reads those, not raw
events, so an empty rollup is an empty dashboard even with ingest working perfectly.
```bash
npm run db:sql "select day, machines, prod_machines from daily_machines order by day desc limit 3"
npm run db:sql "select day, event, count from daily_event_counts order by day desc limit 10"
```
Then open the dashboard (`stats.getcodegraph.com`, see
[`../telemetry-dashboard/README.md`](../telemetry-dashboard/README.md)) and confirm the
panels render live numbers rather than empty states. If the cron did not fire, roll the day
up by hand with `POST /admin/rollup?day=…` above rather than waiting another 24 hours.
6. **Only now, retire PostHog.** Past this point the previous worker version can still be
rolled back, but it will have no key to forward with — this is the step that makes the
cutover final.
```bash
npx wrangler secret delete POSTHOG_KEY # the last vendor credential on the account
npx wrangler secret list # confirm ADMIN_TOKEN is the only secret left
```
Then cancel the subscription and delete the project.
7. **Delete this section.** The forwarding code and the `POSTHOG_HOST` var left the repo with
the D1 rewrite, and `npm run smoke:cutover` asserts on every run that the worker's source
and config reference no analytics vendor and make no outbound request at all. This runbook
is the last place the old vendor is named anywhere in the repository, so once step 6 is
done:
```bash
grep -ri posthog . --exclude-dir=node_modules --exclude-dir=.git
```
returning nothing is the check that the cutover is complete — and deleting these steps is
what makes it pass. Keep them until then: every step above is reversible, and a rollback
is useless if its instructions have already been deleted.
## Local dev & checks
```bash
cp .dev.vars.example .dev.vars # placeholder key; also feeds `wrangler types`
npm run check # wrangler types + tsc --noEmit + deploy --dry-run
npm run dev # http://localhost:8787
npm run check # wrangler types + tsc --noEmit + deploy --dry-run
npm run db:migrate:local # once, so `wrangler dev` has tables to write to
npm run dev # http://localhost:8787 (local D1 in .wrangler/)
npm run smoke # end-to-end: boots `wrangler dev`, POSTs, asserts stored rows
npm run smoke:rollup # end-to-end: seeds synthetic days, rolls them up, purges,
# asserts every number against hand-computed values
npm run smoke:cutover # the whole chain: a client batch → D1 → rollup → the dashboard
# API reads it back. Boots BOTH workers against one shared local
# D1, so it is the only check that covers the seam between them.
curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
"machine_id": "00000000-0000-4000-8000-000000000000",
@@ -51,8 +237,16 @@ curl -i localhost:8787/v1/events -H 'content-type: application/json' -d '{
"props": { "kind": "mcp_tool", "name": "codegraph_explore",
"count": 12, "error_count": 0, "client_name": "Claude Code" } }]
}'
npx wrangler d1 execute codegraph-telemetry --local \
--command "select day, event, machine_id, props from events order by id desc limit 5"
```
To drive the cron body by hand, run `wrangler dev --test-scheduled` and hit
`localhost:8787/__scheduled?cron=30+0+*+*+*`. For `POST /admin/rollup` locally, copy
`.dev.vars.example` to `.dev.vars` — without an `ADMIN_TOKEN` the route 404s, exactly as a
deploy that never set the secret does.
## Changing the schema
The allowlist in `src/index.ts` mirrors `docs/design/telemetry.md` (and the user-facing
+205
View File
@@ -0,0 +1,205 @@
-- codegraph telemetry — initial schema (Cloudflare D1)
--
-- This file is public on purpose, like the rest of telemetry-worker/: it is the
-- complete list of everything codegraph's anonymous telemetry stores. If a column
-- is not here, it is not kept. The field-by-field contract it implements lives in
-- docs/design/telemetry.md (and, user-facing, in TELEMETRY.md).
--
-- Nothing in this database identifies a person or a codebase. No IP addresses (the
-- ingest worker never reads them), no file paths, no repo, file, or symbol names, no
-- query strings. `machine_id` is a random UUIDv4 the client mints locally and the user
-- can delete at any time (`codegraph telemetry off`, or remove ~/.codegraph/telemetry.json).
--
-- Shape: raw events + daily rollups.
-- * The ingest worker (`src/index.ts`) writes ONLY to `events`, `machine_days` and
-- `machine_first_seen`, off the response path.
-- * The nightly cron recomputes the `daily_*` rollups from `events` with idempotent
-- upserts, then purges raw `events` past the retention window.
-- * The admin dashboard reads rollups first and falls back to `events` only for the
-- activation funnel and ad-hoc drill-down (both bounded by the retention window).
--
-- Apply: npm run db:migrate:local (local .wrangler state)
-- npm run db:migrate (remote codegraph-telemetry)
-- ---------------------------------------------------------------------------
-- Raw events
-- ---------------------------------------------------------------------------
-- One row per sanitized event accepted by POST /v1/events. Everything here has
-- already passed the worker's allowlist: unknown events dropped, unknown props
-- stripped, strings length- and charset-checked, timestamps clamped.
--
-- Deliberately NO `CHECK (event IN (...))` constraint: the worker's EVENTS allowlist
-- is the single source of truth, and the write path is fail-silent by design (a
-- rejected INSERT would lose data quietly rather than error visibly). Same reasoning
-- for `json_valid(props)` — the worker constructs that JSON itself.
CREATE TABLE events (
-- rowid alias, no AUTOINCREMENT: ids are never referenced anywhere, and the
-- retention purge only ever deletes the OLDEST rows, so max(id) never drops and
-- ids stay monotonic in practice. Gives the purge a cheap keyset batch:
-- DELETE FROM events WHERE id IN (SELECT id FROM events WHERE day < ? LIMIT 5000)
id INTEGER PRIMARY KEY,
received_at TEXT NOT NULL, -- ISO 8601 UTC, worker clock, always present
ts TEXT, -- ISO 8601 UTC client timestamp, already clamped
-- by the worker (>10min future / >30d past rejected);
-- NULL when the client sent none. For usage_rollup
-- the client sets it to <rollup day>T12:00:00Z, so it
-- attributes counters to the day they happened on.
day TEXT NOT NULL, -- UTC YYYY-MM-DD from substr(ts, 1, 10), else received_at.
-- Every rollup and every chart is keyed on this.
event TEXT NOT NULL, -- install | index | usage_rollup | uninstall
machine_id TEXT NOT NULL, -- random UUIDv4, client-minted (never fingerprinted)
-- Envelope, identical for every event in a batch. All nullable: the worker's
-- sanitizer strips anything malformed rather than rejecting the batch, so an old
-- or odd client shows up as NULLs instead of vanishing.
codegraph_version TEXT,
os TEXT, -- process.platform: darwin | linux | win32 | …
arch TEXT, -- process.arch: arm64 | x64 | …
node_major INTEGER,
ci INTEGER, -- 0/1 from the client's `ci` boolean; NULL if absent.
-- "Production users" = everything except ci = 1
-- (NULL counts as production — see machine_days.prod).
schema_version INTEGER,
props TEXT NOT NULL DEFAULT '{}' -- JSON object of the sanitized event-specific props
);
-- (day, event) subsumes a plain (day) index — SQLite uses the leading-column prefix —
-- so this pair covers day-range scans, per-event day-range scans AND the retention
-- purge with one fewer index than listing them separately. That matters: D1 bills an
-- extra row write per index touched, so every index on this table costs ~97k
-- writes/day. Do not add a third without re-checking the volume note below.
CREATE INDEX events_day_event ON events (day, event);
-- Ad-hoc per-machine drill-down and the activation funnel (install → first index).
CREATE INDEX events_machine_day ON events (machine_id, day);
-- ---------------------------------------------------------------------------
-- Rollups — written by the nightly cron, read by the dashboard
-- ---------------------------------------------------------------------------
-- Rollups are kept FOREVER (they are tiny); raw `events` are purged. So any number a
-- chart needs long-term has to be recoverable from these tables alone — that is why
-- the distinct-machine columns exist alongside the event counts.
-- Daily unique machines.
-- Serves: "Daily Production Users" line; the machine denominator on daily panels.
-- `prod_machines` excludes ci = 1 (CI runners), matching the dashboard's
-- "Production Users" framing. NOTE: these are per-day distinct counts and CANNOT be
-- summed across a range — a range-wide distinct count comes from `machine_days`.
CREATE TABLE daily_machines (
day TEXT PRIMARY KEY,
machines INTEGER NOT NULL DEFAULT 0,
prod_machines INTEGER NOT NULL DEFAULT 0
);
-- Daily event volume per event type.
-- Serves: "Install" / "Uninstall" big numbers; "Installs vs uninstalls over time";
-- "New installs (daily)"; the runs series of "Daily indexing activity".
-- `count` is a row count for install/index/uninstall, but for usage_rollup it is the
-- SUM of the events' `count` prop (the client pre-aggregates locally, so one row can
-- represent hundreds of tool calls). `machines` is the distinct machines that emitted
-- that event that day — the "active users" series of "Daily indexing activity", which
-- is unrecoverable once the raw rows are purged.
CREATE TABLE daily_event_counts (
day TEXT NOT NULL,
event TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
machines INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, event)
) WITHOUT ROWID;
-- One generic (dimension, value) table behind every bar and pie on the dashboard,
-- so a new breakdown is a cron change, never a migration.
-- Serves, by `dim`:
-- os → "Users by operating system" (pie)
-- arch → arch mix
-- codegraph_version → "Users by app version" (bar)
-- node_major → Node version mix
-- language → "Most-indexed programming languages" (bar; unnested from index.languages)
-- file_count_bucket → "Codebase size (files per project)" (bar)
-- duration_bucket → "Session run length" (pie) and "Indexing speed" (bar),
-- plus "indexing duration buckets over time" (stacked line)
-- target → "AI Agent Targets" (bar; unnested from install.targets / uninstall.targets)
-- scope → install local vs global
-- kind → install fresh / upgrade / reinstall
-- name → usage by MCP tool / CLI command (incl. prompt-hook-gate-* outcomes)
-- client_name → usage by agent (Claude Code, Cursor, …), from MCP clientInfo
-- `event` is kept in the key so the same dim can be sliced per event type (e.g. os for
-- install vs os for index). `count` is event volume (SUM of the usage_rollup `count`
-- prop where applicable); `machines` is distinct machines — the honest number for the
-- "users by …" panels, which are machine counts, not event counts.
CREATE TABLE daily_dim_counts (
day TEXT NOT NULL,
event TEXT NOT NULL,
dim TEXT NOT NULL,
value TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
machines INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, event, dim, value)
) WITHOUT ROWID;
-- Cross-event slices of one dimension over a date range ("languages, all events, last 30d").
CREATE INDEX daily_dim_counts_dim_day ON daily_dim_counts (dim, day);
-- First day a machine was ever seen.
-- Serves: "New installs over time"; the denominator of the install → first-use
-- activation funnel; the cohort key for retention.
-- Written by the ingest worker on every batch (upsert keeps the MINIMUM day, so a
-- late-arriving offline buffer can move a machine's first day earlier but never later).
CREATE TABLE machine_first_seen (
machine_id TEXT PRIMARY KEY,
first_day TEXT NOT NULL
);
-- Cohort scans: "machines first seen between X and Y".
CREATE INDEX machine_first_seen_day ON machine_first_seen (first_day);
-- Machine × day activity matrix — the only table that can answer "distinct machines
-- over a RANGE" (daily rollups can't: summing them double-counts returning machines).
-- Serves: "Daily retention cohorts" (day 014 curve, joined to machine_first_seen);
-- the "Production Users" big number over the picker's range;
-- active-machine lines beyond the raw-event retention window.
-- `prod` is 0 only if EVERY event that machine sent that day carried ci = 1; a missing
-- `ci` counts as production. Kept per (machine, day) rather than as a per-machine flag
-- because the same install can run inside and outside CI on different days.
-- ~10k rows/day at current volume — WITHOUT ROWID keeps it compact (the PK is the table).
-- NOT purged by the retention job: retention cohorts need the full history.
CREATE TABLE machine_days (
machine_id TEXT NOT NULL,
day TEXT NOT NULL,
prod INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (machine_id, day)
) WITHOUT ROWID;
-- Day-keyed scans ("distinct machines active in this range").
CREATE INDEX machine_days_day ON machine_days (day);
-- ---------------------------------------------------------------------------
-- Volume & storage sanity check (Workers Paid, limits as of 2026-07)
-- ---------------------------------------------------------------------------
-- Included per month: 50M rows written, 25B rows read, 5 GB storage
-- (then $0.75/GB-mo). Hard cap: 10 GB per database.
--
-- Current ingest is ~97k accepted POSTs/day. D1 counts one row write PER INDEX
-- touched in addition to the table row, so with ~2 events per request:
--
-- events 97k × 2 × (1 table + 2 indexes) ≈ 0.6M writes/day
-- machine_days 97k × (1 table + 1 index) ≈ 0.2M writes/day
-- first_seen 97k × (1 table + 1 index) ≈ 0.2M writes/day
-- rollup cron ~1k rows/day negligible
-- ─────────────────
-- ≈ 1.0M writes/day ≈ 30M/month
--
-- Comfortably inside the 50M included, with ~1.6× headroom. (The epic's "~10M/month"
-- estimate predates counting index writes; the arithmetic above is the one to trust.)
-- Reads are trivial: the dashboard hits rollups, ~thousands of rows per page load.
--
-- STORAGE is the tighter constraint, and it decides the retention window. A raw event
-- row is ~250 B plus ~130 B of index entries, so ~74 MB/day:
--
-- 90-day retention ≈ 6.7 GB under the 10 GB cap, ~$1.30/mo over the 5 GB included
-- 180-day retention ≈ 13 GB EXCEEDS the 10 GB per-database cap
--
-- So the retention job should start at 90 days, not 180 — and the real row size must be
-- measured after cutover (`SELECT count(*), sum(length(props)) FROM events`) before
-- widening it. Rollups are kept forever regardless, so shortening the raw window costs
-- ad-hoc drill-back, never a chart. If writes or storage ever get tight, the levers, in
-- order: drop events_machine_day (drill-down only), move the machine_first_seen upsert
-- off the hot path into the nightly cron, then store timestamps as INTEGER epoch ms.
+8 -1
View File
@@ -6,7 +6,14 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types",
"check": "wrangler types && tsc --noEmit && wrangler deploy --dry-run"
"check": "wrangler types && tsc --noEmit && wrangler deploy --dry-run",
"smoke": "./scripts/smoke-ingest.sh",
"smoke:rollup": "./scripts/smoke-rollup.sh",
"smoke:cutover": "./scripts/smoke-cutover.sh",
"db:migrate:local": "wrangler d1 migrations apply codegraph-telemetry --local",
"db:migrate": "wrangler d1 migrations apply codegraph-telemetry --remote",
"db:migrations": "wrangler d1 migrations list codegraph-telemetry --remote",
"db:sql": "wrangler d1 execute codegraph-telemetry --remote --command"
},
"devDependencies": {
"typescript": "^5.0.0",
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# The cutover gate (CG-14): drives the WHOLE chain the way production will run it —
# a client POSTs a batch, the ingest worker writes D1, the nightly rollup aggregates,
# and the dashboard reads the numbers back out.
#
# Every other suite tests one link. smoke-ingest.sh stops at the `events` table,
# smoke-rollup.sh hand-checks the rollup SQL, and smoke-api.sh reads a fixture that
# was written by hand rather than by the cron. That leaves exactly the seam this
# cutover turns on unverified: the dimension names the rollup WRITES versus the ones
# the dashboard READS. Those two lists live in different workers on different
# branches, and a mismatch is silent — no error, no failed request, just a panel that
# renders zero forever. Catching that after cutover means a day of lost telemetry;
# catching it here costs a minute.
#
# Both workers declare the same D1 `database_id`, so pointing them at one
# `--persist-to` directory gives them literally the same local SQLite file. The state
# is a fresh mktemp each run, so every expected number below is exact rather than a
# lower bound.
#
# npm run smoke:cutover
#
# Expected numbers are derived from THE_BATCH below and nothing else; see the table
# in that comment block.
set -uo pipefail
cd "$(dirname "$0")/.."
WORKER_DIR="$PWD"
DASH_DIR="$(cd .. && pwd)/telemetry-dashboard"
[ -d "$DASH_DIR" ] || { echo "cannot find telemetry-dashboard/ next to telemetry-worker/"; exit 1; }
INGEST_PORT="${CUTOVER_INGEST_PORT:-8795}"
DASH_PORT="${CUTOVER_DASH_PORT:-8796}"
INGEST="http://127.0.0.1:$INGEST_PORT"
DASH="http://127.0.0.1:$DASH_PORT"
# Test-only credentials. The point is to exercise the wiring, not to keep a secret.
ADMIN_TOKEN=cutover-admin-token
DASH_PASSWORD=cutover-dashboard-password
SESSION_SECRET=cutover-session-secret
STATE="$(mktemp -d -t cg-cutover-state)"
JAR="$(mktemp -t cg-cutover-jar)"
ILOG=/tmp/cg-cutover-ingest.log
DLOG=/tmp/cg-cutover-dash.log
pass=0; fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
DEV_PID=""
stop_dev() {
[ -n "$DEV_PID" ] || return 0
kill "$DEV_PID" 2>/dev/null
wait "$DEV_PID" 2>/dev/null
DEV_PID=""
}
cleanup() { stop_dev; rm -rf "$STATE" "$JAR"; }
trap cleanup EXIT
# Boot a worker in <dir> on <port> against the SHARED state, wait for <readyurl>.
boot() { # boot <dir> <port> <readyurl> <log> [extra wrangler args...]
local dir="$1" port="$2" ready="$3" log="$4"; shift 4
( cd "$dir" && exec npx wrangler dev --port "$port" --ip 127.0.0.1 \
--persist-to "$STATE" "$@" ) >"$log" 2>&1 &
DEV_PID=$!
for _ in $(seq 1 90); do
curl -sf -o /dev/null "$ready" && return 0
kill -0 "$DEV_PID" 2>/dev/null || break
sleep 1
done
echo "worker in $dir never came up on :$port — log follows"; cat "$log"; exit 1
}
# Resolve a dotted path through a JSON document. Numeric segments index arrays.
jget() {
node -e '
let v = JSON.parse(process.argv[1]);
for (const k of process.argv[2].split(".")) v = v?.[k];
console.log(v === undefined ? "<missing>" : typeof v === "object" && v !== null ? JSON.stringify(v) : String(v));
' "$1" "$2"
}
day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; }
# Inside the ingest clamp window (30 days) and outside the cron's 3-day lookback.
DAY="$(day_ago 5)"
RANGE="from=$DAY&to=$DAY"
# ---------------------------------------------------------------------------
# THE_BATCH — three machines, one day. Everything asserted below follows from here.
#
# machine os arch node version ci events
# m1 darwin arm64 22 1.5.0 false install(local/fresh, [claude,cursor])
# index([typescript,python], 100-1k, 10-60s)
# usage_rollup(codegraph_explore x12, Claude Code)
# m2 linux x64 20 1.5.0 false install(global/upgrade, [codex])
# index([typescript], 1k-10k, 1-5m)
# usage_rollup(codegraph_explore x8, Codex CLI)
# m3 linux arm64 22 1.4.1 TRUE index([go], <100, <10s)
# uninstall([claude])
#
# The three deliberate traps:
# * m3 is ci=true, so it counts as active but NOT as a production user.
# * tool_calls must SUM the `count` prop (12 + 8 = 20), not count the 2 rows.
# * m3's uninstall carries targets=[claude], so a `target` breakdown that forgets
# to scope by event would report claude twice.
# ---------------------------------------------------------------------------
M1=11111111-1111-4111-8111-111111111111
M2=22222222-2222-4222-8222-222222222222
M3=33333333-3333-4333-8333-333333333333
post_batch() { # post_batch <json>
curl -s -o /dev/null -w '%{http_code}' -X POST "$INGEST/v1/events" \
-H 'content-type: application/json' --data-binary "$1"
}
batch() { # batch <machine> <os> <arch> <node> <version> <ci> <events-json>
node -e '
const [m, os, arch, node_major, v, ci, events, day] = process.argv.slice(1);
process.stdout.write(JSON.stringify({
machine_id: m, codegraph_version: v, os, arch,
node_major: Number(node_major), ci: ci === "true", schema_version: 1,
events: JSON.parse(events).map((e) => ({ ...e, ts: `${day}T12:00:00Z` })),
}));
' "$@" "$DAY"
}
# ---------------------------------------------------------------------------
echo "cutover chain: client → ingest worker → D1 → rollup → dashboard"
echo
echo "migrating the shared local D1 state"
( cd "$WORKER_DIR" && npx wrangler d1 migrations apply codegraph-telemetry \
--local --persist-to "$STATE" ) >/tmp/cg-cutover-migrate.log 2>&1 ||
{ echo "migration failed:"; cat /tmp/cg-cutover-migrate.log; exit 1; }
echo "booting the ingest worker on :$INGEST_PORT"
boot "$WORKER_DIR" "$INGEST_PORT" "$INGEST/" "$ILOG" --var "ADMIN_TOKEN:$ADMIN_TOKEN"
echo
echo "ingest accepts the batch"
is "m1 batch → 204" 204 "$(post_batch "$(batch "$M1" darwin arm64 22 1.5.0 false '[
{"event":"install","props":{"scope":"local","kind":"fresh","targets":["claude","cursor"]}},
{"event":"index","props":{"languages":["typescript","python"],"file_count_bucket":"100-1k","duration_bucket":"10-60s"}},
{"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":12,"client_name":"Claude Code"}}
]')")"
is "m2 batch → 204" 204 "$(post_batch "$(batch "$M2" linux x64 20 1.5.0 false '[
{"event":"install","props":{"scope":"global","kind":"upgrade","targets":["codex"]}},
{"event":"index","props":{"languages":["typescript"],"file_count_bucket":"1k-10k","duration_bucket":"1-5m"}},
{"event":"usage_rollup","props":{"kind":"mcp_tool","name":"codegraph_explore","count":8,"client_name":"Codex CLI"}}
]')")"
is "m3 (ci) batch → 204" 204 "$(post_batch "$(batch "$M3" linux arm64 22 1.4.1 true '[
{"event":"index","props":{"languages":["go"],"file_count_bucket":"<100","duration_bucket":"<10s"}},
{"event":"uninstall","props":{"targets":["claude"]}}
]')")"
sleep 2 # let the ctx.waitUntil writes drain before rolling up
echo
echo "the nightly rollup aggregates the day"
ROLL=$(curl -s -X POST -H "x-admin-token: $ADMIN_TOKEN" "$INGEST/admin/rollup?day=$DAY")
is "POST /admin/rollup → ok" true "$(jget "$ROLL" ok)"
is "rollup wrote rows" true "$(node -e 'process.stdout.write(String((JSON.parse(process.argv[1]).rows ?? 0) > 0))' "$ROLL")"
stop_dev # free the D1 lock before the dashboard opens the same file
echo
echo "booting the dashboard on :$DASH_PORT against the same D1"
( cd "$DASH_DIR" && npm run --silent vendor ) >/dev/null 2>&1
boot "$DASH_DIR" "$DASH_PORT" "$DASH/robots.txt" "$DLOG" \
--var "ADMIN_PASSWORD:$DASH_PASSWORD" --var "SESSION_SECRET:$SESSION_SECRET"
curl -s -o /dev/null -c "$JAR" -X POST "$DASH/login" --data-urlencode "password=$DASH_PASSWORD"
api() { curl -s -b "$JAR" "$DASH/api/$1"; }
is "dashboard session established" 200 "$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/health")"
# --- the big numbers -------------------------------------------------------
echo
echo "summary panel reads back what was ingested"
S=$(api "summary?$RANGE")
is "production users (ci machine excluded)" 2 "$(jget "$S" production_users)"
is "active machines" 3 "$(jget "$S" active_machines)"
is "new machines" 3 "$(jget "$S" new_machines)"
is "installs" 2 "$(jget "$S" installs)"
is "uninstalls" 1 "$(jget "$S" uninstalls)"
is "indexing runs" 3 "$(jget "$S" index_runs)"
is "tool calls SUM the count prop (12+8)" 20 "$(jget "$S" tool_calls)"
# --- every dimension the dashboard offers ----------------------------------
# This is the actual point of the suite: each of these is a distinct string that
# must match between rollup.ts and api.ts's DIMS registry. An empty `labels` means
# the dashboard is asking for a dimension the cron never writes.
echo
echo "every breakdown dimension resolves against the cron's output"
bd() { # bd <desc> <query> <expected-labels-json> <expected-data-json>
local body; body=$(api "breakdown?$RANGE&$2")
is "$1 — labels" "$3" "$(jget "$body" labels)"
is "$1 — data" "$4" "$(jget "$body" datasets.0.data)"
}
bd "os" "dim=os" '["linux","darwin"]' '[2,1]'
bd "arch" "dim=arch" '["arm64","x64"]' '[2,1]'
bd "version" "dim=codegraph_version" '["1.5.0","1.4.1"]' '[2,1]'
bd "node major" "dim=node_major" '["22","20"]' '[2,1]'
bd "language" "dim=language" '["typescript","go","python"]' '[2,1,1]'
bd "files in project" "dim=file_count_bucket" '["<100","100-1k","1k-10k","10k+"]' '[1,1,1,0]'
bd "run length" "dim=duration_bucket" '["<10s","10-60s","1-5m","5m+"]' '[1,1,1,0]'
bd "install scope" "dim=scope" '["global","local"]' '[1,1]'
bd "install kind" "dim=kind" '["fresh","upgrade"]' '[1,1]'
bd "tool name" "dim=name" '["codegraph_explore"]' '[20]'
bd "agent" "dim=client_name" '["Claude Code","Codex CLI"]' '[12,8]'
# The trap: `target` defaults to event=install, so the uninstall's own claude target
# must NOT be folded in — and must still be reachable by asking for it explicitly.
bd "agent target (install-scoped)" "dim=target" '["claude","codex","cursor"]' '[1,1,1]'
bd "agent target (uninstall)" "dim=target&event=uninstall" '["claude"]' '[1]'
# --- the remaining panels --------------------------------------------------
echo
echo "the timeseries and funnel panels see the day"
# Every entry in api.ts's SERIES registry — each one reads a different rollup table,
# so this is the second half of the write-vs-read seam the breakdowns cover above.
ts() { # ts <desc> <metric> <series-0> [series-1]
local body; body=$(api "timeseries?$RANGE&metric=$2")
is "$1 — day" "[\"$DAY\"]" "$(jget "$body" labels)"
is "$1 — series" "$3" "$(jget "$body" datasets.0.data)"
[ $# -ge 4 ] && is "$1 — second series" "$4" "$(jget "$body" datasets.1.data)"
}
ts "installs and uninstalls" installs_uninstalls '[2]' '[1]'
ts "new installs" new_installs '[3]'
ts "production users" production_users '[2]'
ts "indexing activity" indexing_activity '[3]' '[3]'
ts "tool calls (sums the prop)" tool_calls '[20]' '[2]'
MET=$(api "meta")
is "meta anchors on the rolled-up day" "$DAY" "$(jget "$MET" latest_day)"
is "meta reports the rollup ran" "$DAY" "$(jget "$MET" latest_rollup_day)"
# The funnel is the one panel that reads RAW events rather than a rollup, so it is
# also the one the retention purge can blind — worth pinning that it works today.
#
# Its denominator is FIRST-SEEN MACHINES, not `install` events (api.ts: "a machine
# that reinstalls does not re-enter the funnel"). m3 is the discriminator: it never
# sent an install event, but it is new and it indexed, so it belongs in both legs.
# Reading 2 here would mean the funnel had quietly become an install-event ratio.
ACT=$(api "activation?$RANGE&window=1")
is "funnel counts new machines, not install events" 3 "$(jget "$ACT" installs)"
is "all three indexed within the window" 3 "$(jget "$ACT" activated)"
is "nobody dropped out" 0 "$(jget "$ACT" dropped)"
is "raw-event floor is reported to the caller" "$DAY" "$(jget "$ACT" raw_events_from)"
is "retention endpoint answers" 200 \
"$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" "$DASH/api/retention?$RANGE")"
# --- the guarantee the cutover is selling ----------------------------------
echo
echo "the no-third-party guarantee still holds"
is "ingest worker makes no outbound fetch" 0 \
"$(grep -E 'fetch\(' "$WORKER_DIR"/src/*.ts | grep -vc 'async fetch(request' || true)"
is "ingest worker names no third-party analytics endpoint" 0 \
"$(grep -rEil 'https?://[a-z0-9.-]+/(batch|capture|collect|track|ingest)' \
"$WORKER_DIR"/src "$WORKER_DIR"/wrangler.jsonc 2>/dev/null | wc -l | tr -d ' ')"
echo
if [ "$fail" -eq 0 ]; then
echo "$pass passed, 0 failed — the chain is whole; safe to cut over"
else
echo "$pass passed, $fail failed"
fi
[ "$fail" -eq 0 ]
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# End-to-end check of the ingest contract against a real `wrangler dev` + local D1.
#
# Boots the worker, POSTs a spread of good and bad batches, then shuts the worker
# down and inspects the rows that actually landed. Every request uses a fresh
# machine_id, so the script is re-runnable against a dirty local database and never
# trips the per-machine rate limit.
#
# npm run db:migrate:local # once
# npm run smoke # or: INGEST_PORT=8791 ./scripts/smoke-ingest.sh
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${INGEST_PORT:-8787}"
BASE="http://127.0.0.1:$PORT"
DB=codegraph-telemetry
pass=0; fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
uuid() { node -e 'console.log(crypto.randomUUID())'; }
# HTTP status of a POST /v1/events with the given body.
post() { curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
-H 'content-type: application/json' --data-binary "$1"; }
# First column of the first row of a query against the LOCAL D1 state.
q() {
npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
console.log(r===undefined?"":String(Object.values(r)[0]));})'
}
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
echo "booting wrangler dev on :$PORT"
npx wrangler dev --port "$PORT" >/tmp/cg-smoke-ingest.log 2>&1 &
DEV_PID=$!
cleanup() { kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true; }
trap cleanup EXIT
for _ in $(seq 1 60); do
curl -sf -o /dev/null "$BASE/" && break
kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
sleep 1
done
curl -sf -o /dev/null "$BASE/" || { echo "worker never came up:"; cat /tmp/cg-smoke-ingest.log; exit 1; }
# ---------------------------------------------------------------------------
# Request contract
# ---------------------------------------------------------------------------
echo
echo "request contract"
INFO=$(curl -s "$BASE/")
case "$INFO" in *"codegraph anonymous-telemetry ingest"*) ok "GET / serves the info text";;
*) bad "GET / serves the info text" "info text" "$INFO";; esac
case "$INFO" in *"never forwarded to any third-party analytics"*) ok "info text states the storage guarantee";;
*) bad "info text states the storage guarantee" "the no-third-party sentence" "missing";; esac
# The guarantee above holds only while the worker makes no outbound request at all,
# so the only `fetch(` anywhere in the source may be the handler's own declaration.
is "worker source makes no outbound fetch" 0 \
"$(grep -E 'fetch\(' src/*.ts | grep -vc 'async fetch(request' || true)"
is "unknown path → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/nope")"
is "GET /v1/events → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/events")"
is "non-JSON body → 400" 400 "$(post 'not json')"
is "JSON array body → 400" 400 "$(post '[]')"
is "missing machine_id → 400" 400 "$(post '{"events":[]}')"
is "malformed machine_id → 400" 400 "$(post '{"machine_id":"nope","events":[]}')"
is "chunked (no length) → 411" 411 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/v1/events" \
-H 'content-type: application/json' -H 'transfer-encoding: chunked' --data-binary '{"machine_id":"x"}')"
BIG=$(node -e 'process.stdout.write(JSON.stringify({machine_id:"00000000-0000-4000-8000-000000000000",pad:"x".repeat(70000),events:[]}))')
is "oversized body → 413" 413 "$(post "$BIG")"
# ---------------------------------------------------------------------------
# Accepted batches
# ---------------------------------------------------------------------------
echo
echo "ingest"
M_OK=$(uuid); M_DROP=$(uuid); M_CI=$(uuid); M_BACK=$(uuid)
TODAY=$(date -u +%F)
# Three valid events + one unknown event + unknown/malformed props that must be stripped.
is "valid batch → 204" 204 "$(post "$(node -e '
const [m] = process.argv.slice(1);
process.stdout.write(JSON.stringify({
machine_id: m, codegraph_version: "1.5.0", os: "darwin", arch: "arm64",
node_major: 22, ci: false, schema_version: 1, secret_field: "must not be stored",
events: [
{ event: "install", ts: "2026-07-27T10:00:00Z",
props: { scope: "local", kind: "fresh", targets: ["claude", "cursor"], nope: "strip me" } },
{ event: "index", ts: "2026-07-27T10:01:00Z",
props: { languages: ["typescript"], file_count_bucket: "100-1k",
duration_bucket: "bogus-bucket", repo_path: "/Users/someone/secret" } },
{ event: "usage_rollup",
props: { kind: "mcp_tool", name: "codegraph_explore", count: 12, client_name: "Claude Code" } },
{ event: "not_an_event", props: { count: 1 } },
],
}));' "$M_OK")")"
# Nothing survives the allowlist: unknown event + usage_rollup missing required props.
is "all-dropped batch → 204" 204 "$(post "$(node -e '
const [m] = process.argv.slice(1);
process.stdout.write(JSON.stringify({ machine_id: m, os: "linux", events: [
{ event: "made_up" },
{ event: "usage_rollup", props: { kind: "mcp_tool" } },
{ event: "install", props: { scope: "local" } },
]}));' "$M_DROP")")"
# NOTE: build every body into a variable first. Escaped quotes nested inside
# "$(post "…\"…\"…")" break out of the quoting context and get brace-expanded.
index_batch() { # <machine_id> [ci] [ts]
node -e 'const [m, ci, ts] = process.argv.slice(1);
const e = { event: "index", props: {} };
if (ts) e.ts = ts;
const b = { machine_id: m, os: "linux", events: [e] };
if (ci) b.ci = ci === "true";
process.stdout.write(JSON.stringify(b));' "$@"
}
# ci = true, then a non-CI batch for the same machine/day: prod must flip 0 → 1.
CI_ON=$(index_batch "$M_CI" true); CI_OFF=$(index_batch "$M_CI" false)
is "ci batch → 204" 204 "$(post "$CI_ON")"
is "same machine, non-ci → 204" 204 "$(post "$CI_OFF")"
# A late offline buffer arriving second must move first_day EARLIER, never later.
RECENT=$(index_batch "$M_BACK" "" 2026-07-27T09:00:00Z)
BACKDATED=$(index_batch "$M_BACK" "" 2026-07-20T09:00:00Z)
is "recent batch → 204" 204 "$(post "$RECENT")"
is "backdated batch → 204" 204 "$(post "$BACKDATED")"
sleep 2 # let the ctx.waitUntil writes drain
cleanup; trap - EXIT
sleep 1 # and let miniflare release the local sqlite file
# ---------------------------------------------------------------------------
# What actually got stored
# ---------------------------------------------------------------------------
echo
echo "stored rows"
is "3 of 4 events stored (unknown dropped)" 3 "$(q "select count(*) from events where machine_id='$M_OK'")"
is "all-dropped batch stored nothing" 0 "$(q "select count(*) from events where machine_id='$M_DROP'")"
is "…and no machine_days row for it" 0 "$(q "select count(*) from machine_days where machine_id='$M_DROP'")"
is "envelope columns land in their own columns" "darwin|arm64|22|0|1.5.0" \
"$(q "select os||'|'||arch||'|'||node_major||'|'||ci||'|'||codegraph_version from events where machine_id='$M_OK' limit 1")"
is "day derived from the client ts" "2026-07-27" \
"$(q "select day from events where machine_id='$M_OK' and event='install'")"
is "day falls back to received_at when ts is absent" "$TODAY" \
"$(q "select day from events where machine_id='$M_OK' and event='usage_rollup'")"
is "ts is NULL when the client sent none" 1 \
"$(q "select ts is null from events where machine_id='$M_OK' and event='usage_rollup'")"
is "allowlisted props stored" "local|fresh|2" \
"$(q "select json_extract(props,'\$.scope')||'|'||json_extract(props,'\$.kind')||'|'||json_array_length(props,'\$.targets') from events where machine_id='$M_OK' and event='install'")"
is "unknown prop stripped" 0 \
"$(q "select count(*) from events where machine_id='$M_OK' and props like '%strip me%'")"
is "malformed enum prop stripped" 0 \
"$(q "select count(*) from events where machine_id='$M_OK' and props like '%bogus-bucket%'")"
is "path-shaped prop stripped" 0 \
"$(q "select count(*) from events where machine_id='$M_OK' and props like '%/Users/%'")"
is "unknown envelope field stored nowhere" 0 \
"$(q "select count(*) from events where props like '%must not be stored%'")"
# The valid batch mixes ts-dated events (2026-07-27) with an undated rollup (today),
# so it legitimately spans two days and must produce a machine_days row for each.
is "machine_days: one row per distinct day in the batch" 2 \
"$(q "select count(*) from machine_days where machine_id='$M_OK'")"
is "machine_days: non-ci machine is production" 1 \
"$(q "select min(prod) from machine_days where machine_id='$M_OK'")"
is "machine_days: a later non-ci batch flips the day to production" 1 \
"$(q "select prod from machine_days where machine_id='$M_CI'")"
is "machine_days: each backdated batch gets its own day" "2026-07-20,2026-07-27" \
"$(q "select group_concat(day) from (select day from machine_days where machine_id='$M_BACK' order by day)")"
is "machine_first_seen recorded" "2026-07-27" "$(q "select first_day from machine_first_seen where machine_id='$M_OK'")"
is "machine_first_seen only moves earlier" "2026-07-20" \
"$(q "select first_day from machine_first_seen where machine_id='$M_BACK'")"
echo
echo "$pass passed, $fail failed"
[ "$fail" -eq 0 ]
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# End-to-end check of the nightly rollup + retention purge against a real
# `wrangler dev` and the local D1 state.
#
# Seeds three synthetic days of events straight into local D1 (the ingest path clamps
# client timestamps to the last 30 days, so backdating far enough to exercise the purge
# has to bypass it), drives the rollup through the admin endpoint and the cron handler,
# then inspects what actually landed against hand-computed numbers.
#
# What it pins:
# * rollup numbers match the events they came from, including the two that are easy
# to get wrong — usage_rollup SUMs its `count` prop, and array props unnest
# * running a day twice changes nothing (idempotent upserts, no double counting)
# * ?reset=1 drops stale rollup rows on a live day and REFUSES to blank a day whose
# raw events are already purged
# * the purge deletes only rows past the window, and leaves machine_days /
# machine_first_seen alone
# * /admin/rollup does not exist without ADMIN_TOKEN, and rejects a wrong one
#
# Re-runnable: it wipes its own synthetic days first, and they are chosen to sit
# outside the cron's 3-day lookback so the nightly run never rewrites them.
#
# npm run smoke:rollup # or: ROLLUP_PORT=8792 ./scripts/smoke-rollup.sh
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${ROLLUP_PORT:-8788}"
BASE="http://127.0.0.1:$PORT"
DB=codegraph-telemetry
TOKEN=smoke-admin-token
SEED_SQL=/tmp/cg-smoke-rollup-seed.sql
LOG=/tmp/cg-smoke-rollup.log
pass=0; fail=0
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
bad() { fail=$((fail + 1)); printf ' FAIL %s — expected %s, got %s\n' "$1" "$2" "$3"; }
is() { [ "$2" = "$3" ] && ok "$1" || bad "$1" "$2" "$3"; }
day_ago() { node -e 'console.log(new Date(Date.now()-process.argv[1]*864e5).toISOString().slice(0,10))' "$1"; }
# Synthetic days. MAIN/RESET sit inside the 90-day retention window but outside the
# cron's 3-day lookback; OLD sits past the window so the purge takes it.
DAY_MAIN=$(day_ago 40)
DAY_RESET=$(day_ago 41)
DAY_OLD=$(day_ago 200)
CUTOFF=$(day_ago 90)
# First column of the first row of a query against the LOCAL D1 state.
q() {
npx wrangler d1 execute "$DB" --local --json --command "$1" 2>/dev/null |
node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
const r=JSON.parse(s.slice(s.indexOf("[")))[0]?.results?.[0];
console.log(r===undefined?"":String(Object.values(r)[0]));})'
}
# A daily_dim_counts cell as "count/machines" — "" when the row does not exist.
dim() { q "select count||'/'||machines from daily_dim_counts
where day='$1' and event='$2' and dim='$3' and value='$4'"; }
# POST /admin/rollup, printing the HTTP status.
roll() { curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?$1"; }
boot() { # extra wrangler dev args
npx wrangler dev --port "$PORT" "$@" >"$LOG" 2>&1 &
DEV_PID=$!
trap 'kill "$DEV_PID" 2>/dev/null || true; wait "$DEV_PID" 2>/dev/null || true' EXIT
local up=
for _ in $(seq 1 60); do
curl -sf -o /dev/null "$BASE/" && { up=1; break; }
kill -0 "$DEV_PID" 2>/dev/null || { echo "wrangler dev died:"; cat "$LOG"; exit 1; }
sleep 1
done
[ -n "$up" ] || { echo "worker never came up:"; cat "$LOG"; exit 1; }
# If wrangler could not bind the port, something else answers every probe and the
# whole run silently grades a different server. Check who picked up.
case "$(curl -s "$BASE/")" in
*'codegraph anonymous-telemetry ingest'*) : ;;
*) echo "port $PORT is serving something else — set ROLLUP_PORT to a free one"; exit 1 ;;
esac
}
shutdown() {
kill "$DEV_PID" 2>/dev/null || true
wait "$DEV_PID" 2>/dev/null || true
trap - EXIT
sleep 1 # let miniflare release the local sqlite file
}
# ---------------------------------------------------------------------------
# Seed
# ---------------------------------------------------------------------------
echo "applying migrations to local D1"
npx wrangler d1 migrations apply "$DB" --local >/dev/null 2>&1
echo "seeding $DAY_MAIN / $DAY_RESET / $DAY_OLD"
node -e '
const [main, reset, old, seedFile] = process.argv.slice(1);
const sq = (v) => `'"'"'${String(v).replace(/'"'"'/g, "'"'"''"'"'")}'"'"'`;
const M = ["11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222",
"33333333-3333-4333-8333-333333333333", "44444444-4444-4444-8444-444444444444",
"99999999-9999-4999-8999-999999999999"];
const out = [];
// Re-runnable: every table this script touches, scoped to its own synthetic days.
for (const t of ["events", "daily_event_counts", "daily_dim_counts", "daily_machines", "machine_days"]) {
out.push(`DELETE FROM ${t} WHERE day IN (${[main, reset, old].map(sq).join(", ")});`);
}
out.push(`DELETE FROM machine_first_seen WHERE machine_id IN (${M.map(sq).join(", ")});`);
// day, machine, event, os, arch, version, node_major, ci, props
const rows = [
[main, M[0], "install", "darwin", "arm64", "1.5.0", 22, 0, {targets:["claude","cursor"], scope:"local", kind:"fresh"}],
[main, M[0], "index", "darwin", "arm64", "1.5.0", 22, 0, {languages:["typescript","go"], file_count_bucket:"100-1k", duration_bucket:"10-60s"}],
[main, M[0], "usage_rollup", "darwin", "arm64", "1.5.0", 22, 0, {kind:"mcp_tool", name:"codegraph_explore", count:10, error_count:2, client_name:"Claude Code"}],
[main, M[1], "index", "darwin", "x64", "1.5.0", 20, 0, {languages:["typescript"], file_count_bucket:"1k-10k", duration_bucket:"10-60s"}],
[main, M[1], "usage_rollup", "darwin", "x64", "1.5.0", 20, 0, {kind:"mcp_tool", name:"codegraph_explore", count:5, error_count:0, client_name:"Cursor"}],
[main, M[2], "install", "linux", "x64", "1.4.1", 22, 1, {targets:["claude"], scope:"global", kind:"upgrade"}],
[main, M[2], "uninstall", "linux", "x64", "1.4.1", 22, 1, {targets:["claude"]}],
[reset, M[3], "index", "darwin", "arm64", "1.5.0", 22, 0, {languages:["python"], file_count_bucket:"<100", duration_bucket:"<10s"}],
[old, M[4], "install", "linux", "x64", "1.0.0", 20, 0, {targets:["codex"], scope:"local", kind:"fresh"}],
[old, M[4], "index", "linux", "x64", "1.0.0", 20, 0, {languages:["rust"], file_count_bucket:"<100", duration_bucket:"<10s"}],
];
for (const [day, m, event, os, arch, version, node, ci, props] of rows) {
out.push(`INSERT INTO events (received_at, ts, day, event, machine_id, codegraph_version, os, arch, node_major, ci, schema_version, props)
VALUES (${sq(day + "T12:00:00.000Z")}, ${sq(day + "T12:00:00.000Z")}, ${sq(day)}, ${sq(event)}, ${sq(m)},
${sq(version)}, ${sq(os)}, ${sq(arch)}, ${node}, ${ci}, 1, ${sq(JSON.stringify(props))});`);
}
// What the ingest path would have written alongside those events.
for (const [m, day, prod] of [[M[0], main, 1], [M[1], main, 1], [M[2], main, 0], [M[3], reset, 1], [M[4], old, 1]]) {
out.push(`INSERT INTO machine_days (machine_id, day, prod) VALUES (${sq(m)}, ${sq(day)}, ${prod});`);
out.push(`INSERT INTO machine_first_seen (machine_id, first_day) VALUES (${sq(m)}, ${sq(day)})
ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day);`);
}
// A rollup row from a dimension that no longer exists — only ?reset=1 should clear it.
out.push(`INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
VALUES (${sq(reset)}, ${sq("index")}, ${sq("obsolete_dim")}, ${sq("stale")}, 99, 99);`);
require("fs").writeFileSync(seedFile, out.join("\n"));
' "$DAY_MAIN" "$DAY_RESET" "$DAY_OLD" "$SEED_SQL"
npx wrangler d1 execute "$DB" --local --file "$SEED_SQL" >/dev/null
# ---------------------------------------------------------------------------
# The admin route does not exist without a token
# ---------------------------------------------------------------------------
echo
echo "admin route, no ADMIN_TOKEN configured"
boot
is "POST /admin/rollup → 404" 404 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
is "…even with a token header" 404 \
"$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
shutdown
# ---------------------------------------------------------------------------
# Drive the rollup
# ---------------------------------------------------------------------------
echo
echo "admin route, ADMIN_TOKEN configured"
boot --test-scheduled --var "ADMIN_TOKEN:$TOKEN"
is "no token → 401" 401 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/rollup")"
is "wrong token → 401" 401 \
"$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'x-admin-token: nope' "$BASE/admin/rollup")"
is "GET → 405" 405 "$(curl -s -o /dev/null -w '%{http_code}' -H "x-admin-token: $TOKEN" "$BASE/admin/rollup")"
is "impossible day → 400" 400 "$(roll 'day=2026-02-31')"
is "malformed day → 400" 400 "$(roll 'day=yesterday')"
is "days out of range → 400" 400 "$(roll "day=$DAY_MAIN&days=99")"
echo
echo "rollup"
is "rollup $DAY_MAIN → 200" 200 "$(roll "day=$DAY_MAIN")"
is "rollup $DAY_MAIN again → 200" 200 "$(roll "day=$DAY_MAIN")"
is "rollup $DAY_OLD, whose events are still there → 200" 200 "$(roll "day=$DAY_OLD")"
is "rollup $DAY_RESET with reset → 200" 200 "$(roll "day=$DAY_RESET&reset=1")"
# The cron body: rolls up the last three days and purges everything past the window.
is "cron trigger → 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/__scheduled?cron=30+0+*+*+*")"
sleep 2
# Rolling a purged day with reset=1 must NOT blank the rollups it already has: past
# the window the reset is ignored, so the delete-then-rebuild can't find zero events.
is "rollup $DAY_OLD after the purge, with reset → 200" 200 "$(roll "day=$DAY_OLD&reset=1")"
is "…and reports the reset it refused to run" "[\"$DAY_OLD\"]" \
"$(curl -s -X POST -H "x-admin-token: $TOKEN" "$BASE/admin/rollup?day=$DAY_OLD&reset=1" |
node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.stringify(JSON.parse(s).reset_ignored)))')"
shutdown
# ---------------------------------------------------------------------------
# What actually landed — every number below is hand-computed from the seed above
# ---------------------------------------------------------------------------
echo
echo "daily_machines"
is "3 machines, 2 of them production (one is ci)" "3/2" \
"$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_MAIN'")"
echo
echo "daily_event_counts"
is "install: 2 events from 2 machines" "2/2" \
"$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='install'")"
is "index: 2 events from 2 machines" "2/2" \
"$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
is "uninstall: 1 event from 1 machine" "1/1" \
"$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='uninstall'")"
# The one that is easy to get wrong: 2 rows carrying count 10 and 5 is 15 tool calls.
is "usage_rollup: SUMs the count prop (10+5), not the rows" "15/2" \
"$(q "select count||'/'||machines from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
is "one row per event type" 4 "$(q "select count(*) from daily_event_counts where day='$DAY_MAIN'")"
echo
echo "daily_dim_counts"
is "os / index / darwin" "2/2" "$(dim "$DAY_MAIN" index os darwin)"
is "os / usage_rollup / darwin sums counts" "15/2" "$(dim "$DAY_MAIN" usage_rollup os darwin)"
is "arch / uninstall / x64" "1/1" "$(dim "$DAY_MAIN" uninstall arch x64)"
is "codegraph_version / index / 1.5.0" "2/2" "$(dim "$DAY_MAIN" index codegraph_version 1.5.0)"
is "node_major / install / 22 (stored as text)" "2/2" "$(dim "$DAY_MAIN" install node_major 22)"
is "file_count_bucket / index / 100-1k" "1/1" "$(dim "$DAY_MAIN" index file_count_bucket 100-1k)"
is "duration_bucket / index / 10-60s" "2/2" "$(dim "$DAY_MAIN" index duration_bucket 10-60s)"
is "scope / install / global" "1/1" "$(dim "$DAY_MAIN" install scope global)"
is "kind / install / fresh" "1/1" "$(dim "$DAY_MAIN" install kind fresh)"
is "kind / usage_rollup / mcp_tool (same dim, other event)" "15/2" "$(dim "$DAY_MAIN" usage_rollup kind mcp_tool)"
is "name / usage_rollup / codegraph_explore" "15/2" "$(dim "$DAY_MAIN" usage_rollup name codegraph_explore)"
is "client_name / usage_rollup / Claude Code" "10/1" "$(dim "$DAY_MAIN" usage_rollup client_name 'Claude Code')"
# languages and targets are JSON arrays: one row per element, counted once per event.
is "language / index / typescript (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" index language typescript)"
is "language / index / go (unnested, 1 event)" "1/1" "$(dim "$DAY_MAIN" index language go)"
is "target / install / claude (unnested, 2 events)" "2/2" "$(dim "$DAY_MAIN" install target claude)"
is "target / install / cursor" "1/1" "$(dim "$DAY_MAIN" install target cursor)"
is "target / uninstall / claude" "1/1" "$(dim "$DAY_MAIN" uninstall target claude)"
# Only groups with at least one error are stored, so machines = machines that saw one.
is "name_error / usage_rollup / codegraph_explore" "2/1" "$(dim "$DAY_MAIN" usage_rollup name_error codegraph_explore)"
is "no dimension row for a machine with no errors" "" "$(dim "$DAY_MAIN" usage_rollup name_error nothing)"
is "40 dimension rows in total (no strays, no doubles)" 40 \
"$(q "select count(*) from daily_dim_counts where day='$DAY_MAIN'")"
# Independent of the hand-computed numbers: recompute two of them straight off `events`.
echo
echo "cross-check against the raw events"
is "machines matches count(distinct machine_id)" \
"$(q "select count(distinct machine_id) from events where day='$DAY_MAIN' and event='index'")" \
"$(q "select machines from daily_event_counts where day='$DAY_MAIN' and event='index'")"
is "usage count matches sum(props.count)" \
"$(q "select sum(json_extract(props,'\$.count')) from events where day='$DAY_MAIN' and event='usage_rollup'")" \
"$(q "select count from daily_event_counts where day='$DAY_MAIN' and event='usage_rollup'")"
echo
echo "reset"
is "?reset=1 drops a rollup row whose dimension no longer exists" 0 \
"$(q "select count(*) from daily_dim_counts where day='$DAY_RESET' and dim='obsolete_dim'")"
is "…and recomputes the day correctly" "1/1" "$(dim "$DAY_RESET" index language python)"
is "…leaving exactly the 7 dimensions that day has" 7 \
"$(q "select count(*) from daily_dim_counts where day='$DAY_RESET'")"
echo
echo "retention purge"
is "raw events past the window are gone" 0 "$(q "select count(*) from events where day='$DAY_OLD'")"
is "nothing older than the cutoff survives" 0 "$(q "select count(*) from events where day<'$CUTOFF'")"
is "events inside the window are untouched" 7 "$(q "select count(*) from events where day='$DAY_MAIN'")"
is "machine_days is NOT purged (retention cohorts need it)" 1 \
"$(q "select count(*) from machine_days where day='$DAY_OLD'")"
is "machine_first_seen is NOT purged" "$DAY_OLD" \
"$(q "select first_day from machine_first_seen where machine_id='99999999-9999-4999-8999-999999999999'")"
echo
echo "rollups outlive the events they came from"
is "daily_event_counts survives the purge" "1/1" \
"$(q "select count||'/'||machines from daily_event_counts where day='$DAY_OLD' and event='index'")"
is "daily_dim_counts survives the purge" "1/1" "$(dim "$DAY_OLD" index language rust)"
is "…all 14 rows of it, even after a reset run over the purged day" 14 \
"$(q "select count(*) from daily_dim_counts where day='$DAY_OLD'")"
is "daily_machines is still rebuilt for a purged day (machine_days survives)" "1/1" \
"$(q "select machines||'/'||prod_machines from daily_machines where day='$DAY_OLD'")"
echo
echo "$pass passed, $fail failed"
[ "$fail" -eq 0 ]
+10
View File
@@ -0,0 +1,10 @@
/**
* Secrets are set with `wrangler secret put`, so they are deliberately absent from
* wrangler.jsonc (this repo is public) and `wrangler types` cannot see them. Declared
* here by interface merging so the worker type-checks with or without a local
* `.dev.vars`. Anything listed here may be missing at runtime check before use.
*/
interface Env {
/** Shared secret for `POST /admin/rollup`. Unset ⇒ the route does not exist (404). */
ADMIN_TOKEN: string;
}
+137 -36
View File
@@ -3,15 +3,22 @@
*
* This file is public on purpose: it is the exact code that receives codegraph's
* anonymous usage telemetry, so anyone can audit what is (and is not) stored.
* The schema contract lives in docs/design/telemetry.md.
* The schema contract lives in docs/design/telemetry.md; the storage schema the
* complete list of what is kept is migrations/0001_init.sql.
*
* Guarantees enforced here:
* - strict allowlist: unknown events are dropped, unknown properties are stripped
* - the client IP is never read, logged, or forwarded
* - the client IP is never read, logged, or stored
* - accepted events land in our own Cloudflare D1 database and are never forwarded
* to a third-party analytics vendor this worker makes no outbound requests
* - per-machine rate limiting, bounded body/batch sizes
* - forwarding happens off the response path (ctx.waitUntil); bodies are never logged
* - the write happens off the response path (ctx.waitUntil); bodies are never logged
* - raw events expire: a nightly cron rolls each day up into anonymous daily counts
* and then deletes the rows behind it (rollup.ts)
*/
import { handleAdminRollup, retentionDays, runNightly } from './rollup';
const MAX_BODY_BYTES = 64 * 1024;
const MAX_EVENTS_PER_BATCH = 100;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -20,13 +27,25 @@ const TOKEN_RE = /^[A-Za-z0-9_.:+-]+$/;
// Human-ish labels: MCP clientInfo names like "Claude Code", "cursor-vscode/1.2".
const LABEL_RE = /^[A-Za-z0-9_.:+/ @()-]+$/;
const INFO_TEXT = `codegraph anonymous-telemetry ingest.
const infoText = (keepDays: number): string => `codegraph anonymous-telemetry ingest.
What gets collected (and what never does) is documented field-by-field:
https://github.com/colbymchenry/codegraph/blob/main/docs/design/telemetry.md
This endpoint's full source:
https://github.com/colbymchenry/codegraph/tree/main/telemetry-worker
Guarantees: no code, file paths, repo/file/symbol names, or query strings are ever
sent; the client IP is never read or stored; the machine ID is a random UUID the
client mints locally and can delete at any time. Accepted events are stored in our
own database on Cloudflare (D1) and are never forwarded to any third-party analytics
vendor. The stored schema is the complete list of what is kept:
https://github.com/colbymchenry/codegraph/blob/main/telemetry-worker/migrations/0001_init.sql
Individual events are deleted after ${keepDays} days. What outlives them: anonymous
daily totals (counts per day of things like operating system, version and language),
and which days each machine ID was active, so returning-user numbers survive. No event
details, and still nothing that identifies a person or a codebase.
Disable any time: codegraph telemetry off | CODEGRAPH_TELEMETRY=0 | DO_NOT_TRACK=1
`;
@@ -117,11 +136,17 @@ const ENVELOPE_PROPS: Record<string, Sanitize> = {
schema_version: nonNegInt(99),
};
interface PostHogEvent {
/**
* One sanitized event, ready to become one `events` row. The envelope is NOT
* folded in here: it is identical for every event in a batch and lands in its own
* columns, so it is carried alongside (`common`) and bound at write time.
*/
interface StoredEvent {
event: string;
distinct_id: string;
timestamp?: string;
properties: JsonObject;
/** Clamped ISO 8601 UTC; absent when the client sent none or sent nonsense. */
ts?: string;
/** Event-specific props only — stored as the `props` JSON column. */
props: JsonObject;
}
function clampTimestamp(v: unknown): string | undefined {
@@ -134,7 +159,7 @@ function clampTimestamp(v: unknown): string | undefined {
return new Date(t).toISOString();
}
function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): PostHogEvent | null {
function sanitizeEvent(raw: unknown): StoredEvent | null {
if (typeof raw !== 'object' || raw === null) return null;
const e = raw as JsonObject;
if (typeof e.event !== 'string') return null;
@@ -151,36 +176,94 @@ function sanitizeEvent(raw: unknown, machineId: string, common: JsonObject): Pos
if (!(req in props)) return null;
}
const out: PostHogEvent = {
event: e.event,
distinct_id: machineId,
properties: {
...props,
...common,
// Anonymous events: no person profiles, no geo enrichment.
$process_person_profile: false,
$geoip_disable: true,
$lib: 'codegraph-telemetry-worker',
},
};
const out: StoredEvent = { event: e.event, props };
const ts = clampTimestamp(e.ts);
if (ts !== undefined) out.timestamp = ts;
if (ts !== undefined) out.ts = ts;
return out;
}
async function forwardToPostHog(env: Env, batch: PostHogEvent[]): Promise<void> {
/**
* Re-narrow a sanitized envelope value for binding. The ENVELOPE_PROPS sanitizers
* already guarantee these types; these just turn "absent" into a NULL bind.
*/
const asText = (v: unknown): string | null => (typeof v === 'string' ? v : null);
const asInt = (v: unknown): number | null => (typeof v === 'number' ? v : null);
const asFlag = (v: unknown): number | null => (typeof v === 'boolean' ? (v ? 1 : 0) : null);
const INSERT_EVENT = `INSERT INTO events (
received_at, ts, day, event, machine_id,
codegraph_version, os, arch, node_major, ci, schema_version, props
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
// prod = 0 only if EVERY event this machine sent that day carried ci = 1, so a later
// non-CI batch flips the day to production and never back (max, not overwrite).
const UPSERT_MACHINE_DAY = `INSERT INTO machine_days (machine_id, day, prod) VALUES (?, ?, ?)
ON CONFLICT (machine_id, day) DO UPDATE SET prod = max(machine_days.prod, excluded.prod)`;
// A late-arriving offline buffer can move a machine's first day earlier, never later.
const UPSERT_FIRST_SEEN = `INSERT INTO machine_first_seen (machine_id, first_day) VALUES (?, ?)
ON CONFLICT (machine_id) DO UPDATE SET first_day = min(machine_first_seen.first_day, excluded.first_day)`;
/**
* Persist a sanitized batch: one `events` row per event, plus the machine×day and
* first-seen bookkeeping the dashboard's retention/activation panels need. One D1
* `batch()` = one implicit transaction = one round trip.
*
* Fail-silent by design: the client treats every response as final and never retries,
* so a failed write loses a datapoint rather than costing availability. The error is
* logged (Workers Logs) with counts only never the payload.
*/
async function writeToD1(
env: Env,
machineId: string,
common: JsonObject,
batch: StoredEvent[],
): Promise<void> {
try {
const res = await fetch(`${env.POSTHOG_HOST}/batch/`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ api_key: env.POSTHOG_KEY, batch }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
console.error(JSON.stringify({ msg: 'posthog forward failed', status: res.status, events: batch.length }));
const receivedAt = new Date().toISOString();
const insertEvent = env.DB.prepare(INSERT_EVENT);
const stmts: D1PreparedStatement[] = [];
// Envelope columns are identical for every row in the batch.
const envelopeCols = [
asText(common.codegraph_version),
asText(common.os),
asText(common.arch),
asInt(common.node_major),
asFlag(common.ci),
asInt(common.schema_version),
] as const;
// A batch can span days (offline buffers hold completed-day rollups), so
// machine_days gets one row per distinct day rather than one per batch.
const days = new Set<string>();
for (const e of batch) {
const day = (e.ts ?? receivedAt).slice(0, 10);
days.add(day);
stmts.push(
insertEvent.bind(
receivedAt,
e.ts ?? null,
day,
e.event,
machineId,
...envelopeCols,
JSON.stringify(e.props),
),
);
}
const prod = common.ci === true ? 0 : 1;
const upsertDay = env.DB.prepare(UPSERT_MACHINE_DAY);
for (const day of days) stmts.push(upsertDay.bind(machineId, day, prod));
const firstDay = [...days].sort()[0];
if (firstDay !== undefined) {
stmts.push(env.DB.prepare(UPSERT_FIRST_SEEN).bind(machineId, firstDay));
}
await env.DB.batch(stmts);
} catch (err) {
console.error(JSON.stringify({ msg: 'posthog forward error', err: String(err), events: batch.length }));
console.error(JSON.stringify({ msg: 'd1 write failed', err: String(err), events: batch.length }));
}
}
@@ -190,7 +273,13 @@ export default {
const url = new URL(request.url);
if (request.method === 'GET' && url.pathname === '/') {
return new Response(INFO_TEXT, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
return new Response(infoText(retentionDays(env)), {
headers: { 'content-type': 'text/plain; charset=utf-8' },
});
}
// Backfill/repair for the nightly rollup. 404s unless ADMIN_TOKEN is configured.
if (url.pathname === '/admin/rollup') {
return await handleAdminRollup(request, env, url);
}
if (url.pathname !== '/v1/events') {
return new Response('not found\n', { status: 404 });
@@ -240,14 +329,16 @@ export default {
}
const rawEvents = Array.isArray(body.events) ? body.events.slice(0, MAX_EVENTS_PER_BATCH) : [];
const batch: PostHogEvent[] = [];
const batch: StoredEvent[] = [];
for (const raw of rawEvents) {
const sanitized = sanitizeEvent(raw, machineId, common);
const sanitized = sanitizeEvent(raw);
if (sanitized) batch.push(sanitized);
}
// Nothing survived the allowlist ⇒ nothing is written at all, not even the
// machine×day bookkeeping: those tables must only ever describe stored events.
if (batch.length > 0) {
ctx.waitUntil(forwardToPostHog(env, batch));
ctx.waitUntil(writeToD1(env, machineId, common, batch));
}
// Accepted (including "everything was dropped by the allowlist") — the
// client treats every response as final and never retries.
@@ -257,4 +348,14 @@ export default {
return new Response('internal error\n', { status: 500 });
}
},
/**
* Nightly (00:30 UTC, see wrangler.jsonc): roll the completed day up into the
* daily_* tables and purge raw events past the retention window. Awaited rather
* than backgrounded so a failure marks the cron run failed everything it does is
* an idempotent upsert or a bounded delete, so the retry is safe.
*/
async scheduled(event, env): Promise<void> {
await runNightly(env, event.scheduledTime);
},
} satisfies ExportedHandler<Env>;
+397
View File
@@ -0,0 +1,397 @@
/**
* codegraph telemetry nightly rollup + raw-event retention purge.
*
* Public for the same reason the ingest path is: this is every read and every write
* we make over the stored events, including the one that deletes them.
*
* Two jobs, both driven by the cron trigger in wrangler.jsonc (00:30 UTC daily):
*
* 1. ROLL UP the just-completed UTC day into `daily_machines`, `daily_event_counts`
* and `daily_dim_counts` plus the two days before it, because clients buffer
* offline and ship completed-day rollups late, so a day keeps growing after it
* ends. Every write is an upsert that OVERWRITES the recomputed value rather than
* adding to it, so re-running a day is a no-op and never double-counts.
*
* 2. PURGE raw `events` past the retention window, in bounded batches. Rollups are
* kept forever, so only ad-hoc drill-down has a horizon; `machine_days` and
* `machine_first_seen` are never purged, because retention cohorts need the full
* history and they are two orders of magnitude smaller than the raw rows.
*
* `POST /admin/rollup` re-runs a day (or a short range) on demand for backfill and
* repair, guarded by the ADMIN_TOKEN secret. Like everything else here it makes no
* outbound requests the only thing this worker talks to is its own D1 database.
*/
/** Raw-event retention when RETENTION_DAYS is unset or nonsense. Storage-bound — see README. */
export const DEFAULT_RETENTION_DAYS = 90;
/** The just-completed day, plus the two before it (late offline buffers). */
export const ROLLUP_LOOKBACK_DAYS = 3;
/** Widest range one manual /admin/rollup call will attempt. */
export const MAX_MANUAL_DAYS = 31;
/** Rows per purge DELETE — bounded so one statement stays well inside D1's limits. */
const PURGE_BATCH_ROWS = 5_000;
/** Ceiling on one night's deletions (≈1.5 days of ingest at current volume). */
const PURGE_MAX_BATCHES = 60;
const DAY_MS = 86_400_000;
/** UTC YYYY-MM-DD — the key every event, rollup and chart is bucketed on. */
export function utcDay(atMs: number): string {
return new Date(atMs).toISOString().slice(0, 10);
}
/** Rejects both the wrong shape and impossible dates (`2026-02-31` round-trips as `2026-03-03`). */
export function isValidDay(day: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
const t = Date.parse(`${day}T00:00:00Z`);
return Number.isFinite(t) && utcDay(t) === day;
}
/** Configured retention, clamped to something sane; falls back to the default. */
export function retentionDays(env: Env): number {
const raw = Number(env.RETENTION_DAYS);
return Number.isInteger(raw) && raw >= 1 && raw <= 3650 ? raw : DEFAULT_RETENTION_DAYS;
}
/** Oldest day kept: everything strictly before this is purged. */
export function retentionCutoff(atMs: number, keepDays: number): string {
return utcDay(atMs - keepDays * DAY_MS);
}
// ---------------------------------------------------------------------------
// The rollup statements
// ---------------------------------------------------------------------------
// One `INSERT … SELECT … ON CONFLICT DO UPDATE` per table or dimension: the whole
// aggregation happens inside D1, so a day rolls up in one round trip and no event
// row ever crosses the wire. Each takes exactly one bound parameter — the day.
//
// Adding a breakdown is a line in ROLLUP_STATEMENTS, never a migration — that is
// what the generic (dim, value) shape of daily_dim_counts buys.
/**
* A group's event volume. For install/index/uninstall one row is one event, but a
* usage_rollup row is a counter the client pre-aggregated (one per machine × day ×
* tool), so its `count` prop is what has to be summed counting rows there would
* silently report "machines that used the tool" and undercount by an order of magnitude.
*/
const COUNT = `CASE WHEN e.event = 'usage_rollup'
THEN sum(coalesce(json_extract(e.props, '$.count'), 0))
ELSE count(*) END`;
const DIM_CONFLICT = `ON CONFLICT (day, event, dim, value) DO UPDATE
SET count = excluded.count, machines = excluded.machines`;
const prop = (name: string): string => `json_extract(e.props, '$.${name}')`;
const quoted = (values: readonly string[]): string => values.map((v) => `'${v}'`).join(', ');
const onlyEvents = (...events: readonly string[]): string => ` AND e.event IN (${quoted(events)})`;
/** One dimension whose value is a scalar column or a scalar prop. */
function dimStatement(dim: string, value: string, where = ''): string {
return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT e.day, e.event, '${dim}', CAST(${value} AS TEXT), ${COUNT}, count(DISTINCT e.machine_id)
FROM events e
WHERE e.day = ? AND ${value} IS NOT NULL AND ${value} <> ''${where}
GROUP BY e.day, e.event, ${value}
${DIM_CONFLICT}`;
}
/**
* One dimension unnested from a JSON array prop one row per element, so an index
* of a TypeScript+Go repo counts once under each language. `json_each` over a path
* the props do not have yields no rows, which is exactly the wanted behaviour for
* events that omit the array.
*/
function arrayDimStatement(dim: string, path: string, events: readonly string[]): string {
return `INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT e.day, e.event, '${dim}', CAST(j.value AS TEXT), count(*), count(DISTINCT e.machine_id)
FROM events e, json_each(e.props, '${path}') j
WHERE e.day = ? AND e.event IN (${quoted(events)}) AND j.value <> ''
GROUP BY e.day, e.event, j.value
${DIM_CONFLICT}`;
}
/**
* Rebuilt from `machine_days`, not from `events`: that table is never purged, so this
* number stays right for days whose raw rows are long gone. `prod` is already the
* per-machine-day maximum the ingest path maintains (0 only if every event that
* machine sent that day carried ci = 1).
*
* So this is also the one rollup that can still be rebuilt for a day whose raw events
* are long gone.
*/
const DAILY_MACHINES = `INSERT INTO daily_machines (day, machines, prod_machines)
SELECT day, count(*), coalesce(sum(prod), 0) FROM machine_days WHERE day = ? GROUP BY day
ON CONFLICT (day) DO UPDATE
SET machines = excluded.machines, prod_machines = excluded.prod_machines`;
const ROLLUP_STATEMENTS: readonly string[] = [
DAILY_MACHINES,
`INSERT INTO daily_event_counts (day, event, count, machines)
SELECT e.day, e.event, ${COUNT}, count(DISTINCT e.machine_id)
FROM events e
WHERE e.day = ?
GROUP BY e.day, e.event
ON CONFLICT (day, event) DO UPDATE
SET count = excluded.count, machines = excluded.machines`,
// Envelope dimensions — every event type carries them.
dimStatement('os', 'e.os'),
dimStatement('arch', 'e.arch'),
dimStatement('codegraph_version', 'e.codegraph_version'),
dimStatement('node_major', 'e.node_major'),
// Event-specific scalar props.
dimStatement('file_count_bucket', prop('file_count_bucket'), onlyEvents('index')),
dimStatement('duration_bucket', prop('duration_bucket'), onlyEvents('index')),
dimStatement('scope', prop('scope'), onlyEvents('install')),
// `kind` is fresh/upgrade/reinstall on install and mcp_tool/cli_command on
// usage_rollup; `event` is part of the primary key, so both live here without colliding.
dimStatement('kind', prop('kind'), onlyEvents('install', 'usage_rollup')),
dimStatement('name', prop('name'), onlyEvents('usage_rollup')),
dimStatement('client_name', prop('client_name'), onlyEvents('usage_rollup')),
// Array props.
arrayDimStatement('language', '$.languages', ['index']),
arrayDimStatement('target', '$.targets', ['install', 'uninstall']),
// Errors per tool/command. Not in the migration's documented dim list because dims
// are a cron concern rather than a schema one, but rolled up because it is the one
// usage number that is gone for good after the purge. Only groups with at least one
// error are stored, so `count` is errors and `machines` is the machines that saw one
// — NOT the machines that ran the tool (that is the `name` dim).
`INSERT INTO daily_dim_counts (day, event, dim, value, count, machines)
SELECT e.day, e.event, 'name_error', CAST(${prop('name')} AS TEXT),
sum(${prop('error_count')}), count(DISTINCT e.machine_id)
FROM events e
WHERE e.day = ? AND e.event = 'usage_rollup'
AND ${prop('name')} IS NOT NULL AND coalesce(${prop('error_count')}, 0) > 0
GROUP BY e.day, e.event, ${prop('name')}
${DIM_CONFLICT}`,
];
/** Rollup tables derived from raw `events` — the ones `reset` wipes before recomputing. */
const EVENT_DERIVED_TABLES = ['daily_event_counts', 'daily_dim_counts'] as const;
// ---------------------------------------------------------------------------
// Running it
// ---------------------------------------------------------------------------
export interface DayResult {
day: string;
/** Rollup rows written for the day. */
rows: number;
/** Day is past the retention window — a `reset` on it is ignored (see below). */
pastRetention: boolean;
}
/**
* Recompute every rollup for one UTC day. One D1 `batch()` = one implicit
* transaction, so a day is either fully recomputed or not touched at all.
*
* Plain (upsert-only) runs are safe on any day: a day whose raw events are already
* purged selects nothing, so nothing is written and the rollups it earned while the
* events were still around survive untouched. That is what keeps rollups permanent.
*
* `reset` drops the day's event-derived rollup rows first instead of upserting over
* them repair for when the dimension list itself changes and a value that no longer
* exists would otherwise linger. It is IGNORED past the retention window, where it
* would delete rows and then find no events to rebuild them from: silently blanking a
* real day is the one irreversible thing this file could do.
*/
export async function rollupDay(
env: Env,
day: string,
opts: { cutoff: string; reset?: boolean },
): Promise<DayResult> {
const pastRetention = day < opts.cutoff;
const statements: D1PreparedStatement[] = [];
if (opts.reset && !pastRetention) {
for (const table of EVENT_DERIVED_TABLES) {
statements.push(env.DB.prepare(`DELETE FROM ${table} WHERE day = ?`).bind(day));
}
}
for (const sql of ROLLUP_STATEMENTS) {
statements.push(env.DB.prepare(sql).bind(day));
}
const results = await env.DB.batch(statements);
const rows = results.reduce((total, r) => total + (r.meta?.changes ?? 0), 0);
return { day, rows, pastRetention };
}
export interface PurgeResult {
/** Everything strictly before this day was deleted. */
cutoff: string;
deleted: number;
batches: number;
/** Hit the per-run batch ceiling — more rows are still due, next run takes them. */
capped: boolean;
}
/**
* Delete raw events older than the window, oldest first, in bounded batches.
* `id` is a rowid alias and the purge only ever removes the oldest rows, so the
* keyset subquery stays a cheap index range scan on (day, event).
*/
export async function purgeOldEvents(env: Env, cutoff: string): Promise<PurgeResult> {
const del = env.DB.prepare(
`DELETE FROM events WHERE id IN (SELECT id FROM events WHERE day < ? LIMIT ${PURGE_BATCH_ROWS})`,
);
let deleted = 0;
for (let batch = 1; batch <= PURGE_MAX_BATCHES; batch++) {
const { meta } = await del.bind(cutoff).run();
const removed = meta?.changes ?? 0;
deleted += removed;
if (removed < PURGE_BATCH_ROWS) return { cutoff, deleted, batches: batch, capped: false };
}
return { cutoff, deleted, batches: PURGE_MAX_BATCHES, capped: true };
}
/**
* The cron body: roll up the completed day and the two before it, then purge.
*
* Logs one line of counts never a day's contents, never a machine id. Throws if
* anything failed so the invocation is marked failed (and retried) rather than
* quietly skipping a day; every write here is idempotent, so a retry is safe.
*/
export async function runNightly(env: Env, atMs: number): Promise<void> {
const started = Date.now();
const keepDays = retentionDays(env);
const cutoff = retentionCutoff(atMs, keepDays);
const rolled: string[] = [];
const failed: string[] = [];
let rows = 0;
for (let back = 1; back <= ROLLUP_LOOKBACK_DAYS; back++) {
const day = utcDay(atMs - back * DAY_MS);
try {
rows += (await rollupDay(env, day, { cutoff })).rows;
rolled.push(day);
} catch (err) {
failed.push(day);
console.error(JSON.stringify({ msg: 'rollup day failed', day, err: String(err) }));
}
}
let purge: PurgeResult | null = null;
try {
purge = await purgeOldEvents(env, cutoff);
} catch (err) {
console.error(JSON.stringify({ msg: 'purge failed', cutoff, err: String(err) }));
}
console.log(
JSON.stringify({
msg: 'nightly rollup',
days: rolled,
rows,
failed: failed.length,
retention_days: keepDays,
purged_before: cutoff,
purged: purge?.deleted ?? null,
purge_batches: purge?.batches ?? null,
purge_capped: purge?.capped ?? null,
ms: Date.now() - started,
}),
);
if (failed.length > 0 || purge === null) {
throw new Error(`nightly rollup incomplete: ${failed.length} day(s) failed, purge ${purge ? 'ok' : 'failed'}`);
}
}
// ---------------------------------------------------------------------------
// POST /admin/rollup — manual backfill / repair
// ---------------------------------------------------------------------------
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json; charset=utf-8' },
});
/** Constant-time over digests, so neither the length nor a prefix of the token leaks. */
async function tokenMatches(provided: string, expected: string): Promise<boolean> {
const encoder = new TextEncoder();
const [a, b] = await Promise.all([
crypto.subtle.digest('SHA-256', encoder.encode(provided)),
crypto.subtle.digest('SHA-256', encoder.encode(expected)),
]);
return crypto.subtle.timingSafeEqual(a, b);
}
/**
* `POST /admin/rollup?day=YYYY-MM-DD[&days=N][&reset=1]`, header `x-admin-token`.
*
* Re-runs the rollup for `day` (default: yesterday), or for the `N` days ending on it.
* Exists so a backfill or a repair never needs a redeploy. It only ever recomputes
* aggregates from stored rows there is no path here that deletes raw events; the
* purge runs on the cron and nowhere else.
*/
export async function handleAdminRollup(request: Request, env: Env, url: URL): Promise<Response> {
// No secret configured ⇒ no admin surface at all, and nothing that hints there is one.
const expected = env.ADMIN_TOKEN;
if (typeof expected !== 'string' || expected.length === 0) {
return new Response('not found\n', { status: 404 });
}
if (request.method !== 'POST') {
return new Response('method not allowed\n', { status: 405, headers: { allow: 'POST' } });
}
if (!(await tokenMatches(request.headers.get('x-admin-token') ?? '', expected))) {
// Cap how fast the token can be guessed at. Only failures spend the budget, so a
// chunked backfill loop is never throttled. Best-effort and fails open like the
// ingest limiter — the token itself is the guard, this only slows a guesser down.
try {
const { success } = await env.ADMIN_RATE_LIMITER.limit({ key: 'admin' });
if (!success) return new Response('rate limited\n', { status: 429 });
} catch (err) {
console.error(JSON.stringify({ msg: 'rate limiter unavailable', err: String(err) }));
}
return new Response('unauthorized\n', { status: 401 });
}
const now = Date.now();
const day = url.searchParams.get('day') ?? utcDay(now - DAY_MS);
if (!isValidDay(day)) return json({ error: 'day must be YYYY-MM-DD' }, 400);
const requested = url.searchParams.get('days');
const span = requested === null ? 1 : Number(requested);
if (!Number.isInteger(span) || span < 1 || span > MAX_MANUAL_DAYS) {
return json({ error: `days must be an integer between 1 and ${MAX_MANUAL_DAYS}` }, 400);
}
const reset = url.searchParams.get('reset') === '1';
const cutoff = retentionCutoff(now, retentionDays(env));
const endMs = Date.parse(`${day}T00:00:00Z`);
const days: DayResult[] = [];
try {
for (let back = span - 1; back >= 0; back--) {
days.push(await rollupDay(env, utcDay(endMs - back * DAY_MS), { cutoff, reset }));
}
} catch (err) {
console.error(JSON.stringify({ msg: 'manual rollup failed', through: day, err: String(err) }));
return json({ error: 'rollup failed', through: day, completed: days }, 500);
}
const rows = days.reduce((total, d) => total + d.rows, 0);
// A day past the window kept its rollups but ignored the reset — say so rather than
// reporting a repair that did not happen.
const resetIgnored = reset ? days.filter((d) => d.pastRetention).map((d) => d.day) : [];
console.log(
JSON.stringify({
msg: 'manual rollup',
through: day,
days: span,
reset,
reset_ignored: resetIgnored.length,
rows,
ms: Date.now() - now,
}),
);
return json({ ok: true, through: day, retention_cutoff: cutoff, rows, reset_ignored: resetIgnored, days });
}
+34 -3
View File
@@ -1,5 +1,7 @@
// codegraph telemetry ingest see README.md and docs/design/telemetry.md.
// Secrets are NOT configured here: POSTHOG_KEY is set via `wrangler secret put POSTHOG_KEY`.
// Accepted events go straight into the bound D1 database and the worker makes no
// outbound requests. The only secret is ADMIN_TOKEN, which guards the manual rollup
// trigger (`wrangler secret put ADMIN_TOKEN`); leave it unset and that route 404s.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "codegraph-telemetry",
@@ -15,8 +17,30 @@
"observability": { "enabled": true, "head_sampling_rate": 1 },
// Non-secret config. Swap host here if the backend ever moves (EU, self-hosted).
"vars": { "POSTHOG_HOST": "https://us.i.posthog.com" },
// Nightly rollup + retention purge (src/rollup.ts). 00:30 UTC half an hour after
// the day it rolls up closed, so straggling writes for it have landed. It also
// re-runs the two days before that, because offline clients ship completed-day
// rollups late; the writes are idempotent upserts, so re-running is free.
"triggers": { "crons": ["30 0 * * *"] },
// How many days of RAW events are kept. Rollups are kept forever, so shortening
// this costs ad-hoc drill-back, never a chart. 90 is a storage limit, not a policy
// one: raw events grow 74 MB/day, so 90 days 6.7 GB against D1's 10 GB
// per-database cap the arithmetic is in migrations/0001_init.sql's footer.
// Measure real row size after cutover before widening it.
"vars": { "RETENTION_DAYS": 90 },
// Telemetry storage. Schema + the chart each table serves: migrations/0001_init.sql.
// Apply with `npm run db:migrate:local` (local state) / `npm run db:migrate` (remote).
// The admin dashboard worker binds this same database read-mostly, also as `DB`.
"d1_databases": [
{
"binding": "DB",
"database_name": "codegraph-telemetry",
"database_id": "5ed36dfb-d2d7-4e35-9e63-a1b99d0b1ed3",
"migrations_dir": "migrations"
}
],
// Per-machine_id rate limit. Legit clients flush a handful of times per day;
// 6/min absorbs install+index bursts while capping abuse.
@@ -25,6 +49,13 @@
"name": "MACHINE_RATE_LIMITER",
"namespace_id": "1001",
"simple": { "limit": 6, "period": 60 }
},
// POST /admin/rollup. The ADMIN_TOKEN secret is the real guard; this only caps
// how fast it can be guessed at, with enough room for a chunked backfill loop.
{
"name": "ADMIN_RATE_LIMITER",
"namespace_id": "1002",
"simple": { "limit": 10, "period": 60 }
}
]
}