返回 CodeWhale
TELEMETRY.md
根目录 / docs / TELEMETRY.md
1 # Codewhale product telemetry
2
3 > 阅读简体中文版:[zh_hans/TELEMETRY.md](zh_hans/TELEMETRY.md)
4
5 **Usage analytics are on by default in the current source.** The first
6 interactive launch gives a localized, nonblocking disclosure naming **Codewhale
7 and PostHog**, with a direct route to turn counting off. Notice version `5`
8 describes this policy; displaying it is not recorded as human acceptance.
9 Missing an older acceptance record does not turn a fresh installation off.
10 Existing explicit opt-outs remain off across upgrades, and unreadable privacy
11 state fails closed. Environment and CLI kill switches still take precedence.
12
13 Read the disclosure with `codewhale config telemetry`. Turn usage off in
14 `/settings` or run `codewhale config set telemetry false`. Turning it back on
15 in Settings or with `codewhale config set telemetry true` updates the existing
16 durable preference and privacy records for new sessions. The compatibility
17 command `codewhale config telemetry --accept-notice 5` remains available, but
18 is not required for a fresh installation. Collection never includes work content or credentials.
19
20 The PostHog sink is optional and inert until the ingest operator explicitly
21 configures a project token and approved regional host, and records staging
22 proof that the actual egress path does not forward the original client IP.
23 Source readiness does not establish a deployment, an activated processor,
24 an egress receipt, or a retention setting.
25
26 Codewhale does not collect conversations, code, prompts, files, file/repo/branch
27 names, model content, or credentials. It sends no per-turn or per-tool timeline.
28 It does send the closed, aggregate schema below: version and platform classes,
29 session duration/outcome, feature/error counters, and a random install id that
30 rotates every 90 days.
31
32 **There is now a real endpoint.** An enabled session sends its batches to the
33 first-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,
34 which is the shipped default for `telemetry_endpoint`. What that service is,
35 what it stores, and what it structurally cannot store is spelled out in "What
36 the endpoint does" below.
37
38 **To send nothing anywhere, turn telemetry off** (see "Turning it off"). To stay
39 enabled but contact nobody, set `telemetry_endpoint = ""`: batches are then
40 appended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte
41 for byte what the server would have received, and no HTTP client is ever
42 constructed. That file is how you audit this document against reality.
43
44 This document is the schema. It is not a summary of the schema: a test in
45 `crates/telemetry` parses the field names out of this file and asserts set
46 equality against the structs the serializer actually uses, so a field that is
47 here and not in the code — or in the code and not here — fails the build.
48
49 Local saved sessions and `codewhale metrics` remain available with telemetry
50 disabled and require no account or hosted reporting service. Disabling telemetry
51 does not disable local usage receipts.
52
53 ## Turning it off
54
55 There are two off switches and they do different things. Both stop collection
56 completely; only one of them erases anything.
57
58 ```sh
59 codewhale config set telemetry false # opt out: stops collection and erases state
60 CODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing
61 codewhale --telemetry false # the same kill switch, for one command
62 ```
63
64 **`telemetry = false` in the config file is the opt-out.** It is a floor:
65 `--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
66 setting you can undo by accident from a wrapper script is not a setting. It
67 deletes the random install id, truncates every buffered event and every dry-run
68 record, and writes a tombstone. Appends, identity/state writes, and delivery all
69 share the wipe's ordering lock, so once opt-out returns no pre-opt-out write or
70 POST remains in flight. If any part of that wipe fails, the tombstone is still
71 there and the buffer is undrainable — a failed wipe fails closed. Every later
72 run re-asserts the same tombstone for as long as the setting stands, so it
73 survives. Turning telemetry back on in `/settings` records that explicit preference
74 in both existing privacy registers; a fresh launch can then clear the tombstone. Nothing buffered before that point is
75 ever sent.
76
77 **The environment variable and the flag are kill switches, not opt-outs.**
78 Telemetry is off for the run, nothing is written, nothing is sent — and
79 nothing on disk is touched or deleted. That is deliberate: a harness or agent
80 that sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the
81 install id and the dry-run records of the person who owns the machine. If you
82 want the erasing kind, use the config file.
83
84 `CODEWHALE_TELEMETRY` (and its `DEEPSEEK_TELEMETRY` alias) accepts
85 `0`, `1`, `true`, `false`, `yes`, `no`, `on`, `off`, `enabled`, `disabled`.
86 A value this list cannot read also resolves to off — a typo in a kill switch
87 must never resolve to "on".
88
89 The first-run notice is not shown when telemetry is already persistently off or
90 a run-scoped kill switch is active. The notice never rewrites a
91 `telemetry = false` you put there yourself.
92
93 A repo-local `.codewhale/config.toml` can set neither `telemetry` nor
94 `telemetry_endpoint`, and a workspace `.env` can set neither. Someone else's
95 repository cannot turn your telemetry on or aim it at a host of their choosing.
96
97 ## Where it lives, and how much of your disk it uses
98
99 Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
100
101 | file | role |
102 |---|---|
103 | `buffer.jsonl` | pending events, one JSON object per line |
104 | `buffer.jsonl.lock` | the sibling ordering lock shared by writes, delivery, arming, and wipe |
105 | `dryrun.jsonl` | where batches go when the endpoint is configured empty |
106 | `state.json` | the last app version seen and the last flush attempt |
107 | `install_id.json` | the random install id and when it was minted |
108 | `disabled` | the tombstone; present means nothing is appended or sent |
109
110 Both `buffer.jsonl` and `dryrun.jsonl` are rings capped at 512 records or
111 256 KiB, whichever comes first, with the oldest dropped. The documented
112 footprint ceiling for the whole directory is therefore **512 KiB plus a few
113 hundred bytes of metadata**.
114
115 The install id is a random v4 UUID. It is never derived from your hostname,
116 MAC address, `machine-id`, home directory, username, or executable path — a
117 derived id is a device fingerprint that survives reinstall and re-identifies
118 you across your own opt-out. It is regenerated whenever
119 `$CODEWHALE_HOME/telemetry/` is cleared, which opting out does automatically,
120 and in any case every 90 days.
121
122 There is no factory-reset command in Codewhale, so this document does not
123 claim one.
124
125 ## When anything is sent, and where
126
127 Nothing is collected or sent when a persistent opt-out or run-scoped kill
128 switch is in force, or when privacy state is unreadable. TUI and `exec` sessions have exactly one network flush point: an attempt
129 during shutdown, bounded at three seconds. Short CLI commands such as `config`,
130 `doctor`, and `auth` do not wait for that network request. They record
131 `session_end`, seal the event to the local buffer with a much shorter bound, and
132 return; a configured endpoint sends those buffered events with the next
133 interactive shutdown flush. An explicitly empty endpoint finalizes its local
134 dry-run batch immediately.
135
136 There is no startup flush, mid-session flush, per-turn flush, or per-tool-call
137 flush. Every network shutdown flush re-resolves your setting from disk
138 immediately beforehand, so `codewhale config set telemetry false` written from
139 another terminal stops the flush of a session that is already running.
140
141 A flush is **one `POST`** to the resolved endpoint — by default
142 `https://telemetry.codewhale.net/v1/telemetry`. The request carries a
143 `content-type: application/json` header, a
144 `user-agent: codewhale-telemetry/<app_version>`, and the batch body. That is
145 all: no cookies (the HTTP client is built without a cookie jar to disable), no
146 redirects (refused outright), no `Authorization` header, no custom headers, and
147 no query string. The response body is discarded unread; only the status class is
148 looked at.
149
150 `https://` is required. Plain `http://` is accepted only for a loopback host, so
151 you can point the client at a recorder of your own and read the wire form
152 directly; no environment variable overrides that refusal. An endpoint the client
153 refuses turns telemetry off for the run rather than falling back to a different
154 destination.
155
156 Any failure — DNS, connect, TLS, timeout, non-2xx — drops the batch. There is
157 no retry, no backoff, and no re-queue. A permanently offline machine attempts at
158 most once per flush point and never grows a queue.
159
160 ---
161
162 ## Event schema
163
164 `SCHEMA_VERSION = 3`. `notice_version = 5` identifies the disclosed default-on
165 policy; it does not assert human consent or that a notice was seen. The closed
166 v2 contract still accepts only `consent_version = 4`, retaining its original
167 explicit opt-in meaning. Fields cannot be mixed between versions. The unchanged
168 v1 contract remains first-party only and **never sent to PostHog**; it refuses
169 new fields, surfaces, and `product_usage` events.
170
171 Every field is an integer, a boolean, or a **closed enum string**, except exactly three bounded strings: `app_version`, `git_sha`, `panic_site`. Each of the three has a written rule and a test pinning the rule. **There is no free-form string type in this schema, and no open-keyed map.** That is the property that makes red line 3 enforceable rather than aspirational.
172
173 ### Batch envelope — sent on every POST
174
175 ```jsonc
176 {
177 "schema_version": 3,
178 "notice_version": 5,
179 "sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
180 "install_id": "3f2a…", // uuid v4, rotates every 90 days
181 "app_version": "0.9.12",
182 "git_sha": null, // non-null only for SHA-stamped builds
183 "surface": "tui",
184 "os": "macos",
185 "arch": "aarch64",
186 "libc": "none",
187 "tty": true,
188 "events": [ … ]
189 }
190 ```
191
192 | Field | Type | Source anchor | Rule |
193 |---|---|---|---|
194 | `schema_version` | `u32` | const in `crates/telemetry/src/event.rs` | Bumped on any field add/remove/retype. Never reused. Pinned by a golden snapshot test. |
195 | `notice_version` | `u32` | policy constant in `crates/telemetry/src/event.rs` | Exactly `5`; identifies the disclosed opt-out policy, not a human acceptance record. |
196 | `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
197 | `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
198 | `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/telemetry/src/lib.rs:112` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
199 | `git_sha` | string \| null | `option_env!("CODEWHALE_RELEASE_BUILD_SHA")` — a **new** rustc-env | First 12 hex chars. Emitted **only** when `codewhale_build_support::release_build_sha` saw a valid full SHA in `CODEWHALE_BUILD_SHA`, the legacy `DEEPSEEK_BUILD_SHA`, or `GITHUB_SHA`, in that precedence order. `null` for every unstamped build, with no runtime lookup of any kind. **Never** `CODEWHALE_BUILD_COMMIT` — that falls back to `git_commit` and is the builder's private HEAD. **Never** `Thread.git_sha` (`crates/state/src/lib.rs:93`) — that is the user's workspace commit and a red line, one identifier away by name. |
200 | `surface` | enum | explicitly set by the emitting client | `tui \| exec \| cli \| app-server \| mcp-server \| serve \| website \| web-app \| desktop \| control-plane`. Runtime metrics keep their existing collector. Browser product counts use the same closed envelope. A declared surface is not proof its client is deployed. |
201 | `os` | enum | `std::env::consts::OS`, as at `crates/cli/src/update.rs:41` | Whitelist: `linux \| macos \| windows \| freebsd \| android \| other`. |
202 | `arch` | enum | `std::env::consts::ARCH` | `x86_64 \| aarch64 \| other`. |
203 | `libc` | enum | `cfg!(target_env)` — **compile time** | `gnu \| musl \| none`. Runtime detection reads distro vendor strings; compile-time is free and leaks nothing. |
204 | `tty` | bool | `std::io::IsTerminal`, as at `crates/telemetry/src/envelope.rs:196` | `stdin().is_terminal() && stdout().is_terminal()`. |
205 | `events` | array | the drained buffer | Every element is one of the six events below and nothing else. Capped at 200 events or 64 KiB per batch; a batch that would exceed either cap leaves the remainder buffered for the next flush. |
206
207 **`os_major` is not collected.** Reading it costs unsafe FFI on two platforms plus a file parser on a third, in the one crate whose entire value is being small enough to audit — and `os`, `arch`, and `libc` are free and answer the platform question. It may be reconsidered if the stored data ever shows that the OS-version cut is what triage is missing; a hunch is not that evidence.
208
209 ### Which surfaces emit
210
211 Every runtime surface uses the same `decide` and flush recheck: readable privacy
212 state, resolvable home, valid endpoint, and no persistent opt-out or run kill
213 switch. Missing preferences default on. The interactive TUI shows the disclosure;
214 headless commands never synthesize consent. Existing runtime events and counters
215 are unchanged; adding PostHog does not add a second runtime collector.
216
217 Website and application clients use `product_usage`, with notice version `5`,
218 a random browser-local v4 ID rotated after 90 days, `os = other`, `arch = other`,
219 `libc = none`, `tty = false`, and `git_sha = null`. They do not inspect the browser
220 fingerprint. Their configured same-origin proxy forwards only this bounded JSON
221 to the first-party ingest, with no incoming cookies, headers, URLs, or identities.
222 The browser endpoint is inert if unconfigured. Browser opt-out clears pending
223 counts and local identity; actions taken while disabled are never backfilled.
224 Product usage settings belong in the app and runtime; marketing privacy details
225 and any website opt-out belong on the privacy page.
226
227 ### Event: `install_or_upgrade`
228
229 Emitted once when `state.json`'s `last_version` differs from `app_version`.
230
231 ```jsonc
232 { "event": "install_or_upgrade", "kind": "upgrade", "previous_version": "0.9.3" }
233 ```
234
235 | Field | Type | Source | Rule |
236 |---|---|---|---|
237 | `kind` | enum | derived | `install` (no prior record) \| `upgrade` \| `downgrade`. |
238 | `previous_version` | string \| null | `$CODEWHALE_HOME/telemetry/state.json` **only** | Same regex as `app_version`. Never derived from session history or config mtimes — those files have a different privacy contract. |
239
240 ### Event: `session_start`
241
242 ```jsonc
243 { "event": "session_start", "source": "interactive" }
244 ```
245
246 `source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown`.
247
248 ### Event: `session_end`
249
250 The workhorse. Everything a session accumulated ships here, once.
251
252 ```jsonc
253 {
254 "event": "session_end",
255 "duration_bucket": "1m_10m",
256 "exit_class": "clean",
257 "cold_start_bucket": "250_1000",
258 "providers": ["deepseek", "custom"],
259 "counters": { "turns": 14, "tool_calls": 61, "fleet_dispatch": 0, "workflow_run": 0,
260 "subagent_spawn": 2, "mcp_server_connected": 0, "memory_search": 0,
261 "approval_modal_shown": 0, "approval_auto_allowed": 0,
262 "command_palette_open": 3 },
263 "errors": { "auth_preflight_failed": 0, "provider_http_4xx": 0, "provider_http_5xx": 1,
264 "tool_denied_by_policy": 0, "tool_timeout": 0, "network_error": 0 },
265 "turn_wall": { "lt_5s": 9, "5_30s": 4, "30_120s": 1, "gte_120s": 0 }
266 }
267 ```
268
269 **`counters` and `errors` are `#[derive(Serialize)]` structs of named `u32` fields, not maps.** Every field is serialized including zeros. The key set is closed by the compiler: adding a counter requires editing `crates/telemetry/src/event.rs`, which is where the doc-match test lives.
270
271 **`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1889`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
272
273 **`exit_class`** — `clean | signal | panic | error`. **Derived from an explicit `AtomicU8`, never from an exit code.** `RunTerminationReason::Canceled` maps to exit 130 (`crates/tui/src/core/runtime_contract/termination.rs:53`), the same value the signal task uses (`crates/tui/src/lib.rs:682`, 128+SIGINT), so a code-based derivation would report every Esc-cancelled turn as a signal. The atomic is set by the panic hook (`crates/tui/src/lib.rs:1582`), by the signal task (`:678-696`) before `std::process::exit`, and on the clean path from `RunTerminationReason::is_success()` (`crates/tui/src/core/runtime_contract/termination.rs:44-46`) — `error` otherwise. Do **not** use `exec_failure_exit_code` (`crates/tui/src/lib.rs:10432`): it knows only `{75, 1}` and would report an approval-required exit (3) as a generic failure.
274
275 **`cold_start_bucket`** — from `startup_trace::elapsed_ms()`, which reads `PROCESS_START` directly and is independent of the startup summary's buffer clear (`crates/tui/src/startup_trace.rs:39-45`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
276
277 **`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:295`, a `&'static str` from a closed enum; `Custom` yields the literal `"custom"`). **The API takes `ProviderKind` by value, never `&str`.** Do not call `ProviderKind::parse` or `parse_config_identity` (`:300`, `:330`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:5049`), `provider_id_for_persistence()` (`:5058`), `ExecStreamMeta.provider_id` (`crates/tui/src/lib.rs:10220`), or `PlannedTurnRoute.effective_provider_label` (`crates/tui/src/turn_route_plan.rs:189-193`) — all four return the customer's own `[providers.<name>]` table key when the route is Custom. This is the single most likely leak in the feature: it is one field away from the natural seam and `/status` already prints it (`crates/tui/src/commands/groups/config/status.rs:24-28`). **No model id is ever sent, for any provider** — `crates/tui/src/safe_label.rs:11-15` documents that a model id can be a path, a URL, or a deployment id that is itself a credential.
278
279 **`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
280
281 | field | source anchor |
282 |---|---|
283 | `turns` | `crates/tui/src/tui/ui/event_loop.rs:1856` — the *caller* of `execute_turn_end_observer_hook`. Never inside it: that function's first statement is `if !app.hooks.has_hooks_for_event(HookEvent::TurnEnd) { return Ok(()); }` (`crates/tui/src/tui/ui.rs:1035`), and the natural future optimization hoists that check to the call site, silently zeroing the counter for every user without hooks. |
284 | `tool_calls` | `crates/tui/src/core/engine/tool_execution.rs:495` — surface-agnostic, fires for exec and CLI too |
285 | `fleet_dispatch` | `crates/tui/src/fleet/manager.rs:374` — the single funnel (`create_queued_run_with_descriptor`) that `create_run` and `create_queued_run` both land in; counting at either caller would double-count a plain `fleet run`. |
286 | `workflow_run` | counted from the **`WorkflowAction` variant discriminant** returned by `parse_workflow_action` (`crates/tui/src/tools/workflow.rs:752-765`), never from `input["action"]`. The JSON Schema at `:775-779` is what is published *to the model* — a declaration, not a guard; the real parse also accepts `spawn\|wait\|list\|inspect\|stop\|abort`, and its reject arm at `:761-763` embeds the model string verbatim. |
287 | `subagent_spawn` | `crates/tui/src/tui/ui/apply.rs:32` |
288 | `mcp_server_connected` | count of `.connected` in the snapshot at `crates/tui/src/mcp.rs:4254-4261`; never `name`, `command_or_url`, or `error` — server names are user-chosen and routinely internal infra |
289 | `memory_search` | tool name at `crates/tui/src/tools/native_memory.rs:60-61`, counted at the tool_execution choke point |
290 | `approval_modal_shown` | `crates/tui/src/tui/ui/event_loop.rs:2372` (consumer of `Event::ApprovalRequired`, `crates/tui/src/core/events.rs:444`) |
291 | `approval_auto_allowed` | `crates/tui/src/core/engine.rs:5714`. Count only. Never `matched_rule`, `reason()`, the command, or argv — `auto_allow` patterns are user-authored command strings (`crates/execpolicy/src/command_safety.rs:35/309`) |
292 | `command_palette_open` | `crates/tui/src/tui/ui/event_loop.rs:3941` and `crates/tui/src/tui/mouse_ui.rs:1346` |
293
294 **`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:
295
296 | field | source anchor |
297 |---|---|
298 | `auth_preflight_failed` | discriminant of `CredentialReadiness` (`crates/workflow/src/fleet_preflight.rs:37-58`) / `ProviderAuthClass` (`crates/tui/src/provider_readiness.rs:32`). Discriminant only — `Missing { detail }` carries free text |
299 | `provider_http_4xx` | `status.as_u16() / 100 == 4`, captured at `crates/tui/src/client/chat.rs:595` and `:673` **before** the `bail!`. One row per field, because the doc-match test reads this table field for field |
300 | `provider_http_5xx` | `status.as_u16() / 100 == 5`, same capture points |
301 | `tool_denied_by_policy` | the `permission_denied` arm of the 8-variant match at `crates/tui/src/core/engine/tool_execution.rs:512-531` |
302 | `tool_timeout` | the `timeout` arm, same match |
303 | `network_error` | `retry_reason_label_and_human()`'s `&'static str` half, `crates/tui/src/client.rs:2659` |
304
305 Why discriminants and nothing else: `ToolError::PathEscape`'s `Display` *is* an absolute path (`crates/tools/src/lib.rs:61`); `fim.rs:48-50`'s `Display` *is* a literal source fragment the model emitted; `secrets/src/lib.rs:50`'s `Display` carries the secret store's absolute path; every `LlmError` variant carries the raw provider HTTP body verbatim (`crates/tui/src/llm_client/mod.rs:327`), and a 400 from a content filter routinely echoes the prompt.
306
307 **`turn_wall`** — a per-session histogram of counts, never per-turn events. `lt_5s`, `5_30s`, `30_120s`, `gte_120s`. Source `crates/tui/src/tui/ui/event_loop.rs:1857`, which already has `duration` in hand.
308
309 ### Event: `panic`
310
311 Appended **synchronously** by the panic hook, because a `session_end` may never be written.
312
313 ```jsonc
314 { "event": "panic", "site": "crates/tui/src/lib.rs:1582:5" }
315 ```
316
317 `site` comes from `panic_info.location()` (`crates/tui/src/lib.rs:1597-1600`) or `Location::caller()` (`crates/tui/src/utils.rs:523`). **Allowlist reduction, not optional:** emit verbatim only if `file()` starts with `crates/`; otherwise emit the literal `"<dep>"`. Must match `^crates/[A-Za-z0-9_/.-]+\.rs:\d+:\d+$` or `^<dep>$`. There is no `--remap-path-prefix` in this repo (no `.cargo/config.toml`; `Cargo.toml:69-74` sets only `lto`/`strip`/`codegen-units`), so a panic inside a registry dependency yields `/Users/<builder>/.cargo/registry/src/…/ratatui-0.29.0/src/…` — the **build machine's username**, shipped from every user's binary.
318
319 **The panic message is never sent.** The hook at `crates/tui/src/lib.rs:1590-1596` builds `msg` from the payload; telemetry must not read it. A slicing panic embeds the entire string being sliced, and this tree slices user and model text in dozens of places.
320
321 ### Event: `product_usage`
322
323 One aggregate of explicit product interactions, with every field a saturating
324 `u32`, including zero values. No page paths, URLs, referrers, search terms,
325 account/session IDs, text, or action timestamps are permitted. Clients flush
326 aggregate counts on page exit or when hidden, rather than each action.
327
328 ```jsonc
329 {
330 "event": "product_usage",
331 "counters": {
332 "page_view": 1,
333 "docs_view": 0,
334 "install_copy": 0,
335 "download": 0,
336 "signup": 0,
337 "login": 0,
338 "session_create": 0,
339 "session_resume": 0,
340 "turn_submit": 0,
341 "turn_complete": 0,
342 "settings_open": 0,
343 "integration_connect": 0,
344 "error_shown": 0
345 }
346 }
347 ```
348
349 `page_view` counts a page view and `docs_view` a documentation view; `install_copy`
350 and `download` count installation actions. `signup` and `login` count successful
351 authentication actions, never identity. `session_create`, `session_resume`,
352 `turn_submit`, and `turn_complete` count their named product actions.
353 `settings_open`, `integration_connect`, and `error_shown` are counts only:
354 never the selected setting, integration name, error body, or work content.
355 An event's availability depends on the emitting client; zero is not proof a
356 feature was exercised or available.
357
358 ### Event: `operations_summary`
359
360 Anonymous service health aggregates from the existing control-plane signal ledger.
361 Exactly these six saturating `u32` fields are accepted, only on `control-plane`.
362 This requires an explicit operator delivery/processor configuration, independently
363 of the end-user analytics choice. Each batch uses a fresh random v4 `install_id`;
364 there is no user/account/session join. No per-request rows, routes, request
365 digests, IDs, error text, traces, or timing series are permitted. `requests` and
366 `errors` count requests and failures; `duration_ms_total` and `duration_ms_max`
367 are aggregate latency values; `probes` and `probes_failed` count health probes.
368
369 ```jsonc
370 {
371 "event": "operations_summary",
372 "requests": 10,
373 "errors": 1,
374 "duration_ms_total": 1500,
375 "duration_ms_max": 300,
376 "probes": 2,
377 "probes_failed": 0
378 }
379 ```
380
381 ### What the endpoint does — a shipping gate, not a footnote
382
383 This section was a gate on configuring any non-loopback endpoint. The endpoint is now configured by default, so this is a description of a service that exists rather than a promise about one that might.
384
385 **What it is.** `https://telemetry.codewhale.net/v1/telemetry` — a Cloudflare Worker named `codewhale-telemetry-ingest`, whose complete source is in this repository at [`telemetry-ingest/`](../telemetry-ingest/). It is the single schema and storage authority; browser applications may use a same-origin proxy that forwards only the body. There is no telemetry queue or additional runtime collector. It is write-only: nothing in the Worker can read back what was stored, and querying happens out of band through Cloudflare's SQL API with the owner's token. The hostname is deliberately self-describing, so anyone inspecting their own network traffic can tell what it is from the name alone.
386
387 **What it stores.** Everything in this document and nothing else, in Workers Analytics Engine — one row per event. The validator in `telemetry-ingest/src/schema.ts` is a **closed** field set: an unknown key anywhere in a batch rejects the whole batch with `400`. A future client bug that starts attaching a path, a prompt, or a provider table name gets refused by the server rather than quietly stored. `telemetry-ingest/test/schema-doc.test.ts` parses the field names and enum spellings back out of *this file* and asserts set equality against the validator, and `telemetry-ingest/test/ingest.test.ts` posts the Rust client's own pinned golden batch and asserts it is accepted byte for byte — so this document, the client, and the endpoint cannot drift apart without a red test.
388
389 Batches are **IP-stripped at ingest**. No IP is stored, logged, or joined to `install_id` — and that is structural rather than a setting anyone could flip. An Analytics Engine row is exactly `_sample_interval`, `blob1`–`blob20`, `dataset`, `double1`–`double20`, `index1`, and `timestamp`. Every one of those columns is written by the Worker's own `writeDataPoint` call; there is no implicit column, so **there is no IP, country, or geo column** — no slot one could occupy even if the code wanted it there. And the code cannot want it:
390
391 - The handler reads exactly **two** request headers — `content-type` and `content-length`. Nothing else, ever.
392 - It never touches the request's `cf` property, so country, colo, city, region, ASN, timezone, and coordinates are never in scope.
393 - The function that builds every stored row cannot see the request at all; its input type is the validated batch body.
394 - Nothing logs. `invocation_logs` is off in `wrangler.jsonc` — Cloudflare describes those as "enriched with information available to Cloudflare in the context of the invocation", which is exactly the class of automatic per-request record this service will not keep — and there is no `console.*` call in the source.
395 - Rate limiting is keyed on `install_id` from the validated body, never on a network address. An IP-keyed limiter would mean this Worker handles IPs. It is the weaker limiter and it is the right trade.
396 - `telemetry-ingest/test/no-ip.test.ts` reads the shipped source as text and fails the build if any address or geo name appears, if the set of headers read grows past two, if a `console.*` call is added, or if a `Response` is ever constructed with a body.
397
398 **Retention: three months.** That is Cloudflare's fixed window for Analytics Engine and it is not configurable, so it is a ceiling rather than a policy — there is no setting that could make it longer.
399
400 **Optional PostHog processor.** After first-party storage, an explicitly
401 configured ingest may forward validated schema-v3 / notice-v5 batches and
402 legacy schema-v2 / consent-v4 batches to
403 PostHog's [batch capture API](https://posthog.com/docs/api/capture). The only
404 permitted origins are `https://us.i.posthog.com` and `https://eu.i.posthog.com`.
405 Legacy v1 batches cannot reach that path. PostHog receives the same bounded
406 envelope and event fields, the batch timestamp as its event timestamp, and
407 `codewhale:<install_id>` as its anonymous `distinct_id`. Events are named
408 `codewhale_install_or_upgrade`, `codewhale_session_start`,
409 `codewhale_session_end`, `codewhale_panic`, `codewhale_product_usage`, or
410 `codewhale_operations_summary`.
411 `$process_person_profile = false`, `$geoip_disable = true`, and `$ip = null`
412 are fixed transport controls. No request metadata, profile identification,
413 SDK, autocapture, session replay, advertising, or work content is forwarded.
414 A fresh server request has no user cookies or authorization headers, cannot
415 follow redirects, and is limited to 1.5 seconds. There are no retries or logs;
416 a failure does not change a successful first-party ingest response.
417
418 The PostHog project's retention and privacy settings are separate deployment
419 configuration and must be reviewed before activation. The Analytics Engine
420 three-month limit does not establish a PostHog retention period.
421
422 **The edge egress path also requires staging proof.** [Cloudflare documents](https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-connecting-ip-in-worker-subrequests)
423 that Worker subrequests to non-Cloudflare zones can receive platform-added
424 client-IP headers. Fresh JavaScript headers and local fetch tests cannot prove
425 those headers are removed. `POSTHOG_IP_SAFE_EGRESS_VERIFIED="true"` is an explicit
426 operator prerequisite alongside the host and token, and must remain unset
427 until a receipt for the actual deployment and regional destination confirms
428 no original client IP in any received header, including `CF-Connecting-IP`,
429 `X-Forwarded-For`, and `X-Real-IP`. The flag does not strip platform headers.
430 Requalify a changed egress path; if it cannot pass, keep the sink disabled or
431 run only its delivery in a detached first-party context. No such live proof
432 is established by this source change.
433
434 **Every response is a bare status with an empty body** — `204` accepted, `400` schema violation, `404`/`405` wrong path or method, `413` oversized, `415` wrong content type, `429` rate-limited, `500` internal. The endpoint cannot echo back what it received or what it holds, and because the client drops the batch on anything that is not 2xx, a rejection is invisible to you by construction and a server error can never surface as a client-visible failure.
435
436 `install_id` rotates client-side every 90 days (`rotated_at` in `install_id.json`), so no single identifier spans a long history. This costs longitudinal accuracy and the docs say so: **no count derived from `install_id` is a user count.** It is a lower bound on distinct machine-installs in a window, and it undercounts a returning user across a rotation.
437
438 **Turning it off deletes what was kept locally, not what was already sent.** `codewhale config set telemetry false` erases the install id, the buffer, and the dry-run records on your machine, and stops anything further. Rows already accepted by the endpoint are keyed only by a rotating random id that is now gone; first-party rows age out with the three-month window. If PostHog processing is activated, already-delivered processor records follow that project’s configured retention. The client has no remote deletion API and does not claim that local opt-out erases delivered records.
439
440 ### What the owner reads back — observed active installs
441
442 The one product metric derived from this data is **observed active installs**: the number of distinct rotating anonymous install ids that produced a `session_start` event on a UTC day. That is the whole definition. It is not a count of people, not a count of accounts, and not a count of total installs — the id is per installation, rotates every 90 days, and is deleted on opt-out, so no number derived from it can be any of those things.
443
444 The exact owner command is checked into the repository, so the routine query is reviewable code rather than SQL pasted from a chat:
445
446 ```sh
447 cd telemetry-ingest
448 CF_ACCOUNT_ID=... CF_API_TOKEN=... npm run report:active-installs
449 ```
450
451 It prints the daily series, a 7-day trend over complete UTC days, the freshness of the newest ingested event, and — with the numbers, in both text and `--json` output — the coverage caveats: clients older than the telemetry feature, opted-out installs, and non-emitting environments (kill switches, fleet workers, offline shutdowns, dropped flushes) are invisible, so every count is a lower bound; and because ids rotate, week-over-week comparisons are not a retention metric. The report's read path is itself tested: the install id appears only inside an aggregate, no payload column is selected, and the wording can never drift into calling the result users. (`npm run report:dau` remains as a compatibility alias for the same report.)
452
453 ### What is never collected — the public red-line list
454
455 Prompts; completions; tool arguments; diffs; patches; file contents; filenames; absolute or relative paths; git remotes; repo names; branch names; workspace commit SHAs; memory entries; chat history; API keys, tokens, cookies, or `Authorization` headers (including any boolean asserting a key exists); model ids of any kind; custom provider table names; MCP server names, commands, or URLs; approval rule text; error message bodies; panic message text; per-event timestamps; keystrokes; clipboard; screenshots; microphone; camera; location; and any third-party ad or analytics SDK — there are none in the runtime binary and none may be added.
456
457 Two named traps for the implementer. `crates/state/src/lib.rs` persists `git_sha`, `git_branch`, `git_origin_url`, `cwd`, and `path` on the threads table (`:93, :399, :653`): a payload builder that accepts a `Thread` or `ThreadMeta` and derives `Serialize` breaches the contract in one line. **Never derive `Serialize` over an existing state type** — build every telemetry struct from scratch with explicit fields. And `crates/core/src/lib.rs:1389-1398` is the one place in the tree where the word `telemetry` sits inside a JSON object next to `prompt`, `base_url`, and `has_api_key`. It is the object someone will copy. Do not.
458
459 ---
460
460 lines MARKDOWN