feat(telemetry): PR 1 — foundations (types, consent, migration, PRIVACY.md) (#1372)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-03 13:13:26 -07:00 committed by GitHub
parent fcdd88964e
commit d0e3a64768
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 837 additions and 5 deletions

90
PRIVACY.md Normal file
View File

@ -0,0 +1,90 @@
# Orca Privacy Notice
Version: 1.0 — Last updated: 2026-05-02
Orca is a local desktop application for running CLI coding agents across git worktrees. This document describes the anonymous product-usage telemetry we collect in packaged Orca builds, what we never collect, and how to opt out.
## Summary
- **Anonymous.** Telemetry events are keyed by a locally-generated UUID (`install_id`). No account, email, IP, or user name is collected.
- **No content.** We never transmit file contents, prompts, agent output, terminal output, repo names, branch names, URLs, paths, or commit messages. The validator fails closed on anything outside the typed event schema.
- **Cohort-aware defaults.** New installs ship with telemetry on and a dismissible first-run disclosure. If you upgraded into the first telemetry release, telemetry starts off and a banner asks for explicit opt-in.
- **Always off when:** `DO_NOT_TRACK=1`, `ORCA_TELEMETRY_DISABLED=1`, or any common CI environment variable is set (e.g. `CI`, `GITHUB_ACTIONS`).
- **Dev builds never transmit.** `pnpm dev`, contributor checkouts, and third-party forks do not carry the build-identity constant that gates transmission, so events only appear in a local console mirror.
## What we collect
Every event carries these common properties:
- `app_version` — the Orca version string.
- `platform``darwin` / `win32` / `linux`.
- `arch` — CPU architecture (`arm64`, `x64`, …).
- `os_release` — coarse OS release string (e.g. `25.3.0`). No hostname.
- `install_id` — anonymous UUID v4. Stable across launches, regenerable from Settings → Privacy.
- `session_id` — new UUID per app launch; does not persist.
- `orca_channel``stable` or `rc`. Present only on official release builds; dev builds never transmit.
The events we send (7 in total — 8 literal names, counting `telemetry_opted_in` / `telemetry_opted_out` as one matched pair):
### Lifecycle
- `app_opened` — fires when the main window finishes loading. No custom properties. Daily/weekly/monthly active users are derived server-side from distinct `install_id`s with an `app_opened` in the window; we do not emit a dedicated `daily_active_user` event.
### Repos and workspaces
- `repo_added``method`: `folder_picker` / `clone_url` / `drag_drop`. Never the repo URL, repo name, or path.
- `workspace_created``source`: entry-point enum (`command_palette` / `sidebar` / `shortcut` / `drag_drop` / `unknown`); `from_existing_branch` (bool). Never the branch name or base branch.
### Agents
- `agent_started``initial_agent_kind` (enum — `claude-code` / `codex` / `gemini` / `copilot` / `cursor` / `opencode` / `aider` / `amp` / `other`); `launch_source` (`command_palette` / `sidebar` / `tab_bar_quick_launch` / `task_page` / `new_workspace_composer` / `workspace_jump_palette` / `shortcut` / `unknown`); `request_kind` (`new` / `resume` / `followup`). This event means "what Orca launched after confirmed session creation," not a later shell-title inference. No model details, no prompt content.
- `agent_error``error_class` (closed enum of known error types); `agent_kind`; optional `error_name` drawn from a closed whitelist of error class names. Enum-only: no raw error message, no stack trace, no free-form identifiers. Per-incident error context lives only in a local diagnostic trace file on your machine; it reaches Orca only if you explicitly share a diagnostic bundle.
### Settings
- `settings_changed``setting_key` (whitelisted enum, scoped to a small set of feature-flag and UX-preference toggles); `value_kind`: `bool` / `enum`. Never the raw value of a free-form setting.
### Privacy controls
- `telemetry_opted_in` / `telemetry_opted_out` — fires exactly once at the moment of the change. `via`: `first_launch_banner` / `first_launch_notice` / `settings`. Environment-variable and CI overrides do not fire these events — they disable transmission at runtime without changing your stored preference.
## What we never send
- No file paths, repo names, branch names, URLs, commit messages, or current working directory.
- No agent prompts, responses, or terminal contents.
- No raw error messages, no stack frames, and no free-form identifiers. `agent_error` is enum-only (`error_class` + `agent_kind`, plus an optional whitelisted `error_name`). Per-incident error context stays in a local diagnostic trace file and reaches Orca only if you explicitly share a diagnostic bundle.
- No user account information (Orca has no account system).
- No precise geoip. PostHog's project-level "Discard client IP data" is on; country is the only geographic signal derived from the request, and we do not populate `$ip` ourselves.
- No person-profile materialization on the vendor side. Every event is captured with `$process_person_profile: false` so no profile is created against the anonymous `install_id`.
- No free-form strings from any UI input. Every transmitted string property is either an enum, a UUID, or a bucketed/versioned constant.
Runtime enforcement: a single `track(event, props)` wrapper with a TypeScript-typed event map and a runtime Zod validator. Events not in the map never compile; properties outside the declared shape are dropped at runtime with a warning.
## How to opt out
You can disable telemetry in three ways. Any one of them is sufficient; they compose.
1. **In the app.** Settings → Privacy → toggle "Share anonymous usage data" off. The change is immediate and persistent.
2. **`DO_NOT_TRACK=1`** — community-standard environment variable. Disables transmission for that launch. Unsetting it restores your stored preference on the next launch.
3. **`ORCA_TELEMETRY_DISABLED=1`** — Orca-specific kill switch with the same semantics as `DO_NOT_TRACK`.
CI environments are auto-detected (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, `TRAVIS`, `BUILDKITE`, `JENKINS_URL`, `TEAMCITY_VERSION`) and do not transmit.
### Residual flush on opt-out
When you flip telemetry off, events captured up to roughly 10 seconds before the toggle may still be transmitted in the final SDK batch (this is PostHog Node's flush interval). Subsequent events will not.
## Where the data goes
- **Vendor:** PostHog Cloud (`us.i.posthog.com`), **United States** region.
- **Project configuration:** session recordings disabled; precise geoip disabled (country-only); person-profile creation suppressed per event (`$process_person_profile: false`) so no profile is materialized for an anonymous `install_id`.
- **Retention:** PostHog Cloud's plan-level default. At the time of this document, the free tier retains event data for 1 year, with cold-storage thereafter per PostHog's pricing page. Paid tiers extend this. We do not set a custom retention window.
- **Access:** project membership is restricted to the telemetry owner and a single backup.
## Resetting or stopping your anonymous data
Three paths are available to you directly:
- **Reset anonymous ID** in Settings → Privacy rotates your `install_id` prospectively. Subsequent events carry a fresh UUID; events emitted before the rotation remain associated with the old UUID and are not linkable to the new one.
- **Opt out** (Settings → Privacy, `DO_NOT_TRACK=1`, or `ORCA_TELEMETRY_DISABLED=1`) stops transmission entirely from the next event onward.
- **Retention** is PostHog Cloud's plan-level default (see above); old events age out on that schedule without any action on your part.

View File

@ -149,6 +149,7 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See [PRIVACY.md](PRIVACY.md) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** Star this repo to follow along with our daily ships.
---

View File

@ -720,4 +720,90 @@ describe('Store', () => {
expect(store.getWorktreeMeta('a')).toBeUndefined()
expect(store.getWorktreeMeta('b')).toBeDefined()
})
// ── Telemetry cohort migration ─────────────────────────────────────
//
// The migration keys on `existsSync(dataFile)` rather than field-based
// inference because the `telemetry` field is new in this release: keying
// on its presence would misclassify every pre-telemetry install as fresh,
// silently flipping existing users to default-on and violating the social
// contract they installed Orca under.
it('classifies a truly fresh install as new-user cohort (file absent → optedIn=true)', async () => {
// No data file written — truly fresh install of the telemetry release.
const store = await createStore()
const t = store.getSettings().telemetry
expect(t).toBeDefined()
expect(t!.existedBeforeTelemetryRelease).toBe(false)
expect(t!.optedIn).toBe(true)
expect(t!.installId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
})
it('classifies a pre-existing install as existing-user cohort (file present → optedIn=null)', async () => {
// A pre-telemetry data file exists on disk with no telemetry block.
writeDataFile({
schemaVersion: 1,
repos: [makeRepo()],
worktreeMeta: {},
settings: { theme: 'dark' },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
const t = store.getSettings().telemetry
expect(t).toBeDefined()
expect(t!.existedBeforeTelemetryRelease).toBe(true)
expect(t!.optedIn).toBeNull()
expect(t!.installId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
// Sibling migrations still run alongside the telemetry migration.
expect(store.getSettings().theme).toBe('dark')
})
it('still classifies as existing-user cohort when the data file is corrupt', async () => {
// Load-bearing: `fileExistedOnLoad` stays true even when the parse
// throws, so the corrupt-file catch path must also apply the migration.
// Otherwise a user whose `orca-data.json` got corrupted would be
// silently opted in as if they were a fresh install.
mkdirSync(testState.dir, { recursive: true })
writeFileSync(dataFile(), '{{{corrupt json', 'utf-8')
const store = await createStore()
const t = store.getSettings().telemetry
expect(t).toBeDefined()
expect(t!.existedBeforeTelemetryRelease).toBe(true)
expect(t!.optedIn).toBeNull()
expect(t!.installId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
})
it('preserves an already-migrated telemetry block on subsequent launches', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
telemetry: {
optedIn: true,
installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
existedBeforeTelemetryRelease: false,
firstRunNoticeShown: true
}
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().telemetry).toEqual({
optedIn: true,
installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
existedBeforeTelemetryRelease: false,
firstRunNoticeShown: true
})
})
})

View File

@ -6,6 +6,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS
import { writeFile, rename, mkdir, rm } from 'fs/promises'
import { join, dirname } from 'path'
import { homedir } from 'os'
import { randomUUID } from 'node:crypto'
import type {
PersistedState,
Repo,
@ -106,9 +107,19 @@ export class Store {
}
private load(): PersistedState {
// Capture once, at the top: this is the unambiguous "has the user run
// Orca before?" signal used by the telemetry cohort migration below.
// Field-based inference (e.g., `settings.telemetry` presence) does not
// work on the telemetry release itself — `telemetry` is new here, so it
// would be absent on every pre-telemetry install and misclassify existing
// users as fresh, flipping them to default-on in violation of the
// social contract we installed them under.
const dataFile = getDataFile()
const fileExistedOnLoad = existsSync(dataFile)
let result: PersistedState | null = null
try {
const dataFile = getDataFile()
if (existsSync(dataFile)) {
if (fileExistedOnLoad) {
const raw = readFileSync(dataFile, 'utf-8')
const parsed = JSON.parse(raw) as PersistedState
@ -140,7 +151,7 @@ export class Store {
: rawOptionAsAlt === undefined || rawOptionAsAlt === 'true'
? 'auto'
: rawOptionAsAlt
return {
result = {
...defaults,
...parsed,
settings: {
@ -226,7 +237,70 @@ export class Store {
} catch (err) {
console.error('[persistence] Failed to load state, using defaults:', err)
}
return getDefaultPersistedState(homedir())
// Corrupt-file catch path and "no file on disk" path converge here. The
// telemetry migration below runs on whichever branch produced `result`,
// because a user whose `orca-data.json` got corrupted is not a fresh
// install of the telemetry release — they still count as existing and
// must see the opt-in banner, not the default-on toast.
if (result === null) {
result = getDefaultPersistedState(homedir())
}
return this.migrateTelemetry(result, fileExistedOnLoad)
}
// One-shot telemetry cohort migration. Runs on every `load()` but is a
// no-op once `existedBeforeTelemetryRelease` is set, so subsequent launches
// pay only the property lookup. Populates:
// - `existedBeforeTelemetryRelease` — cohort discriminator (drives the
// first-launch toast vs. banner in PR 3).
// - `optedIn` — new users start opted in; existing users are `null` until
// the banner resolves (the consent resolver returns `pending_banner`
// until then, so nothing transmits).
// - `installId` — anonymous UUID v4. Stable across launches; regenerable
// from the Privacy pane (PR 3).
private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState {
const existing = state.settings?.telemetry
// Why: the one-shot is complete only when all three invariants hold.
// Keying on `existedBeforeTelemetryRelease` alone would let a partially-
// written telemetry block (crash mid-save, hand-edit, future bug) short-
// circuit migration and leave `installId` undefined or `optedIn` wiped.
if (
typeof existing?.existedBeforeTelemetryRelease === 'boolean' &&
typeof existing.installId === 'string' &&
existing.installId.length > 0 &&
(existing.optedIn === true || existing.optedIn === false || existing.optedIn === null)
) {
return state
}
return {
...state,
settings: {
...state.settings,
telemetry: {
...existing,
existedBeforeTelemetryRelease:
typeof existing?.existedBeforeTelemetryRelease === 'boolean'
? existing.existedBeforeTelemetryRelease
: fileExistedOnLoad,
// Why: preserve an explicit opt-in/out if the user has ever resolved
// it. Only fall back to the cohort default (new users: on; existing
// users: undecided until the first-launch banner resolves) when
// optedIn is truly unset (undefined), never when it is `false`.
optedIn:
existing?.optedIn === true || existing?.optedIn === false || existing?.optedIn === null
? existing.optedIn
: fileExistedOnLoad
? null
: true,
installId:
typeof existing?.installId === 'string' && existing.installId.length > 0
? existing.installId
: randomUUID()
}
}
}
}
private scheduleSave(): void {
@ -476,13 +550,24 @@ export class Store {
}
updateSettings(updates: Partial<GlobalSettings>): GlobalSettings {
// Why: `telemetry` is deep-merged for the same reason `notifications` is —
// partial updates from the Privacy pane / consent flow (e.g., flipping
// only `optedIn`) must not clobber sibling fields like `installId` or
// `existedBeforeTelemetryRelease`. The field is optional, so we only
// synthesize a `telemetry` key on the result when at least one side has
// one.
const mergedTelemetry =
updates.telemetry !== undefined
? { ...this.state.settings.telemetry, ...updates.telemetry }
: this.state.settings.telemetry
this.state.settings = {
...this.state.settings,
...updates,
notifications: {
...this.state.settings.notifications,
...updates.notifications
}
},
...(mergedTelemetry !== undefined ? { telemetry: mergedTelemetry } : {})
}
this.scheduleSave()
return this.state.settings

View File

@ -0,0 +1,268 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/types'
import { resolveConsent, _resetMisconfigWarnCacheForTests } from './consent'
// A minimal GlobalSettings stub — the resolver only reads `settings.telemetry`,
// so we cast through `unknown` rather than enumerating every unrelated field.
function settingsWithTelemetry(telemetry: GlobalSettings['telemetry']): GlobalSettings {
return { telemetry } as unknown as GlobalSettings
}
// Keys cleared after each test so one case's env setup cannot leak into the
// next. `process.env` writes persist across vi.mock boundaries, so we track
// and restore explicitly.
const ENV_KEYS_UNDER_TEST = [
'DO_NOT_TRACK',
'ORCA_TELEMETRY_DISABLED',
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'BUILDKITE',
'JENKINS_URL',
'TEAMCITY_VERSION'
]
describe('resolveConsent', () => {
let savedEnv: Record<string, string | undefined>
beforeEach(() => {
savedEnv = {}
for (const k of ENV_KEYS_UNDER_TEST) {
savedEnv[k] = process.env[k]
delete process.env[k]
}
_resetMisconfigWarnCacheForTests()
})
afterEach(() => {
for (const k of ENV_KEYS_UNDER_TEST) {
if (savedEnv[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = savedEnv[k]
}
}
})
// ── Env-var overrides (non-persistent, highest precedence) ──────────
it('returns do_not_track when DO_NOT_TRACK=1', () => {
process.env.DO_NOT_TRACK = '1'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({
effective: 'disabled',
reason: 'do_not_track'
})
})
it('returns do_not_track when DO_NOT_TRACK=true (case/whitespace insensitive)', () => {
process.env.DO_NOT_TRACK = ' TRUE '
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({
effective: 'disabled',
reason: 'do_not_track'
})
})
it('returns orca_disabled when ORCA_TELEMETRY_DISABLED=1', () => {
process.env.ORCA_TELEMETRY_DISABLED = '1'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({
effective: 'disabled',
reason: 'orca_disabled'
})
})
it('prefers do_not_track over orca_disabled when both are set', () => {
process.env.DO_NOT_TRACK = '1'
process.env.ORCA_TELEMETRY_DISABLED = '1'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({
effective: 'disabled',
reason: 'do_not_track'
})
})
it.each([
['CI', 'true'],
['GITHUB_ACTIONS', 'true'],
['GITLAB_CI', 'true'],
['CIRCLECI', 'true'],
['TRAVIS', 'true'],
['BUILDKITE', 'true'],
['JENKINS_URL', 'http://ci.example.com/'],
['TEAMCITY_VERSION', '2023.11']
])('returns ci when %s is set', (name, value) => {
process.env[name] = value
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({
effective: 'disabled',
reason: 'ci'
})
})
it('does not treat CI="" (empty string) as a CI environment', () => {
process.env.CI = ''
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({ effective: 'enabled' })
})
it('warns to stderr once for misconfigured env var values like "yes" / "on" / "FALSE"', () => {
const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
process.env.DO_NOT_TRACK = 'yes'
// Two resolves, only one warning written.
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][0]).toContain('DO_NOT_TRACK')
} finally {
spy.mockRestore()
}
})
it('treats a misconfigured DO_NOT_TRACK=0 as unset (not truthy)', () => {
// This is the skills.sh regression called out in the plan doc: `=0` must
// not count as truthy. Without the parse guard, a stringly-truthy check
// would disable telemetry for anyone who types `DO_NOT_TRACK=0` expecting
// it to mean "tracking allowed."
process.env.DO_NOT_TRACK = '0'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({ effective: 'enabled' })
})
// ── Persisted preference ────────────────────────────────────────────
it('returns enabled when optedIn is true (new-user cohort after migration)', () => {
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({ effective: 'enabled' })
})
it('returns user_opt_out when optedIn is false', () => {
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: false,
installId: 'x',
existedBeforeTelemetryRelease: true
})
)
).toEqual({ effective: 'disabled', reason: 'user_opt_out' })
})
it('returns pending_banner when optedIn is null (existing-user cohort awaiting banner)', () => {
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: null,
installId: 'x',
existedBeforeTelemetryRelease: true
})
)
).toEqual({ effective: 'pending_banner' })
})
it('returns pending_banner when telemetry settings are absent (pre-migration defensive path)', () => {
expect(resolveConsent(settingsWithTelemetry(undefined))).toEqual({
effective: 'pending_banner'
})
})
// ── Env-var precedence beats persisted preference ───────────────────
it('env-var override wins over a stored optedIn=true', () => {
process.env.DO_NOT_TRACK = '1'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: true,
installId: 'x',
existedBeforeTelemetryRelease: false
})
)
).toEqual({ effective: 'disabled', reason: 'do_not_track' })
})
it('env-var override wins over a stored pending_banner state', () => {
process.env.ORCA_TELEMETRY_DISABLED = '1'
expect(
resolveConsent(
settingsWithTelemetry({
optedIn: null,
installId: 'x',
existedBeforeTelemetryRelease: true
})
)
).toEqual({ effective: 'disabled', reason: 'orca_disabled' })
})
})

View File

@ -0,0 +1,112 @@
// Pure consent resolver. Every call site (PR 2+) goes through `resolveConsent`
// so env-var / CI detection is centralized — no scattered `process.env.CI`
// checks can drift out of sync with the documented precedence list.
//
// Env-var and CI paths are non-persistent: they set effective consent at
// runtime only and never mutate `GlobalSettings.telemetry.optedIn`. Unsetting
// the variable on the next launch restores the user's stored preference.
import type { GlobalSettings } from '../../shared/types'
// Discriminated union instead of a boolean: the Privacy pane (PR 3) needs the
// `reason` to render the correct "disabled because X" helper text, and the
// first-launch banner must distinguish "existing user awaiting decision"
// (`pending_banner`) from "user explicitly opted out" (`disabled`). A boolean
// would force the UI to re-derive that, re-introducing the scattered env
// checks this module exists to eliminate.
export type ConsentState =
| { effective: 'enabled' }
| {
effective: 'disabled'
reason: 'do_not_track' | 'orca_disabled' | 'ci' | 'user_opt_out'
}
| { effective: 'pending_banner' }
// Precedence for the `disabled` branches is documented alongside
// `resolveConsent` below and in docs/telemetry-plan.md §"Env vars and consent
// precedence". Keep this list in sync with that table.
const CI_ENV_VARS = [
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'BUILDKITE',
'JENKINS_URL',
'TEAMCITY_VERSION'
] as const
// Track which env-var names have already produced a misconfiguration warning
// so a noisy shell profile that sets e.g. `DO_NOT_TRACK=yes` does not spam
// stderr on every resolve call.
const warnedMisconfigured = new Set<string>()
function warnOnceMisconfigured(name: string, raw: string): void {
if (warnedMisconfigured.has(name)) {
return
}
warnedMisconfigured.add(name)
// Stderr, not stdout — consent misconfiguration is an operator signal, not
// user-facing output. Mirrors the skills.sh-style bug note in the plan doc:
// a value like `0` / `yes` / `on` / `FALSE` silently no-oping is exactly
// the class of bug we want surfaced.
process.stderr.write(
`[telemetry] ${name}=${JSON.stringify(raw)} is not a recognized truthy value ` +
`(expected "1" or "true"); treating as unset.\n`
)
}
function isEnvVarTruthy(name: string): boolean {
const v = process.env[name]
if (!v) {
return false
}
const normalized = v.trim().toLowerCase()
if (normalized === '1' || normalized === 'true') {
return true
}
warnOnceMisconfigured(name, v)
return false
}
// Exposed for tests only — resets the one-shot warning dedupe so separate
// tests can each exercise the misconfiguration path independently.
export function _resetMisconfigWarnCacheForTests(): void {
warnedMisconfigured.clear()
}
export function resolveConsent(settings: GlobalSettings): ConsentState {
// Precedence 1: community standard kill switch. Always wins.
if (isEnvVarTruthy('DO_NOT_TRACK')) {
return { effective: 'disabled', reason: 'do_not_track' }
}
// Precedence 2: product-specific kill switch.
if (isEnvVarTruthy('ORCA_TELEMETRY_DISABLED')) {
return { effective: 'disabled', reason: 'orca_disabled' }
}
// Precedence 3: CI detection. Any presence (not just truthy) counts — many
// CI systems set `CI=true` but some legacy ones just set it to an empty
// string or a build ID, and none of those are human intent to opt in.
if (CI_ENV_VARS.some((v) => process.env[v] !== undefined && process.env[v] !== '')) {
return { effective: 'disabled', reason: 'ci' }
}
const t = settings.telemetry
// Defensive: after the PR 1 migration in `Store.load()`, every settings
// object has `telemetry` populated. If we somehow read a settings object
// that predates migration, fail closed to `pending_banner` (no transmit)
// rather than defaulting on.
if (!t) {
return { effective: 'pending_banner' }
}
if (t.optedIn === true) {
return { effective: 'enabled' }
}
if (t.optedIn === false) {
return { effective: 'disabled', reason: 'user_opt_out' }
}
// `optedIn === null` — existing-user cohort awaiting banner resolution.
return { effective: 'pending_banner' }
}

View File

@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest'
import type { Store } from '../persistence'
import type { GlobalSettings } from '../../shared/types'
import { generateInstallId, readInstallId, resetInstallId } from './install-id'
// Minimal in-memory store stand-in. The real `Store` pulls in Electron + fs,
// which is overkill for testing the install-id read/write contract. We only
// need the two methods install-id.ts consumes.
function makeFakeStore(initial: Partial<GlobalSettings>): Store {
let settings = { ...initial } as GlobalSettings
const store = {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates } as GlobalSettings
return settings
})
}
return store as unknown as Store
}
describe('install-id', () => {
describe('generateInstallId', () => {
it('produces a UUID v4 string', () => {
const id = generateInstallId()
// RFC 4122 v4: 8-4-4-4-12 hex with version nibble `4` and variant nibble 8/9/a/b.
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
})
it('produces a different id on each call', () => {
expect(generateInstallId()).not.toBe(generateInstallId())
})
})
describe('readInstallId', () => {
it('returns the persisted install id', () => {
const store = makeFakeStore({
telemetry: {
optedIn: true,
installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
existedBeforeTelemetryRelease: false
}
})
expect(readInstallId(store)).toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
})
it('returns undefined when telemetry is not initialized', () => {
const store = makeFakeStore({})
expect(readInstallId(store)).toBeUndefined()
})
})
describe('resetInstallId', () => {
it('generates a new id, persists it, and preserves sibling telemetry fields', () => {
const store = makeFakeStore({
telemetry: {
optedIn: true,
installId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
existedBeforeTelemetryRelease: false,
firstRunNoticeShown: true
}
})
const newId = resetInstallId(store)
expect(newId).not.toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
expect(newId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
// Read-back should reflect the new id and keep everything else intact.
expect(store.getSettings().telemetry).toEqual({
optedIn: true,
installId: newId,
existedBeforeTelemetryRelease: false,
firstRunNoticeShown: true
})
})
it('initializes telemetry defensively if the block is somehow missing', () => {
// Should not happen after migration — exercised to prove the Privacy
// pane button will not throw if the user reaches it before migration
// has run (startup-ordering bug). We prefer a usable store over a
// silent crash; a future maintainer should leave this branch in place.
const store = makeFakeStore({})
const newId = resetInstallId(store)
expect(store.getSettings().telemetry).toEqual({
optedIn: null,
installId: newId,
existedBeforeTelemetryRelease: true
})
})
})
})

View File

@ -0,0 +1,56 @@
import { randomUUID } from 'node:crypto'
import type { Store } from '../persistence'
// Anonymous UUID v4 that keys the user as a telemetry subject. Two contracts
// this module must preserve:
//
// 1. Stability across launches. The migration in `persistence.ts` populates
// `GlobalSettings.telemetry.installId` once; `readInstallId` is the sole
// read path so call sites cannot accidentally regenerate it by reaching
// into the store themselves.
//
// 2. Rotability from the Privacy pane. `resetInstallId` generates a fresh
// UUID and persists it via `store.updateSettings` so the normal debounced
// save picks it up. PR 3 wires the Privacy pane button to this; PR 2
// wires the PostHog reset(). In PR 1 the reset path exists but has no
// caller — keeping it here (vs. client.ts) keeps pure install-id logic in
// one file and leaves `client.ts` a thin vendor wrapper.
export function generateInstallId(): string {
return randomUUID()
}
// Lookup-only. Returns undefined if `telemetry` is missing — this only
// happens before `Store.load()` has run the migration, which is an invariant
// violation everywhere else. Callers can treat undefined as "telemetry not
// initialized yet" rather than silently regenerating here (regenerating
// behind a caller's back would mask a startup-ordering bug).
export function readInstallId(store: Store): string | undefined {
return store.getSettings().telemetry?.installId
}
// Rotation path used by the Privacy pane's "Reset anonymous ID" button. Why
// `updateSettings` instead of a bespoke setter: `updateSettings` already
// performs the deep-merge that keeps `notifications` intact, schedules the
// 300 ms debounced save, and is the one sanctioned way to mutate persisted
// settings. Duplicating the merge path risks dropping sibling fields on a
// future schema addition.
export function resetInstallId(store: Store): string {
const settings = store.getSettings()
const telemetry = settings.telemetry
// Defensive: migration should have populated this by the time the Privacy
// pane can call reset. If it somehow hasn't, initialize rather than throw
// — the user clicked a button; failing silently loudly is worse than
// returning a fresh ID.
const newId = generateInstallId()
store.updateSettings({
telemetry: telemetry
? { ...telemetry, installId: newId }
: {
optedIn: null,
installId: newId,
existedBeforeTelemetryRelease: true
}
})
return newId
}

View File

@ -208,6 +208,16 @@ describe('detectAgentStatusFromTitle', () => {
expect(detectAgentStatusFromTitle('~/codex-scratch')).toBe('idle')
expect(detectAgentStatusFromTitle('~/codex already built')).toBe('idle')
})
// Why: short agent names are unsafe under substring detection. Telemetry now
// records explicit launch facts rather than widening OSC-title inference, so
// this detector must not grow aliases that turn ordinary shell titles like
// "timestamp ready" into agent activity.
it('does not treat ordinary words containing "amp" as agent titles', () => {
expect(detectAgentStatusFromTitle('timestamp ready')).toBeNull()
expect(detectAgentStatusFromTitle('clamp working')).toBeNull()
expect(detectAgentStatusFromTitle('example permission needed')).toBeNull()
})
})
// Why: regression guard for the STRONG_WORKING_KEYWORDS_RE path-separator

View File

@ -16,6 +16,11 @@ const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
const GEMINI_IDLE = '\u25C7' // ◇
const GEMINI_PERMISSION = '\u270B' // ✋
// Why: this list is for OSC-title detection only. It is intentionally narrower
// than the full set of launchable agents because short names like "amp" are
// unsafe under the substring-based detector and would classify ordinary shell
// titles like "timestamp ready" as agent activity. Product telemetry uses the
// explicit launch/session facts Orca owns, not this inference path.
export const AGENT_NAMES = ['claude', 'codex', 'copilot', 'cursor', 'gemini', 'opencode', 'aider']
// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles

View File

@ -1145,6 +1145,34 @@ export type GlobalSettings = {
* off never mount the overlay. Toggling takes effect immediately in the
* current session (no relaunch) because it is purely renderer-side. */
experimentalSidekick: boolean
/** Anonymous product-telemetry state. Optional because the one-shot
* migration in `Store.load()` is what populates it on first boot of the
* telemetry release; before migration runs, the field is absent. After
* migration every user has `installId` set and `optedIn` is `true` (new
* users) or `null` (existing users awaiting the first-launch banner).
*
* Why this block carries only consent + identity state, not volatile
* counters: DAU and crash attribution are both out of v1 scope
* (daily_active_user is derived server-side from app_opened; crashes are
* handled by a separate crash-reporting lane, not product telemetry). So
* there is no lastActiveDate, no lastSessionId, and no heartbeat
* timestamp here adding any of those would amplify the debounced
* settings write on a fast cadence and couple user preferences to
* volatile telemetry counters. Keep this surface to values that only
* change on explicit consent transitions. */
telemetry?: {
/** New users: initialized to `true` at install.
* Existing users: `null` until they resolve the first-launch banner. */
optedIn: boolean | null
/** Anonymous UUID v4. Generated on first run. Regenerable from Privacy pane. */
installId: string
/** Cohort marker set once during migration. Drives toast-vs-banner. */
existedBeforeTelemetryRelease: boolean
/** New-user toast: true only after active dismissal ("Got it" or "Turn off").
* Re-shows on next launch if false/undefined so the consent disclosure
* is never silently skipped by quitting mid-session. */
firstRunNoticeShown?: boolean
}
}
export type GhosttyImportPreview = {