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