Files
Colby Mchenry 49c11fc2e0 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>
2026-08-01 16:17:10 -05:00

16 KiB
Raw Permalink Blame History

Anonymous usage telemetry

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." Telemetry has to be designed so that sentence stays true and provable: a short, auditable list of anonymous counters, documented field-by-field, easy to turn off, and impossible to grow quietly. This doc is the contract; TELEMETRY.md (repo root, user-facing) restates it and the implementation must never collect anything not listed there.

Goals

Answer, in aggregate and anonymously:

  • How many machines actively use codegraph (daily/weekly), and how does that change?
  • Which agents drive usage (Claude Code, Cursor, Codex, opencode, …) — via MCP clientInfo.
  • Which install targets people pick, local vs global, fresh vs upgrade.
  • Which MCP tools and CLI commands get used, how often, and how often they error.
  • Which languages people index (prioritize extractor/framework work by real usage).
  • Version adoption speed, OS/arch/Node mix. (The SQLite backend is always the built-in node:sqlite now — there is no native-vs-wasm split left to measure.)

Non-goals / never collected

  • 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 — 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.
  • No telemetry from the codegraph-pro fork (see "codegraph-pro rule" below).

Principles

  1. The schema is the allowlist. Client sends only the events below; the ingest Worker validates against the same allowlist and drops anything else. Adding a field = PR that edits this doc + TELEMETRY.md + the Worker allowlist together.
  2. Telemetry may never cost the user anything: zero added latency on the MCP tool-call hot path (the repo's core invariant), zero new npm dependencies (global fetch, Node ≥18), zero bytes on stdout (stdio is the MCP protocol channel), zero retries, zero error noise. Every failure mode is silence.
  3. Off is off. When disabled, no process opens a socket to the telemetry endpoint — not even an "opted out" ping.
  4. First-party endpoint. Clients only ever talk to telemetry.getcodegraph.com. The URL baked into a published npm version POSTs there forever, so the domain must be ours; the backend behind it can change without a client release.

Events

Common envelope on every batch (computed once per process):

field example notes
machine_id b3a8… (UUIDv4) random, minted at first run, stored in global config
codegraph_version 0.9.12 from package.json
os / arch darwin / arm64 process.platform / process.arch
node_major 22 major only
ci false CI env var present
schema_version 2 bump when the schema changes (v2 dropped index.sqlite_backend)

Event types:

  • install — one per installer run. Props: targets (e.g. ["claude","cursor"]), scope (local/global), kind (fresh/upgrade/reinstall).
  • index — one per full index (init/index, not per sync). Props: languages (names only, e.g. ["typescript","go"]), file_count_bucket (<100, 100-1k, 1k-10k, 10k+), duration_bucket (<10s, 10-60s, 1-5m, 5m+).
  • 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 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 / noop-no-index / noop-unverified / noop-explore-keyword / noop-explore-token / noop-vocab-empty — decision names only, never prompt content. This is the gate's measured recall/precision funnel: a rising noop-* share against the high/medium tiers is the signal that the gate (keyword table or segment matching) is missing real questions. A high-* outcome means context was actually injected — a gate decision whose codegraph_explore errored or returned nothing records noop-explore-<trigger> instead (#1143), and a MEDIUM-eligible prompt hitting a not-yet-backfilled segment vocabulary records noop-vocab-empty rather than polluting noop-unverified (#1142).
  • uninstall — one per uninstall/uninit run (churn signal). Props: targets.

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.

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.

Resolution order (first match wins):

  1. DO_NOT_TRACK=1 (community standard — always honored) → off
  2. CODEGRAPH_TELEMETRY=0|1 → forced off/on for that process
  3. Global config ~/.codegraph/telemetry.json → stored user choice
  4. Default: on, gated by the first-run notice below

Surfaces:

  • Installer (interactive): a visible clack toggle in the existing prompt flow — "Share anonymous usage data? (no code, paths, or names — see TELEMETRY.md)" — default yes. Choice persisted with consent_source: "installer". Re-runs/upgrades respect the stored choice and don't re-ask.
  • Headless paths (npx codegraph init, MCP server — no TTY, never prompt): right before the first actual send (recording only buffers locally and stays silent — so the installer's explicit toggle always precedes any notice), print one line to stderr and record first_run_notice_shown: codegraph collects anonymous usage stats (no code or paths) — "codegraph telemetry off" or CODEGRAPH_TELEMETRY=0 disables. Details: TELEMETRY.md
  • CLI: codegraph telemetry status|on|off (status prints the machine ID, current state, and what decided it). Deleting ~/.codegraph/telemetry.json resets everything, including the machine ID.

~/.codegraph/telemetry.json:

{
  "enabled": true,
  "machine_id": "uuid-v4",
  "consent_source": "installer | default-notice | cli",
  "first_run_notice_shown": true,
  "updated_at": "2026-06-12T00:00:00Z"
}

(~/.codegraph/ is new — today nothing global exists. Coexists by filename if a user ever indexes $HOME itself, since per-project data lives in <project>/.codegraph/ with fixed other filenames.)

Client architecture

New module src/telemetry/ (single small module, no deps):

  • Counters in memory — recording a tool call/CLI command is an in-memory increment. Nothing on the hot path touches disk or network. MCP tool handlers call telemetry.count('mcp_tool', name, ok) and move on.
  • Buffer — counters persist (debounced, async) to ~/.codegraph/telemetry-queue.jsonl. Hard cap ~256 KB; on overflow drop oldest lines. Corrupt buffer → truncate, never throw.
  • Flush — many CLI actions end via process.exit(), where beforeExit never fires and async sends die, so the design is: a tiny synchronous append on process.on('exit') persists in-memory deltas (survives process.exit), and actual network sends happen opportunistically — at the start of long-running commands (init/index/sync/ uninit/upgrade), on an unref'd interval in the long-lived MCP server/daemon, and awaited-with-cap at the end of install/init/index/uninit where a second is invisible. Sends POST completed-day rollups + lifecycle events to https://telemetry.getcodegraph.com/v1/events with AbortSignal.timeout(1500), fire-and-forget: any response (or none) is final — no retry, no error surfaced. The queue is claimed by atomic rename so concurrent processes can't double-send (a crashed sender's claim merges back after an hour). CODEGRAPH_TELEMETRY_DEBUG=1 echoes payloads to stderr for development.
  • Offline / air-gapped: flush fails silently, buffer stays within cap, steady state is a bounded file and zero noise.

Ingest endpoint (Cloudflare Worker)

telemetry.getcodegraph.com → small Worker living at telemetry-worker/ in this repo — public on purpose, so anyone can audit exactly what the endpoint stores. It ships nowhere 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 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, 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.

Admin dashboard (Cloudflare Worker)

stats.getcodegraph.com → a second Worker at 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.

  • 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)

The private codegraph-pro fork ships inside customer containers whose guarantee is "nothing leaves the box" — including telemetry. In the fork, telemetry must be default-off and not enableable by the installer (compile-time constant or stripped module), and the container sets CODEGRAPH_TELEMETRY=0 as belt-and-braces. This rule lives in the fork's 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), 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.

Tests (no DB mocking, per repo convention; fetch mocked at globalThis.fetch): consent precedence (env > config > default), off ⇒ zero fetch calls, rollup aggregation across days, buffer cap + corrupt-buffer recovery, no-stdout invariant under MCP transport, flush abort honors timeout, installer toggle persists + re-run doesn't re-ask (__tests__/installer-targets.test.ts per house rules).

Open questions

  • Exact installer copy / notice wording — maintainer call before release.
  • uninstall event: keep or drop? (Honest churn signal vs. "pinging on the way out" optics.)
  • CI events are kept (tagged ci: true) because engine-in-CI is a real usage mode — revisit if it ever dominates volume.