Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-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:

| 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 *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
[`docs/TELEMETRY.md`](docs/TELEMETRY.md).

## Contributing

Switch is being built in the open, with the people who use it. Nobody knows yet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export const SETTINGS_DEFAULTS = {
pr: 'flat' as const,
},
telemetry: {
enabled: false,
enabled: true,
askedAt: null,
},
} satisfies SettingsDefaultsMap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ 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 () => {
vi.mocked(appSettingsService.get).mockResolvedValue({ enabled: false, askedAt: 1_700_000_000 });
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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
const telemetry = await appSettingsService.get('telemetry');
return telemetry.askedAt !== null && telemetry.enabled;
return telemetry.enabled;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading