mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
a192df40f9
Three bugs in the self-hosted telemetry services (telemetry-worker/ and
telemetry-dashboard/), reported against 6560052a and all still on main.
1. An index run that uploads late never counted toward activation. The
dashboard's funnel reads machine_first_seen.first_index_day, which only the
nightly rollup wrote, and the rollup re-rolls just the last three days plus
days with no daily_machines row. Ingest accepts timestamps up to 30 days old
and the client keeps an event's original timestamp when it re-queues it, so
an index event 4+ days late landed on a day nothing revisited and
first_index_day stayed NULL. The ingest upsert of machine_first_seen now
lowers first_index_day the way it lowers first_day:
coalesce(min(old, new), old, new), because SQLite's min() is NULL if either
side is. It is the same statement and row, with no new index and no
migration (the column exists since 0002). The rollup still re-derives it
from raw events, which covers events stored before this change.
2. ingest_stalled read only max(day) FROM events, but usage counters have
lived in usage_daily since 0003, so a day with usage and no lifecycle events
read as "The ingest worker ... is not storing anything". /api/meta now also
reads max(day) FROM usage_daily (a primary-key lookup) and returns
latest_usage_day and latest_ingest_day. Ingest counts as stalled only when
there is no lifecycle event from yesterday or later and no usage counter
from the day before yesterday or later, because clients upload a day's
counters only after that day ends. The banner names latest_ingest_day.
3. runNightly rolled up the 3 regular days, then up to 31 missed days, and ran
purgeOldEvents last. Each day first folds its legacy usage_rollup rows,
which takes minutes on a heavy day, so a backlog could run past Cloudflare's
15-minute Cron Trigger limit and skip the purge and the summary line night
after night. The purge now runs first: it is bounded and touches only days
past the window, which no rollup reads. After that, rollups stop starting
new days or fold chunks 10 minutes in (NIGHTLY_BUDGET_MS). A day stopped
mid-fold gets no rollup at all, so it stays a missed day and the next night
continues from its last committed chunk. The summary line counts those days
as `deferred`, and `caught_up` now counts missed days actually rolled up.
Verification: __tests__/telemetry-services.test.ts (new; the root vitest
config picks it up) runs the worker's fetch/cron code and the dashboard API
against the checked-in migrations in in-memory node:sqlite through a small
D1-shaped adapter. With the source fix reversed, all 5 tests fail (activation
0, first_index_day null, ingest_stalled true, and "nightly rollup incomplete:
4 day(s) failed, purge failed" once statements pass the 15-minute mark). With
the fix, all 5 pass. `npm run check` is clean in both packages. Against
wrangler dev, run locally only: smoke:rollup 71/71, smoke:cutover 62/62,
smoke:api 122/122 and render-check 87/87. smoke (ingest) is 46/47; the one
failure is the 70 KB oversized-body curl argument hitting the Windows
command-line limit, and main fails it the same way. The new smoke assertions
fail on main.
Takes effect only once both workers are redeployed; no migration.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
16 KiB
Markdown
275 lines
16 KiB
Markdown
# 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**: no network requests or queue writes on the MCP tool-call
|
||
hot path (only a small local consent-file read), 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 (identity revalidated before each request):
|
||
|
||
| 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.
|
||
|
||
## Consent & controls
|
||
|
||
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. Turning telemetry off stores a null `machine_id` and removes
|
||
both queued and claimed unsent data. Turning it back on mints a new ID; processes
|
||
discard memory from the previous identity even if they missed the off/on transition.
|
||
Requests already in flight cannot be recalled, but every later request chunk and
|
||
requeue checks current consent and identity again. Config writes use atomic replacement
|
||
so concurrent readers never see a half-written choice.
|
||
|
||
`~/.codegraph/telemetry.json`:
|
||
|
||
```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.
|
||
The small consent file is refreshed before recording so another process's opt-out is
|
||
observed. No queue writes or network requests run on this path. 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/`](../../telemetry-worker/migrations/),
|
||
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. Usage counters are stored one
|
||
row per machine × day × tool (`usage_daily`), each upload adding to its row, so storage
|
||
does not depend on how often a client uploads. The same job **purges raw `events` and
|
||
`usage_daily` rows 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. The activation funnel's "did this machine ever run an index" is not a daily
|
||
aggregate, so it reads `machine_first_seen.first_index_day` instead, which the ingest Worker
|
||
lowers as each index event is stored and the nightly cron re-derives from raw `events`.
|
||
- **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.
|