# Telemetry and privacy

> Consent gating, event schemas in `telemetry-events.ts`, dashboard-ready boundaries in repo docs, and what usage data Orca collects.

- Repository: stablyai/orca
- GitHub: https://github.com/stablyai/orca
- Human docs: https://grok-wiki.com/public/docs/stablyai-orca-2036d532bf1c
- Complete Markdown: https://grok-wiki.com/public/docs/stablyai-orca-2036d532bf1c/llms-full.txt

## Source Files

- `src/shared/telemetry-events.ts`
- `src/main/telemetry/client.ts`
- `src/main/telemetry/consent.ts`
- `docs/reference/telemetry-availability.md`
- `README.md`

---

---
title: "Telemetry and privacy"
description: "Consent gating, event schemas in `telemetry-events.ts`, dashboard-ready boundaries in repo docs, and what usage data Orca collects."
---

Orca’s anonymous product telemetry runs only in the Electron **main process** on official `stable`/`rc` CI builds: the renderer calls `telemetry:track` over IPC, main validates payloads against Zod schemas in `src/shared/telemetry-events.ts`, applies consent and burst limits, and sends events to PostHog (`https://us.i.posthog.com`) with `$process_person_profile: false`. The bundled `orca` CLI does not implement this lane.

## Architecture

```text
Renderer (track / setOptIn / acknowledgeBanner)
        │ IPC
        ▼
src/main/ipc/telemetry.ts  ── inject nth_repo_added / onboarding cohort
        │
        ▼
src/main/telemetry/client.ts
  1. shutdown gate
  2. burst cap (before consent)
  3. resolveConsent()
  4. validator (eventSchemas + commonProps)
  5. posthog-node capture
```

| Layer | Location | Role |
| --- | --- | --- |
| Event contracts | `src/shared/telemetry-events.ts` | Zod schemas, `EventMap`, enums, `COHORT_EXTENDED` / onboarding cohort sets |
| Consent | `src/main/telemetry/consent.ts` | `resolveConsent()` precedence; types in `src/shared/telemetry-consent-types.ts` |
| Transport | `src/main/telemetry/client.ts` | PostHog client, `track()`, `setOptIn()`, `trackAppOpenedOnce()` |
| IPC boundary | `src/main/ipc/telemetry.ts` | Renderer-facing handlers; main derives `via` for opt-in/out |
| Renderer wrapper | `src/renderer/src/lib/telemetry.ts` | Typed `track()`; no PostHog SDK in renderer |
| Dashboard boundaries | `docs/reference/telemetry-availability.md` | Append-only rollout timestamps for PostHog tiles (not sent on the wire) |

<Note>
Product telemetry is separate from the **error-tracking / observability** lane (`initObservability()` in `src/main/index.ts`). Crash and span data follow their own gates (`ORCA_DIAGNOSTICS_DISABLED`, etc.) and are not described by `telemetry-events.ts`.
</Note>

## When events transmit

All of the following must be true for a normal `track()` call to reach PostHog:

| Gate | Behavior |
| --- | --- |
| `TELEMETRY_ENABLED` | Compile-time flag in `client.ts` (currently `true`; release scripts grep this symbol) |
| Official build | `ORCA_BUILD_IDENTITY` is `stable` or `rc` **and** `ORCA_POSTHOG_WRITE_KEY` is non-empty (injected at CI build time via electron-vite `define`) |
| Consent | `resolveConsent()` returns `{ effective: 'enabled' }` |
| Transport initialized | Valid `installId`, common props pass `commonPropsSchema`, PostHog client created |
| Not shutting down | `shutdownTelemetry()` sets a drop gate before flush |
| Burst caps | Per-event token bucket, 1 000 events/session ceiling, unknown event names rejected |

Contributor builds (`pnpm dev`), local rebuilds, and tests get `IS_OFFICIAL_BUILD === false`: `track()` returns immediately with no network, burst, or consent work.

## Consent and gating

`resolveConsent(settings)` is the single authority. Environment overrides are **non-persistent** — they affect the current process only and never write `GlobalSettings.telemetry.optedIn`.

| Precedence | Condition | `effective` | `reason` (if disabled) |
| --- | --- | --- | --- |
| 1 | `DO_NOT_TRACK` is `1` or `true` (trimmed, case-insensitive) | `disabled` | `do_not_track` |
| 2 | `ORCA_TELEMETRY_DISABLED` is `1` or `true` | `disabled` | `orca_disabled` |
| 3 | Any listed CI env var is set and non-empty (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, `TRAVIS`, `BUILDKITE`, `JENKINS_URL`, `TEAMCITY_VERSION`) | `disabled` | `ci` |
| 4 | `settings.telemetry.optedIn === true` | `enabled` | — |
| 5 | `settings.telemetry.optedIn === false` | `disabled` | `user_opt_out` |
| 6 | `settings.telemetry.optedIn === null` | `pending_banner` | — |
| — | Missing `telemetry` block (pre-migration) | `pending_banner` | — |

Misconfigured env values (for example `DO_NOT_TRACK=yes`) log once to stderr and are treated as unset.

### Persisted telemetry settings

Stored under `GlobalSettings.telemetry` (deep-merged on updates):

| Field | Meaning |
| --- | --- |
| `installId` | Anonymous UUID v4; PostHog `distinctId`; generated on first migration if missing |
| `optedIn` | `true` (new installs default on), `false` (user opt-out), or `null` (existing users awaiting banner) |
| `existedBeforeTelemetryRelease` | `true` if profile existed before telemetry shipped → first-launch banner; `false` for fresh installs (no banner) |

The block intentionally holds **only** consent and identity — no DAU counters, session heartbeats, or crash IDs (those live elsewhere).

### First-launch notice (existing users only)

Users with `existedBeforeTelemetryRelease === true` and `optedIn === null` see `FirstLaunchBanner` until they resolve it. **No events transmit** while consent is `pending_banner`, including `app_opened`.

| UI action | IPC | Persisted `optedIn` | Telemetry event |
| --- | --- | --- | --- |
| Got it / ✕ | `telemetry:acknowledgeBanner` | `true` | None (silent acknowledge) |
| Opt out | `telemetry:setOptIn(false)` | `false` | `telemetry_opted_out { via: 'first_launch_banner' }` (captured before SDK `optOut()`) |
| Privacy policy link | — | unchanged | none |

New installs (`existedBeforeTelemetryRelease === false`) have no first-launch UI; disclosure is at install time and `optedIn` defaults to `true`.

`app_opened` fires **once per session** after consent is enabled — on main window `did-finish-load` when `resolveConsent` is already `enabled`, or immediately after banner acknowledge / explicit opt-in from `setOptIn`.

### Settings → Privacy & Telemetry

`PrivacyPane` toggles `telemetrySetOptIn` unless env-blocked (`DO_NOT_TRACK`, `ORCA_TELEMETRY_DISABLED`, or CI). Effective state for helper text comes from `telemetry:getConsentState` (renderer cannot read env vars directly). Product copy links to `https://www.onorca.dev/docs/telemetry` (`PRIVACY_URL`).

`via` on opt-in/out events is **never** passed from the renderer. Main derives it:

- `first_launch_banner` — existing user, `optedIn === null`, incoming `false` (Turn off on notice)
- `settings` — all other flips (Privacy pane, post-banner changes)

Env-var disables do not emit `telemetry_opted_in` / `telemetry_opted_out`.

## Identity and common properties

Every transmitted event merges **common props** (validated once at `initTelemetry`):

| Property | Source |
| --- | --- |
| `app_version` | `app.getVersion()` |
| `platform`, `arch`, `os_release` | Node `os` module |
| `install_id` | Settings; min length 1, max 64 |
| `session_id` | New UUID per process |
| `orca_channel` | `stable` or `rc` from `ORCA_BUILD_IDENTITY` |

PostHog capture sets `distinctId: install_id` and `$process_person_profile: false` so anonymous events do not materialize person profiles. GeoIP is disabled on the SDK (`disableGeoip: true`).

## Event schema model

`src/shared/telemetry-events.ts` is the only contract source:

- **`eventSchemas`** — runtime validator input; adding a key updates compile-time `EventMap` via `z.infer`
- **`.strict()`** on every object — unknown keys fail validation and drop the event
- **Closed enums** — no raw `string` domains for product dimensions
- **String caps** — `.max(N)` on free-form fields (for example 64 on common props, 200 on `agent_hook_install_failed.error_message`)
- **Schema evolution** — breaking changes need a new event name (e.g. `agent_started_v2`); additive fields use `.optional()`

Main injects cohort fields at the IPC layer only where schemas declare them:

- **`nth_repo_added`** — repo count at emit time (`COHORT_EXTENDED` events); `0` means no repos yet, not “missing”
- **`cohort`** — `fresh_install` \| `upgrade_backfill` on onboarding events (`ONBOARDING_COHORT_SET`)

### Event families (registry)

| Family | Example event names | Typical dimensions |
| --- | --- | --- |
| Session / activation | `app_opened`, `repo_added`, `workspace_created`, `workspace_create_failed`, `agent_started`, `agent_error` | `method`, `source`, `agent_kind`, `launch_source`, `error_class`, `nth_repo_added` |
| Add-repo funnel | `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `setup_script_prompt_shown`, `setup_script_prompt_action` | setup actions, count buckets, provider enums |
| Onboarding wizard | `onboarding_started`, `onboarding_step_*`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, Ghostty import events, feature-setup events | `step`, `value_kind`, `cohort`, closed failure reasons |
| Feature wall | `feature_wall_opened`, `feature_wall_closed`, tile/group/feature events | `source`, `dwell_ms`, tour depth buckets |
| Settings | `settings_changed` | Whitelisted boolean keys only (`editorAutoSave`, `openLinksInApp`, experimental flags, etc.) — not the telemetry toggle |
| Consent audit | `telemetry_opted_in`, `telemetry_opted_out` | `via`: `first_launch_banner` \| `settings` |
| Agents / hooks | `agent_hook_install_failed`, `agent_hook_unattributed` | `agent` enum; truncated `error_message` or `reason` |
| Smart sort | `smart_sort_class_distribution`, `smart_sort_class_1_promotion`, `smart_to_recent_switch` | class counts, promotion `cause` |

`agent_error` and `workspace_create_failed` use **enum-only** error taxonomies — `error_message` / `error_stack` are deliberately absent and rejected by `.strict()`.

`AGENT_KIND_VALUES` maps product agent IDs (e.g. `claude-code` on the wire vs `claude` in launch settings) via `tuiAgentToAgentKind` in `src/shared/agent-kind.ts`.

## What is not collected (product telemetry)

By schema design and transport policy, product telemetry does **not** include:

- Repository paths, branch names, clone URLs, or file contents
- Prompts, terminal output, or diff text
- Raw error stacks on `agent_error` / `workspace_create_failed` (classified enums only)
- Usernames, emails, or API keys
- Per-event `env: dev` discriminator (non-official builds do not transmit at all)

The first-launch banner and Privacy pane state this explicitly: anonymous **counts and funnels** of feature usage and failure classes, not content.

<Warning>
`agent_hook_install_failed` allows `error_message` up to 200 characters (truncated at call sites). Treat this as config-shape errors, not user content — still bounded and enum-scoped elsewhere.
</Warning>

## IPC surface (renderer)

| Channel | Input | Notes |
| --- | --- | --- |
| `telemetry:track` | `name: string`, `props?: object` | Injects cohorts; validates in `track()` |
| `telemetry:setOptIn` | `optedIn: boolean` | ≤5 mutations/session; derives `via` |
| `telemetry:acknowledgeBanner` | — | Only when `existedBeforeTelemetryRelease && optedIn === null` |
| `telemetry:getConsentState` | — | Read-only; no rate limit |

Threat model: renderer content (agents, MCP, markdown) is untrusted. Handlers narrow types, cap consent mutations, and run burst limits **before** consent reads on `track`.

## Burst and validation

| Bucket | Limit |
| --- | --- |
| Per event name | 30/min (20/min for `agent_error`), token refill |
| Per session | 1 000 events total |
| Consent mutations | 5/session (`setOptIn` + `acknowledgeBanner`) |

`src/main/telemetry/validator.ts` wraps `eventSchemas[name].safeParse(props)`; failures are fail-closed with rate-limited `console.warn` (≤1 per event name per 60 s).

## Opt out (operator and user)

| Mechanism | Scope |
| --- | --- |
| Settings → Privacy → toggle off | Persists `optedIn: false`; `user_opt_out` |
| First-launch “Opt out” | Same + `telemetry_opted_out` with `via: first_launch_banner` |
| `DO_NOT_TRACK=1` or `true` | Process-wide disable |
| `ORCA_TELEMETRY_DISABLED=1` or `true` | Process-wide disable |
| CI env vars (see consent table) | Process-wide disable while CI is detected |

Unset env kill switches and restart to restore the stored preference.

## Dashboard-ready boundaries

`docs/reference/telemetry-availability.md` is the append-only reference for analytics authors. It records merge commits, first release, PostHog `first_seen_at_utc`, and `dashboard_ready_at_utc` per rollout — **not** shipped in event payloads.

Cross-cutting rules from that doc:

- Use `app_opened` as the return marker (once per session after gating).
- Do not treat missing `nth_repo_added` as `0`; missing means pre-rollout or classifier fail-soft.
- Fresh-install onboarding denominators anchor on `onboarding_started { cohort: 'fresh_install' }`.
- `workspace_created` means create-worktree IPC succeeded, not “user revealed workspace.”
- `agent_started` means PTY spawn with telemetry attached, not “user sent a prompt.”

When `dashboard_ready_at_utc` is `TBD`, avoid saving production cohort/retention tiles; QA charts must note the boundary is provisional.

## Privacy diagnostics (separate lane)

The Privacy settings pane also exposes **diagnostic bundles** (`PrivacyDiagnosticsSection`) for support tickets. That flow uses `window.api.diagnostics.*` and is user-initiated — it is not part of anonymous product telemetry or `telemetry-events.ts`.

## Related pages

<CardGroup>
  <Card title="Settings reference" href="/settings-reference">
    GlobalSettings fields including `telemetry` and experimental toggles whitelisted on `settings_changed`.
  </Card>
  <Card title="Terminals and agents" href="/terminals-and-agents">
    PTY spawn, agent kinds, and hook telemetry that map to `agent_started` / `agent_error`.
  </Card>
  <Card title="Develop and build" href="/develop-and-build">
    Why local dev builds do not transmit and how CI injects PostHog keys.
  </Card>
  <Card title="Contributing" href="/contributing">
    PR workflow and release verification touching telemetry constants.
  </Card>
</CardGroup>
