返回 CodeWhale
TELEMETRY.md
根目录 / docs / TELEMETRY.md
1 # Codewhale product telemetry
2
3 **Status for 0.9.4: opt-in, and off by default.** Nothing is collected until you
4 answer the first-run notice with "Enable" — before that answer no event is
5 recorded, no batch is assembled, and `$CODEWHALE_HOME/telemetry/` is not even
6 created. Answering "Enable" is the entire consent decision; there is no other
7 way to turn this on, and a `telemetry = true` written before 0.9.4 does not
8 count as one.
9
10 **There is now a real endpoint.** An enabled session sends its batches to the
11 first-party ingest service at `https://telemetry.codewhale.net/v1/telemetry`,
12 which is the shipped default for `telemetry_endpoint`. What that service is,
13 what it stores, and what it structurally cannot store is spelled out in "What
14 the endpoint does" below. The default decides only *where* an enabled session's
15 batches go — it changes nothing about whether a session collects.
16
17 **To send nothing anywhere, keep telemetry off** (see "Turning it off"). To stay
18 enabled but contact nobody, set `telemetry_endpoint = ""`: batches are then
19 appended to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` on your own machine, byte
20 for byte what the server would have received, and no HTTP client is ever
21 constructed. That file is how you audit this document against reality.
22
23 This document is the schema. It is not a summary of the schema: a test in
24 `crates/telemetry` parses the field names out of this file and asserts set
25 equality against the structs the serializer actually uses, so a field that is
26 here and not in the code — or in the code and not here — fails the build.
27
28 ## Turning it off
29
30 There are two off switches and they do different things. Both stop collection
31 completely; only one of them erases anything.
32
33 ```sh
34 codewhale config set telemetry false # opt out: stops collection and erases state
35 CODEWHALE_TELEMETRY=0 codewhale # kill switch: stops collection, erases nothing
36 codewhale --telemetry false # the same kill switch, for one command
37 ```
38
39 **`telemetry = false` in the config file is the opt-out.** It is a floor:
40 `--telemetry true` and `CODEWHALE_TELEMETRY=1` both lose to it, because a
41 setting you can undo by accident from a wrapper script is not a setting. It
42 deletes the random install id, truncates every buffered event and every
43 dry-run record, and writes a tombstone that a session already running re-checks
44 before it appends anything and before it sends anything. If any part of that
45 wipe fails, the tombstone is still there and the buffer is undrainable — a
46 failed wipe fails closed. Every later run re-asserts the tombstone for as long
47 as the setting stands, so it survives; turning telemetry back on means writing
48 `telemetry = true` in the same place, and that is also what clears it. Nothing
49 buffered before that point is ever sent.
50
51 **The environment variable and the flag are kill switches, not opt-outs.**
52 Telemetry is off for the run, nothing is written, nothing is sent — and
53 nothing on disk is touched or deleted. That is deliberate: a harness or agent
54 that sets `CODEWHALE_TELEMETRY=0` for one command must not silently discard the
55 install id and the dry-run records of the person who owns the machine. If you
56 want the erasing kind, use the config file.
57
58 `CODEWHALE_TELEMETRY` (and its `DEEPSEEK_TELEMETRY` alias) accepts
59 `0`, `1`, `true`, `false`, `yes`, `no`, `on`, `off`, `enabled`, `disabled`.
60 A value this list cannot read also resolves to off — a typo in a kill switch
61 must never resolve to "on".
62
63 The first-run notice is not shown at all when either switch is already set:
64 it never asks a question this environment would override, and answering it
65 never rewrites a `telemetry = false` you put there yourself.
66
67 A repo-local `.codewhale/config.toml` can set neither `telemetry` nor
68 `telemetry_endpoint`, and a workspace `.env` can set neither. Someone else's
69 repository cannot turn your telemetry on or aim it at a host of their choosing.
70
71 ## Where it lives, and how much of your disk it uses
72
73 Everything is under `$CODEWHALE_HOME/telemetry/` (`0700`), every file `0600`:
74
75 | file | role |
76 |---|---|
77 | `buffer.jsonl` | pending events, one JSON object per line |
78 | `buffer.jsonl.lock` | a sibling lock file; only compaction takes it |
79 | `dryrun.jsonl` | where batches go when the endpoint is configured empty |
80 | `state.json` | the last app version seen and the last flush attempt |
81 | `install_id.json` | the random install id and when it was minted |
82 | `disabled` | the tombstone; present means nothing is appended or sent |
83
84 Both `buffer.jsonl` and `dryrun.jsonl` are rings capped at 512 records or
85 256 KiB, whichever comes first, with the oldest dropped. The documented
86 footprint ceiling for the whole directory is therefore **512 KiB plus a few
87 hundred bytes of metadata**.
88
89 The install id is a random v4 UUID. It is never derived from your hostname,
90 MAC address, `machine-id`, home directory, username, or executable path — a
91 derived id is a device fingerprint that survives reinstall and re-identifies
92 you across your own opt-out. It is regenerated whenever
93 `$CODEWHALE_HOME/telemetry/` is cleared, which opting out does automatically,
94 and in any case every 90 days.
95
96 There is no factory-reset command in Codewhale, so this document does not
97 claim one.
98
99 ## When anything is sent, and where
100
101 Nothing is sent at all unless telemetry resolved on **and** the first-run notice
102 was answered with "Enable" on this machine. Given both, there are exactly two
103 flush points: a startup drain, at most once every six hours, that recovers
104 events a crashed or signalled prior session left behind; and one attempt during
105 shutdown, bounded at three seconds. There is no mid-session flush, no per-turn
106 flush, and no per-tool-call flush. Both re-resolve your setting from disk
107 immediately beforehand, so `codewhale config set telemetry false` written from
108 another terminal stops the flush of a session that is already running.
109
110 A flush is **one `POST`** to the resolved endpoint — by default
111 `https://telemetry.codewhale.net/v1/telemetry`. The request carries a
112 `content-type: application/json` header, a
113 `user-agent: codewhale-telemetry/<app_version>`, and the batch body. That is
114 all: no cookies (the HTTP client is built without a cookie jar to disable), no
115 redirects (refused outright), no `Authorization` header, no custom headers, and
116 no query string. The response body is discarded unread; only the status class is
117 looked at.
118
119 `https://` is required. Plain `http://` is accepted only for a loopback host, so
120 you can point the client at a recorder of your own and read the wire form
121 directly; no environment variable overrides that refusal. An endpoint the client
122 refuses turns telemetry off for the run rather than falling back to a different
123 destination.
124
125 Any failure — DNS, connect, TLS, timeout, non-2xx — drops the batch. There is
126 no retry, no backoff, and no re-queue. A permanently offline machine attempts at
127 most once per flush point and never grows a queue.
128
129 ---
130
131 ## Event schema
132
133 `SCHEMA_VERSION = 1`. 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.
134
135 ### Batch envelope — sent on every POST
136
137 ```jsonc
138 {
139 "schema_version": 1,
140 "sent_at": "2026-08-03T18:04:11Z", // RFC3339 UTC, second precision
141 "install_id": "3f2a…", // uuid v4, rotates every 90 days
142 "app_version": "0.9.4",
143 "git_sha": null, // non-null only for release-CI builds
144 "surface": "tui",
145 "os": "macos",
146 "arch": "aarch64",
147 "libc": "none",
148 "tty": true,
149 "events": [ … ]
150 }
151 ```
152
153 | Field | Type | Source anchor | Rule |
154 |---|---|---|---|
155 | `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. |
156 | `sent_at` | RFC3339 | `chrono::Utc::now()` | Second precision. Per-**batch** only — events carry no timestamps at all. |
157 | `install_id` | uuid v4 | `crates/telemetry/src/envelope.rs` | Random, never derived, rotated every 90 days. See "Where it lives" above. |
158 | `app_version` | string | `env!("CARGO_PKG_VERSION")`, as at `crates/tui/src/tui/ui.rs:17894` | Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`. |
159 | `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 `DEEPSEEK_BUILD_SHA` or `GITHUB_SHA` in the build environment, i.e. only for release-CI builds. `null` for every locally built binary, unconditionally, 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:88`) — that is the user's workspace commit and a red line, one identifier away by name. |
160 | `surface` | enum | set explicitly at each subcommand dispatch | `tui \| exec \| cli \| app-server \| mcp-server \| serve`. **Not derivable from the executable**: `codewhale-tui` serves at least five surfaces, and app-server runs *in-process* inside `codewhale` (`crates/cli/src/lib.rs:3945-3968`), so `current_exe()` would report every app-server session as CLI. `desktop` is omitted — no desktop surface exists. Which of these can emit is governed by the consent record, not by the surface: see "Which surfaces emit" below. |
161 | `os` | enum | `std::env::consts::OS`, as at `crates/cli/src/update.rs:41` | Whitelist: `linux \| macos \| windows \| freebsd \| android \| other`. |
162 | `arch` | enum | `std::env::consts::ARCH` | `x86_64 \| aarch64 \| other`. |
163 | `libc` | enum | `cfg!(target_env)` — **compile time** | `gnu \| musl \| none`. Runtime detection reads distro vendor strings; compile-time is free and leaks nothing. |
164 | `tty` | bool | `std::io::IsTerminal`, as at `crates/tui/src/tui/ui.rs:1169` | `stdin().is_terminal() && stdout().is_terminal()`. Varies, because consent is machine-scoped. |
165 | `events` | array | the drained buffer | Every element is one of the four 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. |
166
167 **`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.
168
169 ### Which surfaces emit
170
171 A surface emits when — and only when — a notice decision was recorded on this machine and resolves to opt-in. The notice is only ever rendered on a TTY. So:
172
173 - **`tui`** — emits after the user answers the notice.
174 - **`exec`, `cli`, `app-server`, `mcp-server`, `serve`** — emit only on a machine where the notice was already answered interactively. On a fresh home (CI, a container, a new user) they emit nothing, and nothing is written to disk.
175 - **Fleet workers never emit**, on any surface, by construction (`crates/tui/src/fleet/host.rs:1362`).
176
177 `docs/TELEMETRY.md` states this so nobody reads a structural zero as an adoption zero.
178
179 ### Event: `install_or_upgrade`
180
181 Emitted once when `state.json`'s `last_version` differs from `app_version`.
182
183 ```jsonc
184 { "event": "install_or_upgrade", "kind": "upgrade", "previous_version": "0.9.3" }
185 ```
186
187 | Field | Type | Source | Rule |
188 |---|---|---|---|
189 | `kind` | enum | derived | `install` (no prior record) \| `upgrade` \| `downgrade`. |
190 | `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. |
191
192 ### Event: `session_start`
193
194 ```jsonc
195 { "event": "session_start", "source": "interactive" }
196 ```
197
198 `source` is `SessionSource` (`crates/state/src/lib.rs:34-41`) stringified by `session_source_to_str` (`:1909-1917`): `interactive | resume | fork | api | unknown`.
199
200 ### Event: `session_end`
201
202 The workhorse. Everything a session accumulated ships here, once.
203
204 ```jsonc
205 {
206 "event": "session_end",
207 "duration_bucket": "1m_10m",
208 "exit_class": "clean",
209 "cold_start_bucket": "250_1000",
210 "providers": ["deepseek", "custom"],
211 "counters": { "turns": 14, "tool_calls": 61, "fleet_dispatch": 0, "workflow_run": 0,
212 "subagent_spawn": 2, "mcp_server_connected": 0, "memory_search": 0,
213 "approval_modal_shown": 0, "approval_auto_allowed": 0,
214 "command_palette_open": 3 },
215 "errors": { "auth_preflight_failed": 0, "provider_http_4xx": 0, "provider_http_5xx": 1,
216 "tool_denied_by_policy": 0, "tool_timeout": 0, "network_error": 0 },
217 "turn_wall": { "lt_5s": 9, "5_30s": 4, "30_120s": 1, "gte_120s": 0 }
218 }
219 ```
220
221 **`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.
222
223 **`duration_bucket`** — `chrono` delta from `app.session_started_at` (`crates/tui/src/tui/app.rs:1767`). Half-open, seconds: `lt_1m` (`d < 60`), `1m_10m` (`60 ≤ d < 600`), `10m_60m` (`600 ≤ d < 3600`), `gt_60m` (`d ≥ 3600`).
224
225 **`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:52`), the same value the signal task uses (`crates/tui/src/main.rs:686`, 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/main.rs:1353`), by the signal task (`:669-689`) 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/main.rs:9680-9685`): it knows only `{75, 1}` and would report an approval-required exit (3) as a generic failure.
226
227 **`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:33-48`). Boundaries: `lt_250`, `250_1000`, `1000_3000`, `gte_3000`. Absent on non-TUI surfaces.
228
229 **`providers`** — sorted, deduplicated array of `ProviderKind::as_str()` (`crates/config/src/provider_kind.rs:252-254`, 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` (`:257`, `:287`) — those are for config-table resolution. **Do not read** `provider_identity_for_persistence()` (`crates/tui/src/tui/app.rs:4635-4641`), `provider_id_for_persistence()` (`:4644-4646`), `ExecStreamMeta.provider_id` (`crates/tui/src/main.rs:9506`), 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.
230
231 **`counters`** — closed field set. Every bump happens at the **call site**, never inside a conditionally-entered handler:
232
233 | field | source anchor |
234 |---|---|
235 | `turns` | `crates/tui/src/tui/ui.rs:4196` — 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(()); }` (`:1806-1808`), and the natural future optimization hoists that check to the call site, silently zeroing the counter for every user without hooks. |
236 | `tool_calls` | `crates/tui/src/core/engine/tool_execution.rs:474` — surface-agnostic, fires for exec and CLI too |
237 | `fleet_dispatch` | `crates/tui/src/fleet/manager.rs:334/351/363` |
238 | `workflow_run` | counted from the **`WorkflowAction` variant discriminant** returned by `parse_workflow_action` (`crates/tui/src/tools/workflow.rs:738-751`), 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 `:746-748` embeds the model string verbatim. |
239 | `subagent_spawn` | `crates/tui/src/tui/ui.rs:1763-1776` |
240 | `mcp_server_connected` | count of `.connected` in the snapshot at `crates/tui/src/mcp.rs:3795-3809`; never `name`, `command_or_url`, or `error` — server names are user-chosen and routinely internal infra |
241 | `memory_search` | tool name at `crates/tui/src/tools/native_memory.rs:60-61`, counted at the tool_execution choke point |
242 | `approval_modal_shown` | `crates/tui/src/tui/ui.rs:4670` (consumer of `Event::ApprovalRequired`, `crates/tui/src/core/events.rs:414`) |
243 | `approval_auto_allowed` | `crates/tui/src/core/engine.rs:5571`. Count only. Never `matched_rule`, `reason()`, the command, or argv — `auto_allow` patterns are user-authored command strings (`crates/tui/src/command_safety.rs:35/309`) |
244 | `command_palette_open` | `crates/tui/src/tui/ui.rs:6192-6213` and `crates/tui/src/tui/mouse_ui.rs:1326` |
245
246 **`errors`** — closed field set. Every value is a **variant discriminant**, never `err.to_string()`:
247
248 | field | source anchor |
249 |---|---|
250 | `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 |
251 | `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 |
252 | `provider_http_5xx` | `status.as_u16() / 100 == 5`, same capture points |
253 | `tool_denied_by_policy` | the `permission_denied` arm of the 8-variant match at `crates/tui/src/core/engine/tool_execution.rs:487-495` |
254 | `tool_timeout` | the `timeout` arm, same match |
255 | `network_error` | `retry_reason_label_and_human()`'s `&'static str` half, `crates/tui/src/client.rs:2570-2584` |
256
257 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:455-511`), and a 400 from a content filter routinely echoes the prompt.
258
259 **`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.rs:4196`, which already has `duration` in hand.
260
261 ### Event: `panic`
262
263 Appended **synchronously** by the panic hook, because a `session_end` may never be written.
264
265 ```jsonc
266 { "event": "panic", "site": "crates/tui/src/tui/ui.rs:8801:17" }
267 ```
268
269 `site` comes from `panic_info.location()` (`crates/tui/src/main.rs:1368-1371`) 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.
270
271 **The panic message is never sent.** The hook at `crates/tui/src/main.rs:1361-1367` 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.
272
273 ### What the endpoint does — a shipping gate, not a footnote
274
275 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.
276
277 **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 only component; there is no queue, no proxy, and no other service in the path. 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.
278
279 **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.
280
281 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:
282
283 - The handler reads exactly **two** request headers — `content-type` and `content-length`. Nothing else, ever.
284 - It never touches the request's `cf` property, so country, colo, city, region, ASN, timezone, and coordinates are never in scope.
285 - The function that builds every stored row cannot see the request at all; its input type is the validated batch body.
286 - 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.
287 - 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.
288 - `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.
289
290 **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.
291
292 **No third-party analytics processor** sits between the client and storage. There is no ad SDK, no analytics SDK, and no session replay, in the endpoint or in the runtime binary.
293
294 **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.
295
296 `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.
297
298 **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; they age out with the three-month window. There is no deletion API, and this document does not claim one.
299
300 ### What is never collected — the public red-line list
301
302 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.
303
304 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 (`:88, :394, :648`): 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:1381-1390` 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.
305
306 ---
307
308
308 lines MARKDOWN