Просмотр исходного кода

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>
Colby McHenry 1 месяц назад
Родитель
Сommit
1a678aabc7
5 измененных файлов с 130 добавлено и 39 удалено
  1. 3 0
      CHANGELOG.md
  2. 25 8
      TELEMETRY.md
  3. 83 25
      docs/design/telemetry.md
  4. 16 4
      telemetry-worker/README.md
  5. 3 2
      telemetry-worker/scripts/smoke-cutover.sh

+ 3 - 0
CHANGELOG.md

@@ -9,6 +9,9 @@ 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.
 
 ## [1.5.0] - 2026-07-21
 

+ 25 - 8
TELEMETRY.md

@@ -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
docs/design/telemetry.md

@@ -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.

+ 16 - 4
telemetry-worker/README.md

@@ -196,13 +196,25 @@ that reads zero forever.
    cutover final.
 
    ```bash
-   npx wrangler secret delete POSTHOG_KEY   # the last PostHog reference on the account
+   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
    ```
 
-   The `POSTHOG_HOST` var and every line of forwarding code are already gone from this repo —
-   `git grep -i posthog telemetry-worker/` returns nothing. Finish by cancelling the PostHog
-   subscription and deleting the project.
+   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
 

+ 3 - 2
telemetry-worker/scripts/smoke-cutover.sh

@@ -257,8 +257,9 @@ 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 "no PostHog reference in ingest worker code or config" 0 \
-   "$(grep -ril posthog "$WORKER_DIR"/src "$WORKER_DIR"/wrangler.jsonc 2>/dev/null | wc -l | tr -d ' ')"
+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