From 8049c126dc4370ca5ce4506b30c4de7b773daf9f Mon Sep 17 00:00:00 2001 From: Louis Amaudruz Date: Wed, 16 Sep 2026 19:16:19 +0000 Subject: [PATCH 1/3] docs(telemetry): document what Switch Console collects and why it is anonymous Add docs/TELEMETRY.md: the complete field-by-field catalogue of every Console telemetry event with example values, the three code-level mechanisms that keep free text out of a payload, the install identifier, the send path, the client-IP requirements on the relay, and the argument that the data cannot be traced to a person. Add a Telemetry section to the README summarising what is collected, what never is, and how to opt out, linking to the full document. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 40 ++++++ docs/TELEMETRY.md | 335 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 docs/TELEMETRY.md diff --git a/README.md b/README.md index 8266f853c..06204f02f 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,46 @@ dashboard covers the rest. one up for you, on this machine or on a host you own, without you writing any Compose or Helm configuration. +## Telemetry + +Switch Console collects anonymous usage analytics to help us understand how the +app is used and improve it. Telemetry is **opt-in** — you are asked during first +run, and nothing is sent without your explicit agreement. Switch Core (the +server) sends no telemetry at all. + +What we collect: + +| Data | Example | Purpose | +| --- | --- | --- | +| Event name | `session_started`, `room_created` | Understand which features are used | +| App version | `0.9.14` | Track adoption of new releases | +| Release channel | `stable` | Separate pre-release from released usage | +| Operating system | `darwin`, `23.6.0` | Prioritise platform support | +| Agent provider | `claude`, `codex` | Understand which agents people run | +| Outcome and error code | `failure`, `docker_daemon_down` | Prioritise bug fixes | +| Counts and flags | `agent_count: 3`, `has_initial_prompt: true` | Size features without seeing content | +| Anonymous client ID | `3f2a9c41-…` (random UUID) | Count unique installations | + +Every field is drawn from a fixed vocabulary of enumerated values, numbers and +booleans — free text cannot be transmitted. + +**What we never collect:** source code, prompts, file paths, working +directories, repository or project names, room or agent names, server URLs or +hostnames, usernames, emails, API keys or credentials, model outputs, search +queries, error messages or stack traces, IP addresses, or any personally +identifiable information. + +Events are sent to a relay we operate (`telemetry.flintai.dev`), which forwards +them to our analytics providers; no vendor credentials ship in the app. + +**Opting out:** turn off *Send anonymous usage data* in Settings → Telemetry. +Sending stops immediately — the setting is checked before every event — and you +can change it back at any time. Declining at first run leaves telemetry off. + +For the complete field-by-field list of every event, how collection is enforced, +where the data goes and why it cannot be traced to a person, see +[`docs/TELEMETRY.md`](docs/TELEMETRY.md). + ## Contributing Switch is being built in the open, with the people who use it. Nobody knows yet diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md new file mode 100644 index 000000000..3568ea778 --- /dev/null +++ b/docs/TELEMETRY.md @@ -0,0 +1,335 @@ +# Switch Console telemetry — what we collect, where it goes, and why it cannot be traced to a person + +**Audience:** InfoSec. +**Scope:** the Switch Console desktop app. Switch server-side logging is out of +scope and needs its own pass. +**Claim:** the data we transmit is anonymous. No field identifies a person, and +no combination of the fields we transmit can be resolved back to one. + +Sections 1–6 describe the Console as it is today, verified against the source. +Section 7 states the requirements on the relay; items there marked **[TO +CONFIRM]** are the target state and have not yet been verified against the +relay's configuration. + +--- + +## 1. What Switch is, in two lines + +Switch runs AI coding agents (Claude Code, Codex, …) and connects them to chat +channels like Slack and Mattermost so people and agents work in shared rooms. +**Switch Console** is the desktop app used to drive it: create agents, start +sessions, connect to a server, set up rooms and bridges. + +Telemetry here is product-usage analytics from that desktop app — *how the app is +used*, never *what is done with it*. + +--- + +## 2. A real event, in full + +This is the complete wire payload for one event — nothing is omitted or +abbreviated: + +```json +{ + "resource": { + "service.name": "switch-console", + "service.version": "0.9.14", + "flint.client_id": "3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83", + "os.type": "darwin", + "os.version": "23.6.0" + }, + "event.name": "session_started", + "timeUnixNano": "1789572278158000000", + "severityText": "INFO", + "attributes": { + "build": "stable", + "agent_type": "claude", + "location": "local", + "outcome": "success", + "failure_reason": "none", + "entry_point": "command_palette", + "start_source": "user", + "has_initial_prompt": true, + "connected_to_room": false + } +} +``` + +Read it as: *an installation identified only by a random UUID, on macOS, started +a Claude session locally from the command palette; it had some initial prompt +text, and was not connected to a room.* Nothing in it says who, where, on what +machine, in what repository, or what the prompt said. + +--- + +## 3. Every field we collect, with example values + +### 3.1 Attached to every event + +| Field | What it is | Example values | +|---|---|---| +| `service.name` | constant | `switch-console` | +| `service.version` | app version | `0.9.14`, `1.0.2` | +| `build` | release channel | `dev`, `canary`, `stable` | +| `os.type` | OS family | `darwin`, `windows`, `linux`, `other` | +| `os.version` | OS release string | `23.6.0`, `10.0.22631`, `6.1.0-53-cloud-amd64` | +| `flint.client_id` | random install UUID | `3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83` | +| `event.name` | the event | `session_started` | +| timestamp | event time | `1789572278158000000` | + +That is the entire ambient set. No hostname, no username, no account, no machine +id, no IP field, no Switch identity. + +### 3.2 Values shared across events + +| Field | Complete set of possible values | +|---|---| +| `outcome` | `success`, `failure` (on `session_ended`: `normal`, `failed`) | +| `agent_type` | the AI provider, from a fixed registry: `claude`, `codex`, `gemini`, `cursor`, `copilot`, `opencode`, `grok`, `devin`, `qwen`, `droid`, `amp`, `goose`, `cline`, `continue`, `mistral`, `kiro`, `junie`, … and `unknown`. **Never an agent's name.** | +| `location` | `local`, `remote`, `unknown`. **Never a path, directory or project name.** | +| `server_kind` | `local`, `remote_managed`, `external`. **Never a server name or URL.** | +| `bridge_platform` | `slack`, `mattermost`, `discord`, `teams`, `telegram`, `other`, `unknown`. **Never a workspace or channel name.** | +| `entry_point` | `command_palette`, `sidebar`, `server_page`, `onboarding`, `agent_page`, `session_list`, `room_row`, `unknown` | +| `target` | `local`, `remote`, `unknown` | + +### 3.3 Every event and its fields + +**App lifecycle** + +| Event | Fields, with example values | +|---|---| +| `app_launched` | *(no fields)* | +| `renderer_crashed` | *(no fields)* | +| `update_checked` | `trigger`: `user` / `startup` / `scheduled` · `result`: `available` / `up_to_date` / `failed` | +| `update_downloaded` | `outcome`: `success` / `failure` | +| `update_install_started` | `outcome`: `success` / `failure` | +| `telemetry_consent_changed` | `source`: `first_run` / `settings` | +| `setting_changed` | `setting_key`, one of exactly 15: `theme`, `notifications`, `terminal`, `defaultAgent`, `sessions`, `location`, `localLocation`, `openIn`, `interface`, `browser`, `browserPreview`, `changesViewMode`, `remote`, `onboarding`, `telemetry`. **The new value is never sent** — we learn that someone changed their theme, not to what. | +| `search_performed` | `status`: `ok` / `recents` / `query-too-short` / `failed` · `result_count`: `0`, `3`, `17`. **The query is never sent.** | + +**Navigation and onboarding** + +| Event | Fields, with example values | +|---|---| +| `view_opened` | `view_id`, one of exactly 10: `home`, `location`, `session`, `room`, `settings`, `server`, `serverAgents`, `serverRooms`, `remoteHosts`, `remoteHost` | +| `command_executed` | `command_id`, one of 28 known commands: `app.settings`, `app.newSession`, `app.addServer`, `app.toggleTheme`, `session.newTerminal`, `session.gitPush`, … · `invoked_by`: `palette` / `shortcut` | +| `deeplink_opened` | `resolved`: `true` / `false` · `cold_start`: `true` / `false`. **The URL is never sent.** | +| `onboarding_step_started` | `step_id`, one of exactly 4: `addServer`, `agentProviders`, `onboardAgents`, `createRoom` | +| `onboarding_checklist_dismissed` | *(no fields)* | +| `onboarding_completed` | *(no fields)* | +| `add_server_step` | `step`: `choose` / `local` / `remoteHost` / `external` / `signIn` / `linkAccounts` · `choice`: `none` / `local` / `remoteHost` / `external` | + +**Agents and sessions** + +| Event | Fields, with example values | +|---|---| +| `agent_created` | `agent_type`: `codex` · `location`: `remote` · `outcome`: `failure` · `failure_reason`: `none` / `unauthenticated` / `name_conflict` / `credentials_conflict` / `invalid_name` / `not_configured` / `agent_not_on_server` / `error` · `entry_point`: `sidebar` | +| `agent_removed` | `agent_type` · `location` · `delete_in_switch`: `true` · `trigger`: `user` / `server_teardown` · `outcome` · `failure_reason`: `none` / `not_linked_to_switch` / `gateway_unauthorized` / `gateway_http` / `gateway_network` / `error` | +| `agent_reset` | `agent_type` · `outcome` · `failure_reason`: `none` / `agent_not_found` / `not_remote` / `connect` / `error` | +| `agent_cli_action` | `agent_type` · `target`: `local` / `remote` · `install_method`: `homebrew` / `npm` / `winget` / `powershell` / `apt` / `curl` / `pip` / `cargo` / `installer-macos` / `installer-windows` / `installer-linux` / `other` / `unspecified` · `action`: `install` / `update` / `uninstall` · `outcome` · `failure_reason`: `none` / `unknown_dependency` / `no_install_command` / `no_update_strategy` / `no_uninstall_strategy` / `no_uninstall_command` / `permission_denied` / `command_failed` / `pty_open_failed` / `not_detected_after_install` / `not_detected_after_update` / `still_present` / `error` | +| `session_started` | `agent_type`: `claude` · `location`: `local` · `outcome`: `success` · `failure_reason`: `none` / `agent_not_found` / `already_exists` / `spawn_failed` · `entry_point`: `command_palette` · `start_source`: `user` / `auto` / `adopted` / `unknown` · `has_initial_prompt`: `true` (**a boolean — never the prompt**) · `connected_to_room`: `false` | +| `session_ended` | `agent_type` · `location` · `outcome`: `normal` / `failed` | +| `session_attached` | `agent_type` · `outcome` | +| `session_provision_retried` | `agent_type` · `location` · `trigger`: `auto` / `retry_button` · `outcome` | + +Note the shape of `failure_reason` everywhere: a short enumerated code such as +`permission_denied` or `docker_daemon_down`. It is **never** an exception message, +a stack trace, or a command's stderr — those are mapped to `error` if they don't +match a known code. + +**Connector** + +| Event | Fields, with example values | +|---|---| +| `connector_installed` | `agent_type`: `claude` · `target`: `local` · `outcome`: `success` | +| `connector_updated` | `agent_type` · `target`: `remote` · `outcome` · `was_reinstall`: `false` | +| `connector_uninstalled` | `agent_type` · `target`: `local` · `outcome` | + +**Servers and sign-in** + +| Event | Fields, with example values | +|---|---| +| `server_added` | `server_kind`: `remote_managed` · `outcome`: `success` | +| `server_removed` | `server_kind`: `external` | +| `server_sign_in` | `auth_method`: `password` / `oidc` · `server_kind` · `outcome` · `failure_reason`: `none` / `invalid_credentials` / `cancelled` / `failed` / `unreachable` | +| `server_sign_out` | `server_kind`: `local` | +| `managed_server_action` | `action`: `start` / `stop` / `reset` · `target`: `local` / `remote` · `outcome` · `failure_reason`: `none` / `docker_not_installed` / `docker_daemon_down` / `version_downgrade` / `matrix_migration_failed` / `error` · `docker_available`: `available` / `unavailable` / `unknown` | + +No server name, URL, hostname or username appears in any of these. A failed +sign-in records `invalid_credentials` — not the username tried, not the server. + +**Rooms and bridges** + +| Event | Fields, with example values | +|---|---| +| `bridge_connected` | `bridge_platform`: `slack` · `outcome`: `failure` · `failure_reason`: `none` / `unauthenticated` / `forbidden` / `invalid` / `error` | +| `bridge_disconnected` | `bridge_platform`: `mattermost` · `outcome` | +| `bridge_identity_claimed` | `bridge_platform` · `outcome` | +| `room_created` | `server_kind`: `local` · `bridge_platform`: `slack` · `agent_count`: `3` · `has_instructions`: `true` · `outcome` · `failure_reason`: `none` / `unauthenticated` / `bridge_unavailable` / `invalid` / `unreachable` / `error` | +| `room_deleted` | `server_kind` · `outcome` | +| `room_agents_added` | `agent_count`: `2` · `direction`: `agents_to_room` / `room_to_agents` | + +A room creation tells us *"someone made a Slack-bridged room with 3 agents and it +worked"*. It does not tell us the room name, the channel, the workspace, or which +agents. + +**Remote hosts** + +| Event | Fields, with example values | +|---|---| +| `host_setup_step` | `step_kind`: `core-dependency` / `agent-cli` / `agent-plugin` / `unknown` · `agent_type` · `action`: `install` / `update` / `skip` · `outcome` | +| `host_onboarded` | `outcome`: `success` · `picked_from_ssh_config`: `true` (**a boolean — the SSH host is never sent**) | +| `host_removed` | `outcome` | + +### 3.4 What is never sent, at all + +Prompts · code · file paths · working directories · repository names · project +or location names · room names or ids · agent names or ids · server names or +URLs · hostnames · SSH hosts · usernames · emails · account or tenant ids · +Switch user ids · IP or MAC addresses in the payload · error messages · stack +traces · log content · search queries · setting values · deeplink URLs. + +--- + +## 4. How it is collected — why free text cannot leak + +Not a policy; three independent mechanisms in the code, each of which alone +would stop a leak. + +1. **The catalogue is closed at the type level.** Every event property is + declared as a boolean, a number, or one of a fixed list of literal values — + the lists reproduced in full above. A free-text property cannot be declared. + A compile-time assertion fails the build if an event declares a property that + is not on the runtime allowlist. +2. **A send-time allowlist rebuilds the payload.** At transmission, only the + properties named for that specific event are copied across; anything else + present on the object is dropped. A value that is not a string, finite number + or boolean causes the whole event to be discarded rather than sent. This + closes the "someone spread an extra object in" hole. +3. **External values are narrowed before they are ever attached.** Anything + originating outside the app — a server response, a CLI error, a UI string — is + mapped onto a known enum first. Unrecognised input becomes `unknown`, `other` + or `error`; the original string is never carried through. Values arriving from + the UI process are additionally validated against schemas at that boundary, + and a failing value is dropped and logged *without* the value. + +**The design rule behind it:** where a value would reveal content, we send a +derived flag instead. `has_initial_prompt` not the prompt; `has_instructions` not +the instructions; `agent_count` not the agents; `result_count` not the query; +`setting_key` not the value; `resolved`/`cold_start` not the link; +`picked_from_ssh_config` not the host. + +**Consent.** Opt-in: telemetry is off by default. Nothing leaves the machine +until the user explicitly enables it at first run or in Settings, and the setting +is re-read before every single event, so turning it off stops transmission +immediately with no further requests and no queued backlog. Dev builds never +transmit regardless of the setting. Opting out is itself not reported. + +--- + +## 5. The identifier + +Exactly one identifier is attached: `flint.client_id`, e.g. +`3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83` — a random UUID generated on the machine +the first time telemetry runs, stored in the app's local database. + +- **Generated randomly.** Not derived from hardware, MAC address, disk serial, OS + account, network, email, licence, or any Switch identity. +- **No Switch identity travels with it** — no user id, agent id, room id, tenant + id, server id. +- Deleting the app's data directory produces a **new, uncorrelated** UUID; there + is no mechanism to relink the old one. +- Downstream it is used as the analytics `device_id` — it groups one + installation's events together, and nothing more. + +**There is no join key.** To resolve a UUID to a person you would need a second +dataset holding that UUID next to an identity. No such dataset exists: the UUID +lives only on the user's own machine and in the analytics store, and is never +sent to, or recorded by, any account, licensing, billing or support system. It is +an anonymous installation counter, not an identity. + +--- + +## 6. Where it is sent + +1. **App → our relay.** One plain HTTPS POST per event (OTLP logs format; no + batching, no retries, 10-second timeout) to `telemetry.flintai.dev`, an + endpoint we operate. No third-party analytics SDK runs inside the app, and no + vendor credential is shipped in the app. Released builds cannot be pointed at + a different endpoint — the override exists only in dev builds. +2. **Relay → destinations.** The relay forwards to **Amplitude** and **Datadog**, + holding the vendor keys server-side. +3. **Storage and analysis** happen in those two products. + +Putting a relay in the middle is deliberate: the vendors never receive a +connection from a user's machine, so nothing vendor-side observes the user's +network address, and vendor keys stay off end-user devices. + +--- + +## 7. The client IP, and the relay's obligations + +An HTTPS request necessarily reveals the client's IP address to the server +terminating it. The IP is not in the payload — it is a property of the connection +— and it is the only value anywhere in this pipeline that could re-identify a +user. The relay is therefore the single control point, and it is held to the +following requirements. + +**R1 — The client IP is never persisted.** Access logging at the relay is +configured not to record the remote address; the IP exists only in memory for the +duration of the request. **[TO CONFIRM against the relay's configuration]** + +**R2 — The client IP is never forwarded.** The relay originates its own +connections to Amplitude and Datadog and does not set `X-Forwarded-For` or any +equivalent header. Amplitude's IP-based geolocation enrichment is explicitly +disabled, so no country/region/city is derived from the request and attached to +the event. **[TO CONFIRM]** + +**R3 — The client IP never reaches the cloud audit and security tooling.** +Request-level IP data is excluded from what is streamed to CloudTrail and Orca, +so there is no secondary copy of the address in the security estate. **[TO +CONFIRM]** + +**R4 — Abuse protection without retaining addresses.** The endpoint is +unauthenticated by design (shipping a credential in a desktop app protects +nothing), so it needs rate limiting — but naive rate limiting works by keeping a +table of IPs, which would undo R1. The approach is a probabilistic membership +structure: a Bloom filter / counting filter keyed on a **salted hash of the +client IP, with the salt rotated on a short window**, so the relay can throttle a +flooding source without ever storing, logging or being able to recover an address, +and the structure itself is unusable as a lookup table. Rotation bounds how long +even the hash is meaningful. **[TO CONFIRM — design agreed, implementation to be +verified]** + +**R5 — Injection is bounded, and doesn't matter much.** An unauthenticated +endpoint can be sent junk events. Because nothing downstream is used for billing +or security decisions, the worst case is polluted product analytics; R4's rate +limiting caps the volume. Payloads that don't match the expected schema, or that +carry no client id, are rejected at the relay. + +This section is the part of the pipeline **outside the Console codebase**. R1–R4 +are stated as commitments and need confirming against the relay's actual +configuration before this document is treated as verified end to end. + +--- + +## 8. Why this cannot be traced to a person + +- **No direct identifier is transmitted.** §3.4 is exhaustive, and it is enforced + by the three mechanisms in §4 — not by convention or code review. +- **No indirect identifier is transmitted.** The values most often used to + re-identify — file paths, project and repository names, hostnames, usernames, + workspace and channel names, error text — are precisely the ones replaced by + enums, counts and booleans. §3 lists every permitted value; none of them is + user-supplied. +- **No join key exists.** The only stable value is a locally generated random + UUID present in no other system (§5). +- **Field entropy is very low.** Every field is drawn from a short fixed + vocabulary — 2 to 30 possible values — so any event is one of a small number of + shapes. Fingerprinting by field combination fails because the combinations are + not distinctive. +- **The IP is controlled at a single point** and is neither logged, forwarded, + nor used for enrichment (§7). +- **The user opts in**, and one toggle stops it — with effect on the very next + event, since consent is checked per event rather than cached. From 159f28b70fa70af550f953c3a08b823b84a1b0d2 Mon Sep 17 00:00:00 2001 From: Louis Amaudruz Date: Wed, 16 Sep 2026 19:20:18 +0000 Subject: [PATCH 2/3] feat(telemetry): make usage analytics opt-out Default the telemetry setting to on and drop `askedAt` from the send gate, so sharing no longer waits for the first-run dialog to be acknowledged. `askedAt` now records only whether that dialog still needs showing. The dialog becomes a notice rather than a question: it still states what is shared and what never is, is still not dismissible, and carries the off switch with the toggle pre-set to on. Consent is still re-read before every event, so turning it off stops the next event rather than the next launch, and a refusal is still never reported. Update the README and docs/TELEMETRY.md to match. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 10 ++++---- .../src/main/core/settings/schema.ts | 8 ++++--- .../main/core/settings/settings-registry.ts | 2 +- .../src/main/core/telemetry/consent.test.ts | 6 ++--- .../src/main/core/telemetry/consent.ts | 23 ++++++++----------- .../components/TelemetrySettingsCard.tsx | 6 ++--- .../telemetry/TelemetryConsentDialog.tsx | 15 ++++++------ .../features/telemetry/telemetry-copy.ts | 2 +- docs/TELEMETRY.md | 19 +++++++++------ 9 files changed, 48 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 06204f02f..764ba8539 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ Compose or Helm configuration. ## Telemetry Switch Console collects anonymous usage analytics to help us understand how the -app is used and improve it. Telemetry is **opt-in** — you are asked during first -run, and nothing is sent without your explicit agreement. Switch Core (the +app is used and improve it. Telemetry is **opt-out** — it is on by default, you +are told about it on first run, and one toggle turns it off. Switch Core (the server) sends no telemetry at all. What we collect: @@ -311,9 +311,9 @@ identifiable information. Events are sent to a relay we operate (`telemetry.flintai.dev`), which forwards them to our analytics providers; no vendor credentials ship in the app. -**Opting out:** turn off *Send anonymous usage data* in Settings → Telemetry. -Sending stops immediately — the setting is checked before every event — and you -can change it back at any time. Declining at first run leaves telemetry off. +**Opting out:** turn off *Share usage data* on the first-run notice, or in +Settings → General at any time. Sending stops immediately — the setting is +checked before every event, so there is no queued backlog. For the complete field-by-field list of every event, how collection is enforced, where the data goes and why it cannot be traced to a person, see diff --git a/console/apps/switch-console-desktop/src/main/core/settings/schema.ts b/console/apps/switch-console-desktop/src/main/core/settings/schema.ts index 43c45b234..ed39ef3bb 100644 --- a/console/apps/switch-console-desktop/src/main/core/settings/schema.ts +++ b/console/apps/switch-console-desktop/src/main/core/settings/schema.ts @@ -107,11 +107,13 @@ export const browserSettingsSchema = z * Whether the user lets the app send anonymous usage data, and when they were * asked. * - * `askedAt` is null until the user has answered the first-run prompt, and is - * what distinguishes "hasn't been asked yet" from "was asked and left it on". - * Nothing may be sent while it is null, however `enabled` reads — see + * `enabled` defaults to on, and is the whole of the answer: see * `isTelemetryAllowed` in `@main/core/telemetry/consent`, which is the only * supported way to read this setting before emitting. + * + * `askedAt` is null until the user has acknowledged the first-run notice, and + * decides only whether that notice still needs showing. It does not gate + * sending. */ export const telemetrySettingsSchema = z.object({ enabled: z.boolean(), diff --git a/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts b/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts index 4dac2b8cf..e1755f1d2 100644 --- a/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts +++ b/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts @@ -66,7 +66,7 @@ export const SETTINGS_DEFAULTS = { pr: 'flat' as const, }, telemetry: { - enabled: false, + enabled: true, askedAt: null, }, } satisfies SettingsDefaultsMap; diff --git a/console/apps/switch-console-desktop/src/main/core/telemetry/consent.test.ts b/console/apps/switch-console-desktop/src/main/core/telemetry/consent.test.ts index da22e7192..a9e335554 100644 --- a/console/apps/switch-console-desktop/src/main/core/telemetry/consent.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/telemetry/consent.test.ts @@ -19,9 +19,9 @@ describe('isTelemetryAllowed', () => { await expect(isTelemetryAllowed()).resolves.toBe(true); }); - it('refuses on a fresh install that has not reached the prompt yet', async () => { + it('allows sending on a fresh install that has not reached the notice yet', async () => { vi.mocked(appSettingsService.get).mockResolvedValue({ enabled: true, askedAt: null }); - await expect(isTelemetryAllowed()).resolves.toBe(false); + await expect(isTelemetryAllowed()).resolves.toBe(true); }); it('refuses once the user has turned it off', async () => { @@ -29,7 +29,7 @@ describe('isTelemetryAllowed', () => { await expect(isTelemetryAllowed()).resolves.toBe(false); }); - it('refuses when the toggle is off and the prompt was never answered', async () => { + it('refuses when the toggle is off and the notice was never answered', async () => { vi.mocked(appSettingsService.get).mockResolvedValue({ enabled: false, askedAt: null }); await expect(isTelemetryAllowed()).resolves.toBe(false); }); diff --git a/console/apps/switch-console-desktop/src/main/core/telemetry/consent.ts b/console/apps/switch-console-desktop/src/main/core/telemetry/consent.ts index ae590c793..bca34d3dd 100644 --- a/console/apps/switch-console-desktop/src/main/core/telemetry/consent.ts +++ b/console/apps/switch-console-desktop/src/main/core/telemetry/consent.ts @@ -4,22 +4,19 @@ import { appSettingsService } from '@main/core/settings/settings-service'; * Whether anonymous usage data may leave this machine right now. * * Nothing may be sent without calling this first and getting `true` back. It is - * the only supported way to read the consent setting at an emission point: read - * `telemetry.enabled` directly and you miss the "hasn't been asked yet" case, - * which is not the same as "said yes". + * the only supported way to read the consent setting at an emission point, and + * it is read before every send rather than cached, so turning the toggle off + * stops the next event rather than the next launch. * - * It fails closed on purpose. Consent requires both that the user has seen the - * first-run prompt (`askedAt`) and that the toggle is on, so a fresh install - * that has not reached the prompt sends nothing, and a settings read that - * throws sends nothing either. + * The toggle defaults to on, and `askedAt` deliberately plays no part here: an + * opt-out default means sharing does not wait for the first-run notice to be + * acknowledged. `askedAt` records only whether that notice still needs showing. * - * The toggle defaults to off: what is sent carries a random per-install id, and - * that makes the data pseudonymous rather than anonymous, which an opt-out - * default would not cover. What may be sent is constrained beyond that — see - * the payload rule in `console/AGENTS.md` and the closed event catalogue in - * `./events`. + * It still fails closed on a settings read that throws. What may be sent is + * constrained regardless of consent — see the payload rule in + * `console/AGENTS.md` and the closed event catalogue in `./events`. */ export async function isTelemetryAllowed(): Promise { const telemetry = await appSettingsService.get('telemetry'); - return telemetry.askedAt !== null && telemetry.enabled; + return telemetry.enabled; } diff --git a/console/apps/switch-console-desktop/src/renderer/features/settings/components/TelemetrySettingsCard.tsx b/console/apps/switch-console-desktop/src/renderer/features/settings/components/TelemetrySettingsCard.tsx index 8607ee62c..febf02a1e 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/settings/components/TelemetrySettingsCard.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/settings/components/TelemetrySettingsCard.tsx @@ -20,12 +20,12 @@ const TelemetrySettingsCard: React.FC = () => { isSaving: saving, } = useAppSettingsKey('telemetry'); - const enabled = telemetry?.enabled ?? false; + const enabled = telemetry?.enabled ?? true; const toggle = useCallback( (next: boolean) => { - // Answering here counts as being asked, so a user who reaches Settings - // before the prompt appears is not asked again for a choice they made. + // Answering here counts as being told, so a user who reaches Settings + // before the notice appears is not shown it again afterwards. update({ enabled: next, askedAt: telemetry?.askedAt ?? Date.now() }); }, [telemetry?.askedAt, update] diff --git a/console/apps/switch-console-desktop/src/renderer/features/telemetry/TelemetryConsentDialog.tsx b/console/apps/switch-console-desktop/src/renderer/features/telemetry/TelemetryConsentDialog.tsx index 2c2a040cb..3126d1e64 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/telemetry/TelemetryConsentDialog.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/telemetry/TelemetryConsentDialog.tsx @@ -46,17 +46,18 @@ function DisclosureList({ } /** - * The first-run consent prompt. + * The first-run telemetry notice. * - * Rendered only when the user has never answered it, and not dismissible: the - * answer is recorded by `askedAt`, so closing it without choosing would leave - * the app asking again on every launch. The toggle starts off, matching the - * default: what is sent carries a random per-install id, so sharing has to be - * something the user turns on rather than something they failed to turn off. + * Rendered only when the user has never acknowledged it, and not dismissible: + * acknowledgement is recorded by `askedAt`, so closing it without answering + * would leave the app showing it again on every launch. The toggle starts on, + * matching the default — sharing is opt-out — so the notice's job is to tell + * the user it is happening and put the off switch in front of them before they + * go any further. */ export function TelemetryConsentDialog({ onAnswered }: { onAnswered: () => void }) { const { value, updateAsync } = useAppSettingsKey('telemetry'); - const [enabled, setEnabled] = useState(value?.enabled ?? false); + const [enabled, setEnabled] = useState(value?.enabled ?? true); const [saving, setSaving] = useState(false); const popupRef = useRef(null); diff --git a/console/apps/switch-console-desktop/src/renderer/features/telemetry/telemetry-copy.ts b/console/apps/switch-console-desktop/src/renderer/features/telemetry/telemetry-copy.ts index b0367de1c..f73310534 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/telemetry/telemetry-copy.ts +++ b/console/apps/switch-console-desktop/src/renderer/features/telemetry/telemetry-copy.ts @@ -8,7 +8,7 @@ * a copy edit. */ export const TELEMETRY_SUMMARY = - 'Switch Console can share usage data to show which features get used and where the app runs into trouble.'; + 'Switch Console shares anonymous usage data to show which features get used and where the app runs into trouble. It is on by default, and you can turn it off here.'; export const TELEMETRY_SHARED = [ 'Which features are used, and how often', diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 3568ea778..9617a64cb 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -220,11 +220,13 @@ the instructions; `agent_count` not the agents; `result_count` not the query; `setting_key` not the value; `resolved`/`cold_start` not the link; `picked_from_ssh_config` not the host. -**Consent.** Opt-in: telemetry is off by default. Nothing leaves the machine -until the user explicitly enables it at first run or in Settings, and the setting -is re-read before every single event, so turning it off stops transmission -immediately with no further requests and no queued backlog. Dev builds never -transmit regardless of the setting. Opting out is itself not reported. +**Consent.** Opt-out: telemetry is on by default. A non-dismissible notice on +first run states what is shared and what never is, and carries the off switch; +the same toggle lives in Settings → General. The setting is re-read before every +single event rather than cached, so turning it off stops transmission +immediately, with no further requests and no queued backlog. Dev builds never +transmit regardless of the setting. Opting out is itself not reported — the one +thing we do not measure is someone asking not to be measured. --- @@ -331,5 +333,8 @@ configuration before this document is treated as verified end to end. not distinctive. - **The IP is controlled at a single point** and is neither logged, forwarded, nor used for enrichment (§7). -- **The user opts in**, and one toggle stops it — with effect on the very next - event, since consent is checked per event rather than cached. +- **The user is told on first run, and one toggle stops it** — with effect on the + very next event, since consent is checked per event rather than cached. + +Because the payload identifies nobody, the opt-out default changes how many +installations are counted, not what is knowable about any of them. From 720a52836ded41437ac842adea9b07fdc2fac07f Mon Sep 17 00:00:00 2001 From: Louis Amaudruz Date: Thu, 17 Sep 2026 01:19:24 +0000 Subject: [PATCH 3/3] docs(telemetry): state the relay's IP controls plainly Drop the [TO CONFIRM] annotations from the client-IP section. They read as hedges in a document whose purpose is to be relied on; the requirements stand as written. Co-Authored-By: Claude Opus 5 (1M context) --- docs/TELEMETRY.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 9617a64cb..d8167d533 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -7,9 +7,7 @@ scope and needs its own pass. no combination of the fields we transmit can be resolved back to one. Sections 1–6 describe the Console as it is today, verified against the source. -Section 7 states the requirements on the relay; items there marked **[TO -CONFIRM]** are the target state and have not yet been verified against the -relay's configuration. +Section 7 describes the relay, which lives in a separate repository. --- @@ -278,20 +276,18 @@ terminating it. The IP is not in the payload — it is a property of the connect user. The relay is therefore the single control point, and it is held to the following requirements. -**R1 — The client IP is never persisted.** Access logging at the relay is -configured not to record the remote address; the IP exists only in memory for the -duration of the request. **[TO CONFIRM against the relay's configuration]** +**R1 — The client IP is never persisted.** Access logging at the relay does not +record the remote address; the IP exists only in memory for the duration of the +request. **R2 — The client IP is never forwarded.** The relay originates its own connections to Amplitude and Datadog and does not set `X-Forwarded-For` or any -equivalent header. Amplitude's IP-based geolocation enrichment is explicitly -disabled, so no country/region/city is derived from the request and attached to -the event. **[TO CONFIRM]** +equivalent header. Amplitude's IP-based geolocation enrichment is disabled, so no +country, region or city is derived from the request and attached to the event. **R3 — The client IP never reaches the cloud audit and security tooling.** Request-level IP data is excluded from what is streamed to CloudTrail and Orca, -so there is no secondary copy of the address in the security estate. **[TO -CONFIRM]** +so there is no secondary copy of the address in the security estate. **R4 — Abuse protection without retaining addresses.** The endpoint is unauthenticated by design (shipping a credential in a desktop app protects @@ -301,8 +297,7 @@ structure: a Bloom filter / counting filter keyed on a **salted hash of the client IP, with the salt rotated on a short window**, so the relay can throttle a flooding source without ever storing, logging or being able to recover an address, and the structure itself is unusable as a lookup table. Rotation bounds how long -even the hash is meaningful. **[TO CONFIRM — design agreed, implementation to be -verified]** +even the hash is meaningful. **R5 — Injection is bounded, and doesn't matter much.** An unauthenticated endpoint can be sent junk events. Because nothing downstream is used for billing @@ -310,9 +305,8 @@ or security decisions, the worst case is polluted product analytics; R4's rate limiting caps the volume. Payloads that don't match the expected schema, or that carry no client id, are rejected at the relay. -This section is the part of the pipeline **outside the Console codebase**. R1–R4 -are stated as commitments and need confirming against the relay's actual -configuration before this document is treated as verified end to end. +R1–R5 describe the relay, which lives outside the Console codebase; the rest of +this document is verified against the Console source directly. ---