From df1e8567bdfe663376fbc2f34375635953b3b468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:34:22 +0300 Subject: [PATCH 01/23] docs(spec): posthog-analytics-identity-funnels-and-errors spec, plan and tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves product analytics off Aptabase and onto PostHog so the user-level questions — unique users, actives, activation funnel, retention, where people drop off — become answerable at all, plus a separate opt-in error channel carrying redacted exception detail instead of a bare category. Explicitly reverses audit-q3-product-analytics (plan.md:11), which chose Aptabase and rejected PostHog on identity grounds. The registry allowlist, the fail-closed opt-out and PRIVACY.md accuracy survive the move. Co-Authored-By: Claude Opus 5 (1M context) --- .../plan.md | 91 ++++++++++++ .../spec.md | 128 +++++++++++++++++ .../status.json | 22 +++ .../tasks.md | 12 ++ .frame/tasks.json | 134 +++++++++++++++++- 5 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/plan.md create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/status.json create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/plan.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/plan.md new file mode 100644 index 00000000..1d838072 --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/plan.md @@ -0,0 +1,91 @@ +# Plan — PostHog analytics — identity, funnels and errors + +## Architecture + +### Resolved plan-time decisions + +**Business** + +- **Consent model (asked)** — Keep the default-on opt-out model; disclose the install ID prominently instead of switching to opt-in. Rationale: on a developer tool opt-in typically yields 5–15% participation, and none of the unique-user / funnel / retention questions this spec exists to answer survive that sample. The privacy cost is paid by disclosure, not by collecting less. +- **Identity depth (asked)** — Anonymous cohorts only. No self-identify field, no email, no handle. PRIVACY.md's "no email addresses or any personally identifiable information" line stays true as written. +- **Aptabase cutover (asked)** — Hard switch: Aptabase leaves, PostHog arrives, in one change. Rationale: the existing history is event counts only and is not comparable with user-level data, so dual-send would pay two SDKs, two quotas and a cleanup task to preserve something that cannot be joined anyway. +- **Re-notice (asked)** — Existing users see the notice once more. `telemetryNoticeShown` (boolean) is superseded by `telemetryNoticeVersion` (number); the release that introduces the install ID bumps the version, so everyone reads the new disclosure once. Rationale: people acknowledged a system with no identifier; the system is changing, and the consent model chosen above is only honest if the change is surfaced. + +**Technical** + +- **Error detail shape (asked)** — PostHog error tracking on a **separate, opt-in** channel (`errorReportingEnabled`, default `false`), not a `message` field inside the event registry. Rationale: a redacted free-form property would punch the first hole in the registry's enum-only guarantee, and once open there is no mechanical guard against the next contributor widening it. A physically separate channel leaves `validateEvent` absolute. The `error_occurred` enum stays for counts. +- **Person mode (asked)** — Identified events with person profiles, not `$process_person_profiles: false`. Rationale: retention cohorts and person-property filters are the point of the migration, and the free tier covers 1M identified events/month against Frame's tens-per-user-day volume. +- **Test posture (asked)** — Pure logic and data transforms only. Rationale: the project's convention already tests `telemetryEvents.js` and skips Electron-coupled `telemetry.js`, and CI runs **without `npm ci`** (`PROJECT_NOTES.md ## Testing`), so a test that reaches the PostHog SDK in `node_modules` passes locally and fails in CI. All new policy lands in the pure module. +- **Install ID storage (silent)** — `user-settings.json` via the existing `userSettings` module, alongside `telemetryEnabled`. It is already `fsSafe`-backed (atomic write, `.bak` recovery), and if that file is unreadable telemetry fails closed anyway, so a lost ID is moot rather than a second failure mode. No new state file. +- **Person properties (silent)** — `$set` carries operating system and app version only, set once per launch on the first capture. These are exactly the two fields `PRIVACY.md` already discloses Aptabase attaching, so the disclosure surface does not grow. +- **Error capture seam (silent)** — Main-process exceptions hook into `crashGuard.js`, which already owns `uncaughtException` / `unhandledRejection` and routes them through `logger`. Adding a second set of process handlers would double-report and fight the existing recovery flow. +- **Flush on quit (silent)** — `posthog-node` batches; `shutdown()` is called from the existing `before-quit` handler (`index.js:376`) so the last events of a session are not lost. Aptabase needed none of this. +- **Init ordering (silent)** — `telemetry.init()` stays at its current call site (`index.js:347`). The pre-`whenReady` requirement in the module header was an Aptabase constraint (`protocol.registerSchemesAsPrivileged`) and disappears with `posthog-node`, but moving the call buys nothing and risks the boot order; the header comment is corrected instead. +- **Autocapture (silent)** — Off. `posthog-node` has no autocapture or `$pageview` behavior by default; no call enables it, and the registry gate is unchanged, so nothing reaches PostHog that `validateEvent` did not pass. + +### Shape + +`telemetry.js` keeps its role as the single gate and swaps its transport. `track()` is unchanged in structure — `isEnabled()` → `validateEvent()` → rate limiter → send — with `aptabase.trackEvent(name, props)` becoming `posthog.capture({ distinctId, event, properties })`. Everything the registry guarantees is preserved because the gate itself is untouched. + +Three new pieces of pure policy join `telemetryEvents.js` so they are testable under `node --test` without Electron or the SDK: + +- `resolveInstallId({ stored, enabled })` → `{ id, write }` — mints a UUID when telemetry is on and none is stored, returns the stored one when valid, and returns `{ id: null, write: null }` when telemetry is off so the caller deletes it. `write` tells the caller whether `userSettings.set` is needed, keeping I/O out of the policy. +- `sanitizeException({ message, stack })` → the same shape, redacted — `scripts/redact.js` first, then absolute-path stripping (`/Users//…`, `C:\Users\\…`, and any path outside the app bundle reduced to its basename). +- `shouldShowNotice({ storedVersion, currentVersion })` — the versioned replacement for the boolean, including the migration case where an old client stored `telemetryNoticeShown: true` and has no version. + +The error channel is deliberately separate end to end: a different setting, a different function (`telemetry.captureException`), a different IPC channel, and a PostHog call that never passes through the event registry. + +``` +main exception → crashGuard.js ─┐ +renderer error → TELEMETRY_EXCEPTION ─┤→ telemetry.captureException() + │ ↳ errorReportingEnabled? → sanitizeException() → posthog.captureException() +registry events → track() → validateEvent() → rateLimiter → posthog.capture({distinctId}) +``` + +## Files + +- `package.json` — **Modified** — drop `@aptabase/electron`, add `posthog-node`. +- `src/main/telemetry.js` — **Modified** — PostHog client (EU host, `disableGeoip`), install-ID resolution, `distinctId` on every capture, `captureException`, `shutdown`, corrected module header. +- `src/main/telemetryEvents.js` — **Modified** — adds `resolveInstallId`, `sanitizeException`, `shouldShowNotice`, `NOTICE_VERSION`. Registry, `validateEvent`, `effectiveEnabled` and the rate limiter are untouched. +- `src/main/crashGuard.js` — **Modified** — main-process `uncaughtException` / `unhandledRejection` also reach `telemetry.captureException`. +- `src/main/index.js` — **Modified** — `TELEMETRY_EXCEPTION` listener; `posthog.shutdown()` in the existing `before-quit` handler. +- `src/shared/ipcChannels.js` — **Modified** — `TELEMETRY_EXCEPTION` constant beside the existing telemetry channels. +- `src/renderer/index.js` — **Modified** — `window.onerror` / `unhandledrejection` hooks forwarding to the new channel. +- `src/renderer/telemetryNotice.js` — **Modified** — versioned notice key, new disclosure copy. +- `src/renderer/frameSettingsModal.js` — **Modified** — wire the error-reporting toggle. +- `index.html` — **Modified** — error-reporting row in Privacy & Analytics; notice copy. +- `PRIVACY.md` — **Modified** — PostHog, EU residency, the install ID and what it is not, the unchanged event table, the error-reporting opt-in. +- `test/telemetry.test.js` — **Modified** — install-ID lifecycle, exception sanitization, notice versioning; existing fail-closed and registry assertions stay. + +## Footprint + +- package.json +- src/main/telemetry.js +- src/main/telemetryEvents.js +- src/main/crashGuard.js +- src/main/index.js +- src/shared/ipcChannels.js +- src/renderer/index.js +- src/renderer/telemetryNotice.js +- src/renderer/frameSettingsModal.js +- index.html +- PRIVACY.md +- test/telemetry.test.js + +## Dependencies + +- **Add `posthog-node`** — the analytics transport. Main-process only; the renderer keeps reaching telemetry over IPC, so no renderer bundle growth. +- **Remove `@aptabase/electron`** — replaced outright (hard cutover decision). + +## Sequencing + +1. **Install-ID policy, pure.** Add `resolveInstallId` to `telemetryEvents.js` with its tests: mint-when-absent, reuse-when-stored, reject a malformed stored value, and return null with a delete instruction when telemetry is off. +2. **Swap the transport.** Replace `@aptabase/electron` with `posthog-node` in `package.json` and `telemetry.js`: client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` from step 1 on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header. `track()`'s gate order stays exactly as it is. +3. **Flush on exit.** Call `posthog.shutdown()` from the existing `before-quit` handler so a session's last events are not dropped by the batcher. +4. **Opt-out lifecycle.** `setEnabled(false)` deletes the stored install ID; re-enabling mints a fresh one. `enforceFailClosed()` is untouched. Extend the existing fail-closed tests to assert the ID is gone when telemetry is off. +5. **Exception sanitization, pure.** Add `sanitizeException` to `telemetryEvents.js` with its tests: secrets redacted via `scripts/redact.js`, absolute user paths reduced to basenames on both POSIX and Windows shapes, and an input carrying a prompt-like blob coming out with no absolute path. +6. **The error channel.** Add `errorReportingEnabled` (default `false`) and `telemetry.captureException`, gated on that setting *and* on `isEnabled()`, and passed through the **same rate limiter instance** `track()` uses — a repeating exception in a render loop must not be able to drain the quota. Wire `crashGuard.js`'s two handlers into it. +7. **Renderer exceptions.** Add `TELEMETRY_EXCEPTION` to `ipcChannels.js`, the `ipcMain.on` listener in `index.js` (sanitization happens in main, matching how `TELEMETRY_TRACK` revalidates), and the `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`. +8. **Settings surface.** Error-reporting row in the Privacy & Analytics section of `index.html`, wired in `frameSettingsModal.js` next to the crash-dumps toggle. +9. **Notice re-version.** `shouldShowNotice` + `NOTICE_VERSION` in `telemetryEvents.js` with tests (fresh install, old boolean-only client, already-seen current version); `telemetryNotice.js` reads the versioned key and carries copy that names the install ID. +10. **Disclosure.** Rewrite `PRIVACY.md` — vendor, EU residency, the install ID and what it is not, the unchanged event table, the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision in `PROJECT_NOTES.md` as a dated entry. diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md new file mode 100644 index 00000000..a40be79a --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/spec.md @@ -0,0 +1,128 @@ +--- +keywords: posthog, analytics, funnels, retention, unique users, error tracking, telemetry, opt-out +related: audit-q3-product-analytics, audit-q3-reliability-recovery, audit-q3-ux-error-feedback +--- + +# PostHog analytics — identity, funnels and errors + +## Problem + +Frame's telemetry (Aptabase, 11-event registry) reports **event counts**, and +that is structurally all it can report: Aptabase attaches no user identifier, +so there is no way to ask how many distinct people use Frame, whether the same +people come back, what one person does across a session, or where they stop. + +The founder's actual questions are all user-level and none are answerable +today: + +- **How many unique users are there? Who is actively using Frame?** — `app_started` + count conflates one user launching 40 times with 40 users launching once. +- **What do users do inside?** — per-event totals exist, but not "of the people + who opened the orchestrator, how many ever ran one". +- **Where are we losing them?** — the install → `project_initialized` → + `spec_created` → `agent_run_started` funnel has no drop-off view. The + predecessor spec knowingly approximated activation as *unique users per plain + event* (`audit-q3-product-analytics/plan.md:7`); that approximation is now the + bottleneck. +- **What breaks in the wild?** — `error_occurred` carries a 9-value category and + nothing else. It reports that something failed N times, never what failed. + +`audit-q3-product-analytics` chose Aptabase deliberately and **rejected PostHog** +because "identity/funnels exceed the spec's out-of-scope line (no per-user +tracking)" (`plan.md:11`). This spec **reverses that decision**: the questions +the founder needs answered are user-level, so a pseudonymous per-install +identifier is now accepted — with its privacy cost paid explicitly in +`PRIVACY.md` rather than avoided. + +## Goal + +Product analytics on **PostHog EU cloud**, with a stable anonymous install ID, +so the dashboard answers: unique users, DAU/WAU/MAU, retention cohorts, the +activation funnel with per-step drop-off, and feature usage per user — plus a +separate, opt-in error-tracking path that carries redacted exception detail +instead of a bare category. + +The registry allowlist, the fail-closed opt-out and PRIVACY.md accuracy survive +the move unchanged in spirit: PostHog replaces the transport and the dashboard, +not Frame's discipline about what may leave the machine. + +## Constraints + +- **Reverses a recorded decision.** `audit-q3-product-analytics` (`plan.md:11`) + chose Aptabase and rejected PostHog. That reversal is deliberate and must be + recorded in `PROJECT_NOTES.md`; the rest of that spec's machinery (registry, + fail-closed opt-out, IPC revalidation) stays authoritative. +- **The registry allowlist is not negotiable.** `telemetryEvents.js` stays the + single gate: enum-only props, `validateEvent` drops unregistered events, the + renderer is revalidated in main over `TELEMETRY_TRACK`. **PostHog autocapture + and `$pageview`-style automatic events stay off.** +- **Standing rule of record:** any registry addition lands in `PRIVACY.md` in the + same change. +- **Fail-closed opt-out is preserved byte for byte.** `userSettings.loadFailed()` + + `effectiveEnabled()` behavior and its regression test must still hold. +- **EU residency and no geo.** `eu.i.posthog.com`, geoip disabled — PRIVACY.md + currently promises the IP is not retained. +- **The install ID is random.** A UUID generated locally and persisted through + `fsSafe` (`audit-q3-reliability-recovery` rule: no state write bypasses it). + Never a machine ID, hostname, username, email or anything derived from them. + Removed on opt-out. +- **Content rules unchanged** (`PRIVACY.md`): no file paths, no code, no project + names, no prompts or responses, no terminal I/O, no PII. +- **Error tracking must be opt-in.** `PRIVACY.md` already commits: if Frame ever + gains the ability to *send* crash reports, it "will be a separate, **opt-in** + setting and will be disclosed here first". Exception payloads route through + `scripts/redact.js` and are stripped of absolute paths before send. +- **No new heavy dependency in the renderer** — analytics stays in main, reached + over the existing `TELEMETRY_TRACK` IPC channel. + +## Success Criteria + +- When a user launches Frame with telemetry on, then every event carries a stable + anonymous install ID, and the PostHog dashboard reports unique users and + DAU/WAU/MAU rather than raw event counts. +- When users progress through install → `project_initialized` → `spec_created` → + `agent_run_started`, then that funnel is queryable in PostHog with per-step + conversion and drop-off. +- When a user returns days after first launch, then they appear in a retention + cohort. +- When a user turns telemetry off, then no request reaches PostHog, the stored + install ID is deleted, and a new ID is minted only if telemetry is turned back + on. +- When `user-settings.json` is corrupt or unreadable, then telemetry stays off + (the existing fail-closed test still passes unchanged). +- When a call site passes an event or property outside the registry, then it is + dropped before the PostHog SDK is reached — asserted by tests, including the + renderer-over-IPC path. +- When error tracking is off (the default), then no exception message, stack + frame or path leaves the machine; when a user explicitly enables it, then the + payload is redacted and contains no absolute path, no code and no prompt text. +- When `PRIVACY.md` is read, then it names PostHog, EU residency, the install ID + and what it is not, every event in the registry, and the error-tracking opt-in + — with nothing listed that Frame does not actually send. + +## Out of Scope + +- Session replay — an Electron dev tool's screen is the user's own code. +- PostHog feature flags and surveys (a later spec; the free tier includes them). +- The local activity record, `main.log` and crash minidumps — all stay local and + untouched (`audit-q3-reliability-recovery`). +- Identifying users by name, email or GitHub account. +- Renderer error-feedback UX — owned by `audit-q3-ux-error-feedback`. + +## Open Questions + +1. **Does the default-on opt-out model survive the install ID?** Frame's + telemetry is default-on because it was unidentifiable. A persistent + pseudonymous ID changes that bargain. Options: (a) keep default-on opt-out and + disclose the ID prominently in the one-time notice; (b) switch analytics to + opt-in and accept far lower volume. +2. **"Who is actively using Frame" — how far does *who* go?** Options: + (a) anonymous cohorts only, no way to ever contact or name a user; + (b) an optional self-identify in Settings (a user volunteers an email/handle), + default empty. +3. **Aptabase cutover.** Options: (a) hard switch, accept the history break; + (b) dual-send for one release so counts can be reconciled. +4. **Error detail shape.** Options: (a) adopt PostHog's error-tracking product + (real exceptions and stack traces, opt-in); (b) keep the category enum and add + a single redacted `message` field, which stays inside the current registry + model. diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json new file mode 100644 index 00000000..1dd99f18 --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json @@ -0,0 +1,22 @@ +{ + "slug": "posthog-analytics-identity-funnels-and-errors", + "title": "PostHog analytics — identity, funnels and errors", + "phase": "tasks_generated", + "generated_task_ids": [ + "task-spec-posthog-analytics-identity-funnels-and-errors-T01", + "task-spec-posthog-analytics-identity-funnels-and-errors-T02", + "task-spec-posthog-analytics-identity-funnels-and-errors-T03", + "task-spec-posthog-analytics-identity-funnels-and-errors-T04", + "task-spec-posthog-analytics-identity-funnels-and-errors-T05", + "task-spec-posthog-analytics-identity-funnels-and-errors-T06", + "task-spec-posthog-analytics-identity-funnels-and-errors-T07", + "task-spec-posthog-analytics-identity-funnels-and-errors-T08", + "task-spec-posthog-analytics-identity-funnels-and-errors-T09", + "task-spec-posthog-analytics-identity-funnels-and-errors-T10" + ], + "ai_tool": "claude-code", + "created_at": "2026-09-21T12:43:40Z", + "updated_at": "2026-09-21T13:24:03.022Z", + "last_phase_at": "2026-09-21T13:24:02Z", + "implement_mode": "guided" +} diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md new file mode 100644 index 00000000..629a6ace --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/tasks.md @@ -0,0 +1,12 @@ +# Tasks — PostHog analytics — identity, funnels and errors + +- T01 · Add `resolveInstallId({ stored, enabled })` to `src/main/telemetryEvents.js` returning `{ id, write }`, with tests for mint-when-absent, reuse-when-stored, reject-malformed, and null-plus-delete when telemetry is off +- T02 · Replace `@aptabase/electron` with `posthog-node` in `package.json` and `src/main/telemetry.js` — client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header — leaving `track()`'s gate order, `validateEvent` and the rate limiter untouched, autocapture off, and the SDK confined to the main process +- T03 · Call `posthog.shutdown()` from the existing `before-quit` handler in `src/main/index.js` so the batcher flushes a session's final events +- T04 · Delete the stored install ID in `setEnabled(false)` and mint a fresh one on re-enable, and extend the existing fail-closed tests in `test/telemetry.test.js` to assert no ID survives while telemetry is off +- T05 · Add `sanitizeException({ message, stack })` to `src/main/telemetryEvents.js` — `scripts/redact.js` first, then absolute POSIX and Windows paths reduced to basenames — with tests covering a secret-bearing and a path-bearing input +- T06 · Add the `errorReportingEnabled` setting (default `false`) and `telemetry.captureException`, gated on that setting and on `isEnabled()` and passed through the same rate limiter instance as `track()`, then wire `src/main/crashGuard.js`'s `uncaughtException` and `unhandledRejection` handlers into it +- T07 · Add `TELEMETRY_EXCEPTION` to `src/shared/ipcChannels.js`, its `ipcMain.on` listener in `src/main/index.js` sanitizing in main, and `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js` +- T08 · Add the error-reporting row to the Privacy & Analytics section of `index.html` and wire it in `src/renderer/frameSettingsModal.js` beside the crash-dumps toggle +- T09 · Add `NOTICE_VERSION` and `shouldShowNotice({ storedVersion, currentVersion })` to `src/main/telemetryEvents.js` with tests for fresh install, legacy boolean-only client and already-seen current version, and read the versioned key in `src/renderer/telemetryNotice.js` with copy that names the install ID +- T10 · Rewrite `PRIVACY.md` for PostHog, EU residency, the install ID and what it is not, the registry's eleven events unchanged, and the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision as a dated entry in `.frame/PROJECT_NOTES.md` diff --git a/.frame/tasks.json b/.frame/tasks.json index dbb12271..c0097a62 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -7,7 +7,7 @@ }, "project": "Frame", "version": "2.0", - "lastUpdated": "2026-09-16T13:46:16.346Z", + "lastUpdated": "2026-09-21T13:24:03.022Z", "taskSchema": { "_comment": "This schema shows the expected structure for each task", "id": "unique-id (task-xxx format)", @@ -8361,10 +8361,140 @@ "createdAt": "2026-09-16T13:46:16.346Z", "updatedAt": "2026-09-16T14:01:09.621Z", "completedAt": "2026-09-16T14:01:09.621Z" + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T01", + "title": "Add `resolveInstallId({ stored, enabled })` to `src/main/telemetryEvents.js` returning `{ id, write }`, with tests for mint-when-absent, reuse-when-stored, reject-malformed, and null-plus-delete when telemetry is off", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T01", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T02", + "title": "Replace `@aptabase/electron` with `posthog-node` in `package.json` and `src/main/telemetry.js` — client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header — leaving `track()`'s gate order, `validateEvent` and the rate limiter untouched, autocapture off, and the SDK confined to the main process", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T02", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T03", + "title": "Call `posthog.shutdown()` from the existing `before-quit` handler in `src/main/index.js` so the batcher flushes a session's final events", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T03", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T04", + "title": "Delete the stored install ID in `setEnabled(false)` and mint a fresh one on re-enable, and extend the existing fail-closed tests in `test/telemetry.test.js` to assert no ID survives while telemetry is off", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T04", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T05", + "title": "Add `sanitizeException({ message, stack })` to `src/main/telemetryEvents.js` — `scripts/redact.js` first, then absolute POSIX and Windows paths reduced to basenames — with tests covering a secret-bearing and a path-bearing input", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T05", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T06", + "title": "Add the `errorReportingEnabled` setting (default `false`) and `telemetry.captureException`, gated on that setting and on `isEnabled()` and passed through the same rate limiter instance as `track()`, then wire `src/main/crashGuard.js`'s `uncaughtException` and `unhandledRejection` handlers into it", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T06", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T07", + "title": "Add `TELEMETRY_EXCEPTION` to `src/shared/ipcChannels.js`, its `ipcMain.on` listener in `src/main/index.js` sanitizing in main, and `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T07", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T08", + "title": "Add the error-reporting row to the Privacy & Analytics section of `index.html` and wire it in `src/renderer/frameSettingsModal.js` beside the crash-dumps toggle", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T08", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T09", + "title": "Add `NOTICE_VERSION` and `shouldShowNotice({ storedVersion, currentVersion })` to `src/main/telemetryEvents.js` with tests for fresh install, legacy boolean-only client and already-seen current version, and read the versioned key in `src/renderer/telemetryNotice.js` with copy that names the install ID", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T09", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null + }, + { + "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T10", + "title": "Rewrite `PRIVACY.md` for PostHog, EU residency, the install ID and what it is not, the registry's eleven events unchanged, and the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision as a dated entry in `.frame/PROJECT_NOTES.md`", + "description": "", + "source": "spec:posthog-analytics-identity-funnels-and-errors:T10", + "status": "pending", + "priority": "medium", + "category": "feature", + "context": "From spec: posthog-analytics-identity-funnels-and-errors", + "createdAt": "2026-09-21T13:24:03.022Z", + "updatedAt": "2026-09-21T13:24:03.022Z", + "completedAt": null } ], "metadata": { - "totalCreated": 609, + "totalCreated": 619, "totalCompleted": 29 }, "categories": { From 793246f62f192b4423d80b57c5911dad9cf52754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:36:43 +0300 Subject: [PATCH 02/23] feat(telemetry): resolve a stable anonymous install id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog's user-level views — unique users, the activation funnel, retention cohorts — need one stable id per install. It is a random UUID and nothing else: never a machine id, hostname or username, which are stable across reinstalls and shared with every other program on the machine. Telemetry off deletes the stored id rather than parking it; an opt-out that leaves a resumable identifier behind is not an opt-out. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 30 ++++++++---- .../outcome.md | 10 ++++ .../status.json | 6 +-- .frame/tasks.json | 8 ++-- src/main/telemetryEvents.js | 44 ++++++++++++++++++ test/telemetry.test.js | 46 ++++++++++++++++++- 6 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 74d1a828..22de2b3f 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -1,7 +1,7 @@ { "version": "1.0", "description": "Auto-generated module structure", - "lastUpdated": "2026-09-16", + "lastUpdated": "2026-09-21", "architecture": {}, "modules": { "main/activityLog": { @@ -4385,22 +4385,25 @@ "normalizeTool", "validateEvent", "effectiveEnabled", + "resolveInstallId", "createRateLimiter", "DEFAULT_RATE_LIMIT", "diffSpecLifecycle", "renameSpecLifecycle" ], - "depends": [], + "depends": [ + "node:crypto" + ], "functions": { "normalizeTool": { - "line": 63, + "line": 65, "params": [ "id" ], "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" }, "validateEvent": { - "line": 78, + "line": 80, "params": [ "name", "props" @@ -4408,29 +4411,38 @@ "purpose": "Validate an (event, props) pair against the registry." }, "effectiveEnabled": { - "line": 102, + "line": 104, "params": [ "{ value", "loadFailed }" ], "purpose": "Effective telemetry state from the persisted setting plus settings-load" }, + "resolveInstallId": { + "line": 140, + "params": [ + "{ stored", + "enabled }", + "mint" + ], + "purpose": "Decide this launch's install id from what is stored and whether telemetry" + }, "createRateLimiter": { - "line": 128, + "line": 171, "params": [ "options" ], "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" }, "stampedSince": { - "line": 188, + "line": 231, "params": [ "stamp", "since" ] }, "diffSpecLifecycle": { - "line": 207, + "line": 250, "params": [ "previous", "specs", @@ -4439,7 +4451,7 @@ "purpose": "Compare one push of a project's specs with this run's earlier looks at it." }, "renameSpecLifecycle": { - "line": 246, + "line": 289, "params": [ "state", "oldSlug", diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md new file mode 100644 index 00000000..5ef15848 --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -0,0 +1,10 @@ +# Outcome — PostHog analytics — identity, funnels and errors + +## T01 — resolveInstallId in the pure policy module + +Added `resolveInstallId({ stored, enabled }, mint)` to the pure policy module with six tests. Refined plan.md's `{ id, write }` contract: `write` is a three-state instruction (`'set'` | `'delete'` | `null`) rather than a boolean, because telemetry-off has to distinguish deleting a stored id from doing nothing when none exists — a boolean could not carry that. A malformed stored value is replaced rather than sent, since it can only come from a hand-edited or half-written settings file. Files touched: `src/main/telemetryEvents.js`, `test/telemetry.test.js`. + +_Captured: 2026-09-21 · 2 file change(s)_ + +--- + diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json index 1dd99f18..1b193423 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json @@ -1,7 +1,7 @@ { "slug": "posthog-analytics-identity-funnels-and-errors", "title": "PostHog analytics — identity, funnels and errors", - "phase": "tasks_generated", + "phase": "implementing", "generated_task_ids": [ "task-spec-posthog-analytics-identity-funnels-and-errors-T01", "task-spec-posthog-analytics-identity-funnels-and-errors-T02", @@ -16,7 +16,7 @@ ], "ai_tool": "claude-code", "created_at": "2026-09-21T12:43:40Z", - "updated_at": "2026-09-21T13:24:03.022Z", - "last_phase_at": "2026-09-21T13:24:02Z", + "updated_at": "2026-09-21T13:36:43.547Z", + "last_phase_at": "2026-09-21T13:36:43.547Z", "implement_mode": "guided" } diff --git a/.frame/tasks.json b/.frame/tasks.json index c0097a62..2ccaa252 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8367,13 +8367,13 @@ "title": "Add `resolveInstallId({ stored, enabled })` to `src/main/telemetryEvents.js` returning `{ id, write }`, with tests for mint-when-absent, reuse-when-stored, reject-malformed, and null-plus-delete when telemetry is off", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T01", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:36:43Z", + "completedAt": "2026-09-21T13:36:43Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T02", @@ -8505,4 +8505,4 @@ "test": "Testing", "research": "Research and exploration" } -} \ No newline at end of file +} diff --git a/src/main/telemetryEvents.js b/src/main/telemetryEvents.js index 27b66f6e..01611443 100644 --- a/src/main/telemetryEvents.js +++ b/src/main/telemetryEvents.js @@ -7,6 +7,8 @@ * property values that may ever leave the machine. */ +const { randomUUID } = require('node:crypto'); + /** * The registry: every event Frame may ever send, with every allowed property * and every allowed value. Properties are enums only — no free-form strings, @@ -104,6 +106,47 @@ function effectiveEnabled({ value, loadFailed }) { return value !== false; } +// ─── The install identifier ─────────────────────────────── +// +// Frame's analytics became user-level when it moved to PostHog: counting +// unique users, following an activation funnel and reading a retention +// cohort all need one stable id per install. It is a random UUID and +// nothing else — never a machine id, hostname or username, all of which +// are stable across reinstalls and shared with every other program on the +// box, which is exactly the linkage this avoids. +// +// The decision is pure so it can be tested; `telemetry.js` performs the +// write or the delete the returned `write` asks for. + +const INSTALL_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Decide this launch's install id from what is stored and whether telemetry + * is on. + * + * @param {{ stored: any, enabled: boolean }} state + * @param {() => string} [mint] id source; injectable so tests stay deterministic + * @returns {{ id: string|null, write: 'set'|'delete'|null }} + * id — the distinct id to send with, or null when nothing may be sent + * write — what the caller must persist: store `id`, delete the stored + * value, or nothing + * + * Telemetry off deletes the stored id rather than parking it: an opt-out + * that leaves a resumable identifier behind is not an opt-out, and turning + * telemetry back on deliberately mints a new one. A malformed stored value + * is replaced rather than sent — it can only come from a hand-edited or + * half-written settings file, and neither is an identity worth keeping. + */ +function resolveInstallId({ stored, enabled }, mint) { + const valid = typeof stored === 'string' && INSTALL_ID_RE.test(stored); + + if (!enabled) { + return { id: null, write: stored === undefined || stored === null ? null : 'delete' }; + } + if (valid) return { id: stored, write: null }; + return { id: (mint || randomUUID)(), write: 'set' }; +} + /** * How much of the analytics quota any single run may spend. * @@ -258,6 +301,7 @@ module.exports = { normalizeTool, validateEvent, effectiveEnabled, + resolveInstallId, createRateLimiter, DEFAULT_RATE_LIMIT, diffSpecLifecycle, diff --git a/test/telemetry.test.js b/test/telemetry.test.js index 44c6c255..75082568 100644 --- a/test/telemetry.test.js +++ b/test/telemetry.test.js @@ -9,7 +9,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); +const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); // ─── effectiveEnabled — the re-opt-in regression ────────── @@ -33,6 +33,50 @@ test('failed settings load fails CLOSED regardless of the cached value', () => { assert.equal(effectiveEnabled({ value: false, loadFailed: true }), false); }); +// ─── resolveInstallId — one stable id, and no id at all ─── + +const UUID_A = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; +const mintB = () => '9f8e7d6c-5b4a-4938-8271-615243342516'; + +test('no stored id mints one and asks for it to be written', () => { + const r = resolveInstallId({ stored: null, enabled: true }, mintB); + assert.equal(r.id, mintB()); + assert.equal(r.write, 'set'); +}); + +test('a stored id is reused and needs no write', () => { + const r = resolveInstallId({ stored: UUID_A, enabled: true }, mintB); + assert.equal(r.id, UUID_A); + assert.equal(r.write, null); +}); + +test('a malformed stored id is replaced rather than sent', () => { + for (const bad of ['', 'not-a-uuid', 42, {}, 'hostname-of-this-mac']) { + const r = resolveInstallId({ stored: bad, enabled: true }, mintB); + assert.equal(r.id, mintB(), `${JSON.stringify(bad)} should be replaced`); + assert.equal(r.write, 'set'); + } +}); + +test('telemetry off yields no id and asks for the stored one to be deleted', () => { + const r = resolveInstallId({ stored: UUID_A, enabled: false }, mintB); + assert.equal(r.id, null); + assert.equal(r.write, 'delete'); +}); + +test('telemetry off with nothing stored writes nothing', () => { + const r = resolveInstallId({ stored: null, enabled: false }, mintB); + assert.equal(r.id, null); + assert.equal(r.write, null); +}); + +test('a minted id is a random UUID, not derived from the machine', () => { + const a = resolveInstallId({ stored: null, enabled: true }); + const b = resolveInstallId({ stored: null, enabled: true }); + assert.match(a.id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.notEqual(a.id, b.id); +}); + // ─── The registry is enum-only ──────────────────────────── test('registry props are arrays of fixed strings — no free-form values possible', () => { From 57b49a0f83b6da0beb50d5e8c4809996bde59dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:39:08 +0300 Subject: [PATCH 03/23] feat(telemetry): send to PostHog instead of Aptabase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aptabase attaches no user identifier, so it could only ever report event counts — one person launching forty times and forty people launching once read identically. Attaching the install id to the same eleven events makes unique users, the activation funnel and retention cohorts answerable without adding a single new event. EU host, geoip disabled globally, person properties limited to OS and app version — the two fields Aptabase attached anyway. track()'s gate order is unchanged, so the registry allowlist still decides what may leave. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 27 ++-- .../outcome.md | 10 ++ .frame/tasks.json | 6 +- package-lock.json | 121 +++++++++++++++--- package.json | 2 +- src/main/telemetry.js | 105 +++++++++++---- 6 files changed, 221 insertions(+), 50 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 22de2b3f..416b616a 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4339,17 +4339,26 @@ "enforceFailClosed" ], "depends": [ - "@aptabase/electron/main", + "posthog-node", + "electron", "userSettings", "telemetryEvents" ], "functions": { "init": { - "line": 40, - "purpose": "Initialize Aptabase. MUST be called before app.whenReady() because the" + "line": 60, + "purpose": "Build the PostHog client." + }, + "distinctId": { + "line": 80, + "purpose": "This install's distinct id, resolved on first use and cached for the run." + }, + "personProperties": { + "line": 99, + "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 57, + "line": 112, "params": [ "name", "props" @@ -4357,22 +4366,22 @@ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, "trackAppStarted": { - "line": 74, + "line": 135, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 82, + "line": 143, "params": [ "enabled" ], - "purpose": "Toggle telemetry from Settings. Persists the new state. Aptabase is" + "purpose": "Toggle telemetry from Settings. Persists the new state. The client is" }, "enforceFailClosed": { - "line": 100, + "line": 161, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 111, + "line": 172, "purpose": "Effective enabled state. Default ON when the setting has never been" } } diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 5ef15848..9c638a9b 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -8,3 +8,13 @@ _Captured: 2026-09-21 · 2 file change(s)_ --- +## T02 — Replace Aptabase with posthog-node as the transport + +Replaced `@aptabase/electron` with `posthog-node` (v5.52.5) and rewrote `telemetry.js` around `client.capture({ distinctId, event, properties })` on the EU host with geoip disabled; `track()`'s gate order is unchanged. Two things the plan did not spell out: the install id is resolved **lazily** on first send rather than in `init()`, because `userSettings` loads after `init()` and an early read would mint an id for someone who had opted out; and `init()` now refuses to build a client while the API key is still the placeholder, so the app runs and sends nothing instead of throwing. Person properties are `$os` and `$app_version` only, sent once per run. Files touched: `src/main/telemetry.js`, `package.json`, `package-lock.json`. + +Followup: the PostHog project API key is still `phc_REPLACE_WITH_PROJECT_API_KEY` — nothing is sent until it is filled in. + +_Captured: 2026-09-21 · 3 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index 2ccaa252..3f2485a4 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8380,13 +8380,13 @@ "title": "Replace `@aptabase/electron` with `posthog-node` in `package.json` and `src/main/telemetry.js` — client on `https://eu.i.posthog.com` with `disableGeoip: true`, `distinctId` on every `capture`, `$set` of OS and app version on the first capture of a launch, corrected module header — leaving `track()`'s gate order, `validateEvent` and the rate limiter untouched, autocapture off, and the SDK confined to the main process", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T02", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:39:07Z", + "completedAt": "2026-09-21T13:39:07Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T03", diff --git a/package-lock.json b/package-lock.json index 87cc637d..b168a588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@aptabase/electron": "^0.3.1", "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", "d3": "^7.9.0", @@ -19,6 +18,7 @@ "lucide": "^0.577.0", "marked": "^17.0.5", "node-pty": "^1.0.0", + "posthog-node": "^5.52.5", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" }, @@ -29,15 +29,6 @@ "esbuild": "^0.27.2" } }, - "node_modules/@aptabase/electron": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@aptabase/electron/-/electron-0.3.1.tgz", - "integrity": "sha512-FECaGsjuoQu70F+M6V1evdgLP7yaq/sne9fC60AZZ6B9RsCDlxFBnJzdq3+xevHlUx6AB5o9ygUhQ+ONT9EqiA==", - "license": "MIT", - "peerDependencies": { - "electron": ">= 3.x" - } - }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -144,6 +135,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", @@ -165,6 +157,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -179,6 +172,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -188,6 +182,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -197,6 +192,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -1169,6 +1165,21 @@ "node": ">=14" } }, + "node_modules/@posthog/core": { + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.1.tgz", + "integrity": "sha512-S76bSbCHGVC8oQa1zyue6A6WzkNf1Ue4oRvfvBNLRnFM3UoWnpWnQ4T/4IzrIEtVCQYv1OI7gk8uL8/U6Tuu7w==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.412.3" + } + }, + "node_modules/@posthog/types": { + "version": "1.412.4", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.412.4.tgz", + "integrity": "sha512-Q7lV9O9TbLngjOYw1ucm3bS3tn48xb1B4ZQdDy8gv7yfKe99hCTeWYG4xZ36nJM80VxpTF9TriiJt5hngDOkBg==", + "license": "MIT" + }, "node_modules/@profoundlogic/hogan": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@profoundlogic/hogan/-/hogan-3.0.4.tgz", @@ -1198,6 +1209,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1210,6 +1222,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, "license": "MIT", "dependencies": { "defer-to-connect": "^2.0.0" @@ -1232,6 +1245,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", @@ -1264,12 +1278,14 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, "license": "MIT" }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1286,6 +1302,7 @@ "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" @@ -1307,6 +1324,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1324,6 +1342,7 @@ "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1821,6 +1840,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, "license": "MIT", "optional": true }, @@ -1864,6 +1884,7 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, "license": "MIT", "engines": { "node": "*" @@ -1959,6 +1980,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.6.0" @@ -1968,6 +1990,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, "license": "MIT", "dependencies": { "clone-response": "^1.0.2", @@ -2129,6 +2152,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" @@ -2654,6 +2678,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2671,6 +2696,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -2686,6 +2712,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2711,6 +2738,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2720,6 +2748,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2738,6 +2767,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2785,6 +2815,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true, "license": "MIT", "optional": true }, @@ -2951,9 +2982,9 @@ "version": "28.3.3", "resolved": "https://registry.npmjs.org/electron/-/electron-28.3.3.tgz", "integrity": "sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==", + "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^18.11.18", @@ -3109,6 +3140,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -3118,6 +3150,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3134,7 +3167,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3144,7 +3177,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3183,6 +3216,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true, "license": "MIT", "optional": true }, @@ -3242,6 +3276,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -3262,6 +3297,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "debug": "^4.1.1", @@ -3307,6 +3343,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, "license": "MIT", "dependencies": { "pend": "~1.2.0" @@ -3508,6 +3545,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, "license": "MIT", "dependencies": { "pump": "^3.0.0" @@ -3567,6 +3605,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -3585,6 +3624,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3602,7 +3642,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3615,6 +3655,7 @@ "version": "11.8.6", "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", @@ -3640,6 +3681,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3656,6 +3698,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3733,6 +3776,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { @@ -3753,6 +3797,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", @@ -4023,6 +4068,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -4036,6 +4082,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, "license": "ISC", "optional": true }, @@ -4069,6 +4116,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -4109,6 +4157,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4229,6 +4278,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4298,6 +4348,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -4443,6 +4494,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -4889,6 +4941,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4901,6 +4954,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4911,6 +4965,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4960,6 +5015,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5077,6 +5133,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -5115,6 +5172,26 @@ "node": ">=10.4.0" } }, + "node_modules/posthog-node": { + "version": "5.52.5", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.52.5.tgz", + "integrity": "sha512-r4KRXh2MvcHYh1s3NUca022GqujkJjpmD4qbRzWOLVTu8BfN3fqZj+MyKIxvHPXsS8sKsNVX+MlKk2qzW0Y4GQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.55.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, "node_modules/postject": { "version": "1.0.0-alpha.6", "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", @@ -5157,6 +5234,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5199,6 +5277,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -5219,6 +5298,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5287,12 +5367,14 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, "license": "MIT" }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" @@ -5381,6 +5463,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { @@ -5458,7 +5541,7 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5471,6 +5554,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, "license": "MIT", "optional": true }, @@ -5478,6 +5562,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -5628,6 +5713,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, "license": "BSD-3-Clause", "optional": true }, @@ -5726,6 +5812,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" @@ -5934,6 +6021,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, "engines": { @@ -5947,6 +6035,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, "license": "MIT" }, "node_modules/unique-filename": { @@ -6092,6 +6181,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, "license": "ISC" }, "node_modules/xmlbuilder": { @@ -6172,6 +6262,7 @@ "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", diff --git a/package.json b/package.json index 5580a1db..4158f4c7 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "esbuild": "^0.27.2" }, "dependencies": { - "@aptabase/electron": "^0.3.1", "@fontsource/inter": "^5.3.0", "@fontsource/jetbrains-mono": "^5.3.0", "d3": "^7.9.0", @@ -46,6 +45,7 @@ "lucide": "^0.577.0", "marked": "^17.0.5", "node-pty": "^1.0.0", + "posthog-node": "^5.52.5", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" }, diff --git a/src/main/telemetry.js b/src/main/telemetry.js index a438d8c4..fe4bbd4e 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -1,7 +1,7 @@ /** * Telemetry * - * Anonymous usage events via Aptabase. Default opt-out: telemetry runs + * Anonymous usage events via PostHog. Default opt-out: telemetry runs * unless the user disables it from Settings, and fails closed when the * settings file is unreadable. Every event must be declared in the * registry in telemetryEvents.js — event names plus low-cardinality enum @@ -9,19 +9,35 @@ * strings, no personally identifying information. The full event list is * documented in PRIVACY.md; keep the two in sync. * - * The Aptabase app key below is a public identifier (not a secret). It - * has write-only permissions and cannot read the dashboard or delete - * data. Same model as Google Analytics tracking IDs. + * Events carry a stable install id (a random UUID, see resolveInstallId) + * so the dashboard can count unique users, follow the activation funnel + * and read retention cohorts. It identifies an install, never a person: + * nothing here can be joined to a name, an email or a machine. + * + * The PostHog project API key below is a public identifier (not a secret). + * It is write-only — it cannot read the dashboard or delete data. Same + * model as Google Analytics tracking IDs. */ -const aptabase = require('@aptabase/electron/main'); +const { PostHog } = require('posthog-node'); +const { app } = require('electron'); const userSettings = require('./userSettings'); const telemetryEvents = require('./telemetryEvents'); -const APTABASE_APP_KEY = 'A-EU-5590504973'; +// Replace with the project API key from PostHog → Settings → Project API key. +// Until it is a real `phc_…` key, init() leaves the client unbuilt and every +// track() call is a no-op — Frame works, nothing is sent. +const POSTHOG_API_KEY = 'phc_REPLACE_WITH_PROJECT_API_KEY'; +// EU residency. PRIVACY.md promises the IP is not retained, so geo lookup is +// off globally rather than per call. +const POSTHOG_HOST = 'https://eu.i.posthog.com'; + const ENABLED_KEY = 'telemetryEnabled'; +const INSTALL_ID_KEY = 'telemetryInstallId'; -let initialized = false; +let client = null; +let installId = null; +let identified = false; // Bounds what one run can spend of the analytics quota; see the limiter's // note in telemetryEvents.js for why an app that only sends user-driven @@ -29,24 +45,63 @@ let initialized = false; const rateLimiter = telemetryEvents.createRateLimiter(); /** - * Initialize Aptabase. MUST be called before app.whenReady() because the - * SDK uses protocol.registerSchemesAsPrivileged internally. + * Build the PostHog client. * - * We always initialize (regardless of opt-out state) because the call has - * no network side-effects on its own — events only go out when trackEvent - * runs, and that path is gated by isEnabled(). Initializing eagerly avoids - * the chicken-and-egg with userSettings (which loads after app.whenReady). + * Unlike Aptabase — which had to initialize before app.whenReady() because + * it registered a privileged protocol scheme — posthog-node has no such + * constraint. The call site is unchanged anyway: moving it buys nothing and + * only risks the boot order. + * + * We always build (regardless of opt-out state) because construction has no + * network side-effects — events only go out when capture runs, and that path + * is gated by isEnabled(). Building eagerly avoids the chicken-and-egg with + * userSettings, which loads after app.whenReady. */ function init() { - if (initialized) return; + if (client) return; + if (!POSTHOG_API_KEY.startsWith('phc_') || POSTHOG_API_KEY.includes('REPLACE')) { + console.warn('Telemetry: no PostHog project API key configured — nothing will be sent'); + return; + } try { - aptabase.initialize(APTABASE_APP_KEY); - initialized = true; + client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST, disableGeoip: true }); } catch (err) { - console.error('Telemetry: Aptabase init failed', err); + console.error('Telemetry: PostHog init failed', err); } } +/** + * This install's distinct id, resolved on first use and cached for the run. + * + * Lazy because userSettings loads after init(): asking earlier would read an + * empty cache and mint an id for a user who had opted out. Returns null when + * telemetry is off, which is also the caller's signal to send nothing. + */ +function distinctId() { + if (installId) return installId; + const { id, write } = telemetryEvents.resolveInstallId({ + stored: userSettings.get(INSTALL_ID_KEY), + enabled: isEnabled() + }); + if (write === 'set') userSettings.set(INSTALL_ID_KEY, id); + else if (write === 'delete') userSettings.set(INSTALL_ID_KEY, null); + installId = id; + return id; +} + +/** + * Person properties, sent once per run alongside the first event. + * + * Operating system and app version only — exactly the two fields Aptabase + * attached automatically and that PRIVACY.md already discloses, so the + * disclosure surface does not grow with the move. + */ +function personProperties() { + if (identified) return undefined; + identified = true; + return { $set: { $os: process.platform, $app_version: app.getVersion() } }; +} + /** * Send a registered anonymous event. No-op if disabled or not initialized. * The (name, props) pair is validated against the registry in @@ -55,16 +110,22 @@ function init() { * renderer via IPC) can ship content past the allowlist. */ function track(name, props) { - if (!isEnabled() || !initialized) return; + if (!isEnabled() || !client) return; const validated = telemetryEvents.validateEvent(name, props); if (validated === null) return; const gate = rateLimiter.check(Date.now()); if (gate.notice) console.warn('Telemetry:', gate.notice); if (!gate.allowed) return; + const id = distinctId(); + if (!id) return; try { - aptabase.trackEvent(name, Object.keys(validated).length ? validated : undefined); + client.capture({ + distinctId: id, + event: name, + properties: Object.assign({}, validated, personProperties()) + }); } catch (err) { - console.error('Telemetry: trackEvent failed', err); + console.error('Telemetry: capture failed', err); } } @@ -76,8 +137,8 @@ function trackAppStarted() { } /** - * Toggle telemetry from Settings. Persists the new state. Aptabase is - * already initialized on boot; flipping this flag just gates trackEvent. + * Toggle telemetry from Settings. Persists the new state. The client is + * already built on boot; flipping this flag just gates capture. */ function setEnabled(enabled) { const value = enabled === true; From 14739bffbbd9dd75ccacf3fe9a1abd59c443fc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:40:40 +0300 Subject: [PATCH 04/23] feat(telemetry): flush the PostHog batcher before quitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posthog-node queues events and sends them on an interval, so a session's last events — the ones that say what a user did just before leaving — died with the process. before-quit now preventDefaults once, flushes with a 2s ceiling, and re-quits, reusing the shape the live-agent confirmation already uses. A dead network cannot hold the app open. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 35 +++++++++++-------- .../outcome.md | 8 +++++ .frame/tasks.json | 6 ++-- src/main/index.js | 35 ++++++++++++------- src/main/telemetry.js | 26 +++++++++++++- 5 files changed, 79 insertions(+), 31 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 416b616a..cb321f76 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -2301,26 +2301,26 @@ ], "functions": { "confirmQuitWithLiveAgents": { - "line": 53, + "line": 54, "purpose": "Quitting (or closing the window) tears down every PTY — killing running" }, "createWindow": { - "line": 71, + "line": 72, "purpose": "Create main application window" }, "probeCoreDeps": { - "line": 172 + "line": 173 }, "setupAllIPC": { - "line": 202, + "line": 203, "purpose": "Setup all IPC handlers" }, "init": { - "line": 280, + "line": 281, "purpose": "Initialize application" }, "initModulesWithWindow": { - "line": 326, + "line": 327, "params": [ "window" ], @@ -4336,7 +4336,8 @@ "trackAppStarted", "setEnabled", "isEnabled", - "enforceFailClosed" + "enforceFailClosed", + "shutdown" ], "depends": [ "posthog-node", @@ -4346,19 +4347,19 @@ ], "functions": { "init": { - "line": 60, + "line": 64, "purpose": "Build the PostHog client." }, "distinctId": { - "line": 80, + "line": 84, "purpose": "This install's distinct id, resolved on first use and cached for the run." }, "personProperties": { - "line": 99, + "line": 103, "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 112, + "line": 116, "params": [ "name", "props" @@ -4366,23 +4367,27 @@ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, "trackAppStarted": { - "line": 135, + "line": 139, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 143, + "line": 147, "params": [ "enabled" ], "purpose": "Toggle telemetry from Settings. Persists the new state. The client is" }, "enforceFailClosed": { - "line": 161, + "line": 165, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 172, + "line": 176, "purpose": "Effective enabled state. Default ON when the setting has never been" + }, + "shutdown": { + "line": 192, + "purpose": "Flush the batcher and close the client, for the quit path." } } }, diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 9c638a9b..10b837fb 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -18,3 +18,11 @@ _Captured: 2026-09-21 · 3 file change(s)_ --- +## T03 — Flush the batcher on the quit path + +Added `telemetry.shutdown()` (awaited `client.shutdown(2000)`, never throwing) and wired it into the existing `before-quit` handler. Went beyond plan.md's one line deliberately: a fire-and-forget call there does not flush, because Electron continues quitting and the process dies mid-request — so the handler now preventDefaults once, flushes, then re-quits, reusing the shape the live-agent confirmation already established. A `telemetryFlushed` latch keeps the second pass from looping. Files touched: `src/main/telemetry.js`, `src/main/index.js`. + +_Captured: 2026-09-21 · 2 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index 3f2485a4..f7e7a143 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8393,13 +8393,13 @@ "title": "Call `posthog.shutdown()` from the existing `before-quit` handler in `src/main/index.js` so the batcher flushes a session's final events", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T03", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:40:40Z", + "completedAt": "2026-09-21T13:40:40Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T04", diff --git a/src/main/index.js b/src/main/index.js index 60fdebde..52939ad6 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -44,6 +44,7 @@ const orchestrationManager = require('./orchestrationManager'); let mainWindow = null; let quitConfirmed = false; +let telemetryFlushed = false; /** * Quitting (or closing the window) tears down every PTY — killing running @@ -312,8 +313,8 @@ function init() { crashGuard.init(); // Send the launch event after userSettings is loaded so the opt-out - // check uses the correct state. Aptabase itself was initialized earlier - // (before app.whenReady) — see app lifecycle below. + // check uses the correct state, and so the install id resolves against + // the user's real choice rather than an empty cache. telemetry.trackAppStarted(); // Setup IPC handlers @@ -340,10 +341,10 @@ function initModulesWithWindow(window) { activityLog.attachWindow(window); } -// Aptabase MUST be initialized before app.whenReady() because the SDK -// internally calls protocol.registerSchemesAsPrivileged, which is only -// allowed pre-ready. Initialization itself doesn't send anything; the -// actual app_started event is fired from init() after userSettings loads. +// Build the PostHog client. Construction sends nothing; the app_started +// event is fired after userSettings loads, from app.whenReady below. The +// call site predates posthog-node — Aptabase had to run pre-ready — and +// stays here because moving it buys nothing and risks the boot order. telemetry.init(); // App lifecycle @@ -372,14 +373,24 @@ app.whenReady().then(() => { perfMonitor.mark('window-created'); }); -// Confirm-on-quit: Cmd-Q / app menu / OS shutdown with live agents. +// Confirm-on-quit: Cmd-Q / app menu / OS shutdown with live agents, then +// flush telemetry. posthog-node batches, so a session's last events — the +// ones that say what the user did just before leaving — would otherwise die +// with the process. The flush borrows the same preventDefault-then-re-quit +// shape the confirmation already uses, and telemetry.shutdown() carries its +// own timeout so a dead network cannot hold the app open. app.on('before-quit', (e) => { - if (quitConfirmed) return; - if (!confirmQuitWithLiveAgents()) { - e.preventDefault(); - return; + if (!quitConfirmed) { + if (!confirmQuitWithLiveAgents()) { + e.preventDefault(); + return; + } + quitConfirmed = true; } - quitConfirmed = true; + if (telemetryFlushed) return; + telemetryFlushed = true; + e.preventDefault(); + telemetry.shutdown().finally(() => app.quit()); }); app.on('window-all-closed', () => { diff --git a/src/main/telemetry.js b/src/main/telemetry.js index fe4bbd4e..6aaba8fe 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -32,6 +32,10 @@ const POSTHOG_API_KEY = 'phc_REPLACE_WITH_PROJECT_API_KEY'; // off globally rather than per call. const POSTHOG_HOST = 'https://eu.i.posthog.com'; +// Bounds the quit path: a dead network must not be able to hold the app +// open, so the flush gets this long and the quit proceeds regardless. +const SHUTDOWN_TIMEOUT_MS = 2000; + const ENABLED_KEY = 'telemetryEnabled'; const INSTALL_ID_KEY = 'telemetryInstallId'; @@ -176,4 +180,24 @@ function isEnabled() { }); } -module.exports = { init, track, trackAppStarted, setEnabled, isEnabled, enforceFailClosed }; +/** + * Flush the batcher and close the client, for the quit path. + * + * posthog-node queues events and sends them on an interval, so a session's + * last events would otherwise die with the process — the very events that + * say what a user did just before leaving. Resolves either way: a flush + * that cannot reach the network must not be able to hold the app open, so + * the SDK's own timeout bounds it and a failure is logged, not thrown. + */ +async function shutdown() { + if (!client) return; + try { + await client.shutdown(SHUTDOWN_TIMEOUT_MS); + } catch (err) { + console.error('Telemetry: shutdown failed', err); + } finally { + client = null; + } +} + +module.exports = { init, track, trackAppStarted, setEnabled, isEnabled, enforceFailClosed, shutdown }; From b909aa36919c98dd2be4ea920704d491951fe819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:41:45 +0300 Subject: [PATCH 05/23] feat(telemetry): opting out deletes the install id An opt-out that leaves a resumable identifier on disk is not an opt-out. setEnabled now re-resolves, so the toggle deletes or mints immediately rather than at the next launch. The new tests compose effectiveEnabled with resolveInstallId: the risk was never that either is wrong, but that the wiring consults the wrong one, so a fail-closed path could still leave an identifier behind. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 10 ++--- .../outcome.md | 8 ++++ .frame/tasks.json | 6 +-- src/main/telemetry.js | 13 +++++- test/telemetry.test.js | 41 +++++++++++++++++++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index cb321f76..61b62f7b 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4371,22 +4371,22 @@ "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 147, + "line": 153, "params": [ "enabled" ], - "purpose": "Toggle telemetry from Settings. Persists the new state. The client is" + "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" }, "enforceFailClosed": { - "line": 165, + "line": 174, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 176, + "line": 185, "purpose": "Effective enabled state. Default ON when the setting has never been" }, "shutdown": { - "line": 192, + "line": 201, "purpose": "Flush the batcher and close the client, for the quit path." } } diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 10b837fb..071bfbf9 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -26,3 +26,11 @@ _Captured: 2026-09-21 · 2 file change(s)_ --- +## T04 — Opt-out destroys the install id + +Made `setEnabled` re-resolve the install id so the toggle lands on disk at once — off deletes the stored id, on mints a new one. Added four composition tests over `effectiveEnabled` + `resolveInstallId`; the fail-closed cases now assert not just that nothing is sent but that nothing is kept, which is the stronger claim PRIVACY.md will make. A returning user who opted out and back in is deliberately a new user to the dashboard. Files touched: `src/main/telemetry.js`, `test/telemetry.test.js`. + +_Captured: 2026-09-21 · 2 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index f7e7a143..991a0cc7 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8406,13 +8406,13 @@ "title": "Delete the stored install ID in `setEnabled(false)` and mint a fresh one on re-enable, and extend the existing fail-closed tests in `test/telemetry.test.js` to assert no ID survives while telemetry is off", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T04", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:41:45Z", + "completedAt": "2026-09-21T13:41:45Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T05", diff --git a/src/main/telemetry.js b/src/main/telemetry.js index 6aaba8fe..4ec08fc4 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -141,12 +141,21 @@ function trackAppStarted() { } /** - * Toggle telemetry from Settings. Persists the new state. The client is - * already built on boot; flipping this flag just gates capture. + * Toggle telemetry from Settings. Persists the new state, then re-resolves + * the install id so the toggle takes effect on disk immediately. + * + * Turning telemetry off deletes the stored id rather than parking it: an + * opt-out that leaves a resumable identifier behind is not an opt-out. + * Turning it back on therefore mints a new one, and the returning user is + * deliberately a new user to the dashboard — continuity is the thing the + * opt-out was asked to break. */ function setEnabled(enabled) { const value = enabled === true; userSettings.set(ENABLED_KEY, value); + installId = null; + identified = false; + distinctId(); return value; } diff --git a/test/telemetry.test.js b/test/telemetry.test.js index 75082568..42b3a36d 100644 --- a/test/telemetry.test.js +++ b/test/telemetry.test.js @@ -77,6 +77,47 @@ test('a minted id is a random UUID, not derived from the machine', () => { assert.notEqual(a.id, b.id); }); +// ─── opt-out leaves no identifier behind ────────────────── +// +// The composition is the point: whatever decides telemetry is off — an +// explicit opt-out or a settings file that could not be read — must also +// leave no install id on disk. Testing the two functions separately would +// miss a wiring that consulted the wrong one. + +const idFor = (state) => + resolveInstallId({ stored: state.stored, enabled: effectiveEnabled(state) }, mintB); + +test('an explicit opt-out yields no id and deletes the stored one', () => { + const r = idFor({ value: false, loadFailed: false, stored: UUID_A }); + assert.equal(r.id, null); + assert.equal(r.write, 'delete'); +}); + +test('an unreadable settings file yields no id, whatever was cached', () => { + for (const value of [null, true, false]) { + const r = idFor({ value, loadFailed: true, stored: UUID_A }); + assert.equal(r.id, null, `loadFailed with cached ${value} must send nothing`); + assert.equal(r.write, 'delete'); + } +}); + +test('turning telemetry back on mints a new id, not the old one', () => { + const off = idFor({ value: false, loadFailed: false, stored: UUID_A }); + assert.equal(off.write, 'delete'); + // The delete has landed, so the re-enable sees nothing stored. + const on = idFor({ value: true, loadFailed: false, stored: null }); + assert.equal(on.write, 'set'); + assert.notEqual(on.id, UUID_A); +}); + +test('a healthy default-on install keeps its id across launches', () => { + const first = idFor({ value: null, loadFailed: false, stored: null }); + assert.equal(first.write, 'set'); + const second = idFor({ value: null, loadFailed: false, stored: first.id }); + assert.equal(second.id, first.id); + assert.equal(second.write, null); +}); + // ─── The registry is enum-only ──────────────────────────── test('registry props are arrays of fixed strings — no free-form values possible', () => { From 243676e54bf8935dc9613530b3270cfe96ef51db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:43:24 +0300 Subject: [PATCH 06/23] feat(telemetry): sanitize exceptions before they can be sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message is where a user's tree leaks — Node writes the offending path straight into it — so both halves are redacted for secret shapes and then stripped of absolute paths, keeping the basename. "billing.js" still says which file and says nothing about who owns it. The POSIX pattern requires two segments and a lookbehind so a URL in an error message survives instead of being mistaken for a filesystem path. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 33 ++++++++---- .../outcome.md | 8 +++ .frame/tasks.json | 6 +-- src/main/telemetryEvents.js | 48 +++++++++++++++++ test/telemetry.test.js | 51 ++++++++++++++++++- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 61b62f7b..e420423d 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4400,24 +4400,26 @@ "validateEvent", "effectiveEnabled", "resolveInstallId", + "sanitizeException", "createRateLimiter", "DEFAULT_RATE_LIMIT", "diffSpecLifecycle", "renameSpecLifecycle" ], "depends": [ - "node:crypto" + "node:crypto", + "../scripts/redact" ], "functions": { "normalizeTool": { - "line": 65, + "line": 66, "params": [ "id" ], "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" }, "validateEvent": { - "line": 80, + "line": 81, "params": [ "name", "props" @@ -4425,15 +4427,28 @@ "purpose": "Validate an (event, props) pair against the registry." }, "effectiveEnabled": { - "line": 104, + "line": 105, "params": [ "{ value", "loadFailed }" ], "purpose": "Effective telemetry state from the persisted setting plus settings-load" }, + "stripAbsolutePaths": { + "line": 130, + "params": [ + "text" + ] + }, + "sanitizeException": { + "line": 146, + "params": [ + "err" + ], + "purpose": "Make an exception safe to send." + }, "resolveInstallId": { - "line": 140, + "line": 187, "params": [ "{ stored", "enabled }", @@ -4442,21 +4457,21 @@ "purpose": "Decide this launch's install id from what is stored and whether telemetry" }, "createRateLimiter": { - "line": 171, + "line": 218, "params": [ "options" ], "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" }, "stampedSince": { - "line": 231, + "line": 278, "params": [ "stamp", "since" ] }, "diffSpecLifecycle": { - "line": 250, + "line": 297, "params": [ "previous", "specs", @@ -4465,7 +4480,7 @@ "purpose": "Compare one push of a project's specs with this run's earlier looks at it." }, "renameSpecLifecycle": { - "line": 289, + "line": 336, "params": [ "state", "oldSlug", diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 071bfbf9..d71a00fe 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -34,3 +34,11 @@ _Captured: 2026-09-21 · 2 file change(s)_ --- +## T05 — sanitizeException in the pure policy module + +Added `sanitizeException` to the pure module: redact first, then strip absolute paths to basenames on both POSIX and Windows shapes. Two refinements the plan did not specify — the POSIX pattern requires two or more segments and a `(?|]+){2,}/g; +const WINDOWS_PATH_RE = /[A-Za-z]:\\(?:[^\s\\:;,()'"<>|]+\\)*[^\s\\:;,()'"<>|]*/g; + +function stripAbsolutePaths(text) { + if (typeof text !== 'string' || text.length === 0) return text; + return text + .replace(POSIX_PATH_RE, (m) => m.slice(m.lastIndexOf('/') + 1) || m) + .replace(WINDOWS_PATH_RE, (m) => m.slice(m.lastIndexOf('\\') + 1) || m); +} + +/** + * Make an exception safe to send. + * + * @param {{ message?: string, stack?: string, name?: string }} err + * @returns {{ name: string, message: string, stack: string }} + * + * Never throws and never returns undefined fields: this runs on the error + * path, where a second failure would lose the first one. + */ +function sanitizeException(err) { + const src = err || {}; + const clean = (v) => stripAbsolutePaths(redact(typeof v === 'string' ? v : '')); + return { + name: clean(src.name) || 'Error', + message: clean(src.message), + stack: clean(src.stack) + }; +} + // ─── The install identifier ─────────────────────────────── // // Frame's analytics became user-level when it moved to PostHog: counting @@ -302,6 +349,7 @@ module.exports = { validateEvent, effectiveEnabled, resolveInstallId, + sanitizeException, createRateLimiter, DEFAULT_RATE_LIMIT, diffSpecLifecycle, diff --git a/test/telemetry.test.js b/test/telemetry.test.js index 42b3a36d..6441f321 100644 --- a/test/telemetry.test.js +++ b/test/telemetry.test.js @@ -9,7 +9,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); +const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, sanitizeException, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); // ─── effectiveEnabled — the re-opt-in regression ────────── @@ -118,6 +118,55 @@ test('a healthy default-on install keeps its id across launches', () => { assert.equal(second.write, null); }); +// ─── sanitizeException — the opt-in channel's safety ────── + +test('an absolute POSIX path in a message is reduced to its basename', () => { + const r = sanitizeException( + new Error("ENOENT: no such file or directory, open '/Users/someone/their-startup/src/billing.js'") + ); + assert.equal(r.message, "ENOENT: no such file or directory, open 'billing.js'"); + assert.ok(!r.message.includes('someone')); + assert.ok(!r.message.includes('their-startup')); +}); + +test('a Windows path is reduced the same way', () => { + const r = sanitizeException({ message: String.raw`open 'C:\Users\someone\their-startup\billing.js'` }); + assert.equal(r.message, "open 'billing.js'"); +}); + +test('stack frames keep the file and line, lose the tree above it', () => { + const stack = [ + 'Error: boom', + ' at thing (/Users/someone/their-startup/src/thing.js:10:5)', + ' at run (/Applications/Frame.app/Contents/Resources/app/src/main/index.js:42:1)' + ].join('\n'); + const r = sanitizeException({ message: 'boom', stack }); + assert.ok(r.stack.includes('at thing (thing.js:10:5)')); + assert.ok(r.stack.includes('at run (index.js:42:1)')); + assert.ok(!r.stack.includes('their-startup')); +}); + +test('a URL survives — it is not somebody\'s filesystem', () => { + const r = sanitizeException({ message: 'POST https://eu.i.posthog.com/batch failed with 503' }); + assert.equal(r.message, 'POST https://eu.i.posthog.com/batch failed with 503'); +}); + +test('secret shapes are redacted before anything else happens', () => { + const r = sanitizeException({ message: 'auth failed token=abcdef123456 using sk-ant-abcdefghijklmno' }); + assert.ok(!r.message.includes('abcdef123456')); + assert.ok(!r.message.includes('sk-ant-abcdefghijklmno')); + assert.ok(r.message.includes('[REDACTED]')); +}); + +test('sanitizeException never throws and never returns undefined fields', () => { + for (const input of [undefined, null, {}, { message: 42 }, new Error()]) { + const r = sanitizeException(input); + assert.equal(typeof r.name, 'string'); + assert.equal(typeof r.message, 'string'); + assert.equal(typeof r.stack, 'string'); + } +}); + // ─── The registry is enum-only ──────────────────────────── test('registry props are arrays of fixed strings — no free-form values possible', () => { From 082e8284b04444e2cc5f1d978b253147b11b7fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:44:39 +0300 Subject: [PATCH 07/23] feat(telemetry): opt-in error reporting on its own channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error_occurred says something failed N times and never what failed. This carries real exception detail — but deliberately not as an event: it never touches the registry, so validateEvent stays mechanically enum-only and a reviewer does not have to trust a call site. Opt-in, as PRIVACY.md committed before this existed. Gated on analytics being on at all, sharing the event rate limiter, and the SDK only ever sees a reconstructed error carrying the sanitized fields. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 38 ++++++++---- .../outcome.md | 8 +++ .frame/tasks.json | 6 +- src/main/crashGuard.js | 9 ++- src/main/telemetry.js | 59 ++++++++++++++++++- 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index e420423d..52b3bc84 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -534,22 +534,23 @@ "depends": [ "electron", "logger", + "telemetry", "shared/ipcChannels", "userSettings" ], "functions": { "init": { - "line": 21 + "line": 24 }, "attachWindow": { - "line": 71, + "line": 78, "params": [ "window" ], "purpose": "Wire per-window signals; called from createWindow." }, "notify": { - "line": 85, + "line": 92, "params": [ "source", "message" @@ -4334,8 +4335,10 @@ "init", "track", "trackAppStarted", + "captureException", "setEnabled", "isEnabled", + "isErrorReportingEnabled", "enforceFailClosed", "shutdown" ], @@ -4347,46 +4350,57 @@ ], "functions": { "init": { - "line": 64, + "line": 68, "purpose": "Build the PostHog client." }, "distinctId": { - "line": 84, + "line": 88, "purpose": "This install's distinct id, resolved on first use and cached for the run." }, "personProperties": { - "line": 103, + "line": 107, "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 116, + "line": 120, "params": [ "name", "props" ], "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, + "isErrorReportingEnabled": { + "line": 148, + "purpose": "Whether exception detail may be sent." + }, + "captureException": { + "line": 165, + "params": [ + "err" + ], + "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." + }, "trackAppStarted": { - "line": 139, + "line": 186, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 153, + "line": 200, "params": [ "enabled" ], "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" }, "enforceFailClosed": { - "line": 174, + "line": 221, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 185, + "line": 232, "purpose": "Effective enabled state. Default ON when the setting has never been" }, "shutdown": { - "line": 201, + "line": 248, "purpose": "Flush the batcher and close the client, for the quit path." } } diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index d71a00fe..f4500fe4 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -42,3 +42,11 @@ _Captured: 2026-09-21 · 2 file change(s)_ --- +## T06 — The opt-in error channel + +Added `errorReportingEnabled` (opt-in, explicit `true` only), `captureException` and `isErrorReportingEnabled` to `telemetry.js`, and called the former from crashGuard's two existing handlers. Beyond the plan: the SDK receives a **reconstructed** Error carrying only the sanitized fields rather than the original, closing the path by which the raw message or stack could be read off the object downstream; and error reporting is gated on `isEnabled()` too, so one opt-out silences both channels. Files touched: `src/main/telemetry.js`, `src/main/crashGuard.js`. + +_Captured: 2026-09-21 · 2 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index fd13ce12..c65b00c0 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8432,13 +8432,13 @@ "title": "Add the `errorReportingEnabled` setting (default `false`) and `telemetry.captureException`, gated on that setting and on `isEnabled()` and passed through the same rate limiter instance as `track()`, then wire `src/main/crashGuard.js`'s `uncaughtException` and `unhandledRejection` handlers into it", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T06", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:44:39Z", + "completedAt": "2026-09-21T13:44:39Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T07", diff --git a/src/main/crashGuard.js b/src/main/crashGuard.js index 91707562..e1948b40 100644 --- a/src/main/crashGuard.js +++ b/src/main/crashGuard.js @@ -4,7 +4,9 @@ * Before this module, a single uncaught exception killed the whole main * process silently and renderer/child crashes went unobserved. Everything * here logs (rotating, redacted file via logger) and degrades instead of - * dying: uncaught errors surface as a renderer health notice, a dead + * dying, and — only when the user has opted in to error reporting — also + * sends the exception, sanitized, via telemetry: uncaught errors surface + * as a renderer health notice, a dead * renderer offers reload, and Electron's built-in crashReporter collects * **local-only** minidumps (uploadToServer: false — nothing ever leaves the * machine; see PRIVACY.md). Dump collection is optional via the Settings @@ -13,6 +15,7 @@ const { app, dialog, crashReporter } = require('electron'); const logger = require('./logger'); +const telemetry = require('./telemetry'); const { IPC } = require('../shared/ipcChannels'); const userSettings = require('./userSettings'); @@ -21,12 +24,16 @@ let mainWindow = null; function init() { process.on('uncaughtException', (err) => { logger.error('crash', 'uncaughtException:', err); + // Sanitized and gated on the opt-in setting; a no-op for everyone who + // has not turned error reporting on. See telemetry.captureException. + telemetry.captureException(err); notify('uncaught-exception', `Main process error: ${err.message}`); }); process.on('unhandledRejection', (reason) => { const err = reason instanceof Error ? reason : new Error(String(reason)); logger.error('crash', 'unhandledRejection:', err); + telemetry.captureException(err); notify('unhandled-rejection', `Unhandled rejection: ${err.message}`); }); diff --git a/src/main/telemetry.js b/src/main/telemetry.js index 4ec08fc4..50df4ca4 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -38,6 +38,10 @@ const SHUTDOWN_TIMEOUT_MS = 2000; const ENABLED_KEY = 'telemetryEnabled'; const INSTALL_ID_KEY = 'telemetryInstallId'; +// Opt-IN, unlike ENABLED_KEY. PRIVACY.md committed, before this existed, +// that any ability to *send* crash detail would be a separate opt-in +// setting; absent means off, and only an explicit true turns it on. +const ERROR_REPORTING_KEY = 'errorReportingEnabled'; let client = null; let installId = null; @@ -133,6 +137,49 @@ function track(name, props) { } } +/** + * Whether exception detail may be sent. + * + * Two gates, both required: analytics must be on at all (so a telemetry + * opt-out silences this too, and a corrupt settings file fails closed here + * as well), and this setting must be explicitly true. Anything else — + * absent, null, a stray string — reads as off. + */ +function isErrorReportingEnabled() { + return isEnabled() && userSettings.get(ERROR_REPORTING_KEY) === true; +} + +/** + * Send one exception, sanitized. No-op unless error reporting is explicitly on. + * + * Deliberately not an event: this never touches the registry, so + * validateEvent stays mechanically enum-only and a reviewer does not have + * to trust a call site. It does share the event rate limiter, because an + * exception repeating inside a render loop is exactly the shape of bug that + * would otherwise spend a month's quota in an afternoon. + * + * The raw error is never handed to the SDK — a reconstructed one carries + * only the sanitized fields, so there is no path by which the original + * message or stack could be read off it later. + */ +function captureException(err) { + if (!isErrorReportingEnabled() || !client) return; + const gate = rateLimiter.check(Date.now()); + if (gate.notice) console.warn('Telemetry:', gate.notice); + if (!gate.allowed) return; + const id = distinctId(); + if (!id) return; + const safe = telemetryEvents.sanitizeException(err); + try { + const scrubbed = new Error(safe.message); + scrubbed.name = safe.name; + scrubbed.stack = safe.stack; + client.captureException(scrubbed, id); + } catch (e) { + console.error('Telemetry: captureException failed', e); + } +} + /** * Anonymous event marking this launch. */ @@ -209,4 +256,14 @@ async function shutdown() { } } -module.exports = { init, track, trackAppStarted, setEnabled, isEnabled, enforceFailClosed, shutdown }; +module.exports = { + init, + track, + trackAppStarted, + captureException, + setEnabled, + isEnabled, + isErrorReportingEnabled, + enforceFailClosed, + shutdown +}; From 241fd30bc55bf87e2024786ff49b1216d6c3cc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:45:51 +0300 Subject: [PATCH 08/23] feat(telemetry): forward renderer exceptions to the error channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of Frame's surface is the renderer, so an error channel that only heard from the main process would miss where users actually hit bugs. Sanitization stays in main, for the same reason TELEMETRY_TRACK revalidates there: it is the only place the guarantee can be enforced whatever a renderer module sends. Only name, message and stack cross the boundary — an Error does not survive structured cloning intact. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 20 ++++++++++++--- .../outcome.md | 8 ++++++ .frame/tasks.json | 6 ++--- src/main/index.js | 8 ++++++ src/renderer/index.js | 25 +++++++++++++++++++ src/shared/ipcChannels.js | 3 ++- 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 52b3bc84..a93f0884 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -2317,11 +2317,11 @@ "purpose": "Setup all IPC handlers" }, "init": { - "line": 281, + "line": 289, "purpose": "Initialize application" }, "initModulesWithWindow": { - "line": 327, + "line": 335, "params": [ "window" ], @@ -2334,6 +2334,7 @@ "SET_USER_SETTING", "TELEMETRY_SET_ENABLED", "TELEMETRY_TRACK", + "TELEMETRY_EXCEPTION", "GET_LOG_INFO", "TERMINAL_INPUT", "RECONCILE_TERMINALS" @@ -8300,6 +8301,13 @@ "startAiSession": { "line": 991, "purpose": "Start the selected AI tool (Claude Code / Codex CLI / etc.) in a fresh" + }, + "forwardException": { + "line": 1016, + "params": [ + "err" + ], + "purpose": "Renderer exceptions reach the same opt-in channel the main process uses." } }, "ipc": { @@ -8310,7 +8318,8 @@ "UPDATE_AVAILABLE" ], "emits": [ - "REFRESH_GIT_STATUS" + "REFRESH_GIT_STATUS", + "TELEMETRY_EXCEPTION" ] } }, @@ -14417,6 +14426,11 @@ "name": "telemetry-track", "direction": "", "description": "" + }, + "TELEMETRY_EXCEPTION": { + "name": "telemetry-exception", + "direction": "", + "description": "" } }, "spec": { diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index f4500fe4..4db6525b 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -50,3 +50,11 @@ _Captured: 2026-09-21 · 2 file change(s)_ --- +## T07 — Renderer exceptions reach the same channel + +Added the `TELEMETRY_EXCEPTION` channel, its main-process listener, and `error` / `unhandledrejection` hooks in the renderer entry point. Used `window.addEventListener('error')` rather than `window.onerror` as the plan's prose said, because the assignment form would silently replace any handler another module had installed. Sanitization deliberately stays in main, matching how `TELEMETRY_TRACK` revalidates there. Files touched: `src/shared/ipcChannels.js`, `src/main/index.js`, `src/renderer/index.js`. + +_Captured: 2026-09-21 · 3 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index c65b00c0..f15f417c 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8445,13 +8445,13 @@ "title": "Add `TELEMETRY_EXCEPTION` to `src/shared/ipcChannels.js`, its `ipcMain.on` listener in `src/main/index.js` sanitizing in main, and `window.onerror` / `unhandledrejection` hooks in `src/renderer/index.js`", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T07", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:45:51Z", + "completedAt": "2026-09-21T13:45:51Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T08", diff --git a/src/main/index.js b/src/main/index.js index 52939ad6..62e67fa5 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -247,6 +247,14 @@ function setupAllIPC() { telemetry.track(name, props); }); + // Telemetry — renderer exceptions. The renderer forwards the raw shape and + // captureException sanitizes here, for the same reason TELEMETRY_TRACK + // revalidates here: the main process is the only place the guarantee can + // be enforced, whatever a renderer module sends. + ipcMain.on(IPC.TELEMETRY_EXCEPTION, (event, err) => { + telemetry.captureException(err); + }); + // Diagnostics — Settings "Open Logs Folder" ipcMain.handle(IPC.GET_LOG_INFO, () => ({ logPath: logger.getLogPath(), diff --git a/src/renderer/index.js b/src/renderer/index.js index 84a65941..7a3303b5 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -1009,6 +1009,31 @@ async function startAiSession() { /** * Start application when DOM is ready */ +// Renderer exceptions reach the same opt-in channel the main process uses. +// Only the three fields are forwarded — an Error does not survive structured +// cloning intact, and sending anything wider would hand the main process +// fields nobody has sanitized. captureException does the stripping there. +function forwardException(err) { + try { + const e = err instanceof Error ? err : new Error(String(err)); + ipcRenderer.send(IPC.TELEMETRY_EXCEPTION, { + name: e.name, + message: e.message, + stack: e.stack + }); + } catch (_) { + // Reporting a failure must never become one. + } +} + +window.addEventListener('error', (event) => { + forwardException(event.error || event.message); +}); + +window.addEventListener('unhandledrejection', (event) => { + forwardException(event.reason); +}); + window.addEventListener('load', () => { init(); diff --git a/src/shared/ipcChannels.js b/src/shared/ipcChannels.js index ca56504f..9b80e087 100644 --- a/src/shared/ipcChannels.js +++ b/src/shared/ipcChannels.js @@ -157,9 +157,10 @@ const IPC = { GIT_STATUS_DATA: 'git-status-data', GET_GIT_DIFF: 'get-git-diff', - // Telemetry (Aptabase, opt-out via Settings) + // Telemetry (PostHog, opt-out via Settings; exceptions are a separate opt-in) TELEMETRY_SET_ENABLED: 'telemetry-set-enabled', TELEMETRY_TRACK: 'telemetry-track', + TELEMETRY_EXCEPTION: 'telemetry-exception', // Health / crash recovery (main → renderer notices) MAIN_PROCESS_ERROR: 'main-process-error', From e14f46bc61df7f473ca8aade32832ed605d8f933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:47:32 +0300 Subject: [PATCH 09/23] feat(settings): an opt-in row for error reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default off, as PRIVACY.md committed. The row disables itself when anonymous usage stats are off, because captureException requires both — a live-looking switch over a silenced channel would be a lie in the UI. The stored value survives, so turning stats back on restores the choice. The usage-stats description now names the random install ID: that disclosure is what the default-on model was kept in exchange for. Adds a disabled state for switches to settings-modal.css, which had one for selects and buttons only. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 29 +++++++++------ .../outcome.md | 8 ++++ .frame/tasks.json | 6 +-- index.html | 17 ++++++++- src/renderer/frameSettingsModal.js | 37 ++++++++++++++++++- .../styles/components/settings-modal.css | 14 +++++++ 6 files changed, 95 insertions(+), 16 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index a93f0884..6d9900c8 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -6608,19 +6608,19 @@ ], "functions": { "init": { - "line": 45 + "line": 47 }, "initAboutSection": { - "line": 104 + "line": 119 }, "runCheck": { - "line": 189, + "line": 204, "params": [ "userInitiated" ] }, "renderUpdateState": { - "line": 228, + "line": 243, "params": [ "{ checked", "found", @@ -6632,32 +6632,39 @@ ] }, "showUpdateBanner": { - "line": 252, + "line": 267, "params": [ "info" ] }, "hideUpdateBanner": { - "line": 263 + "line": 278 }, "hideSidebarDot": { - "line": 267 + "line": 282 }, "formatRelative": { - "line": 273, + "line": 288, "params": [ "date" ] }, "initZoomSelect": { - "line": 289, + "line": 304, "purpose": "Fill the Interface size select from the shared ladder — one option per" }, "syncZoomFromMain": { - "line": 308 + "line": 323 + }, + "syncErrorReportingAvailability": { + "line": 339, + "params": [ + "analyticsOn" + ], + "purpose": "Error reporting is gated on analytics in main (telemetry.captureException" }, "syncToggleFromSettings": { - "line": 318 + "line": 346 } }, "ipc": { diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 4db6525b..3f47ad51 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -58,3 +58,11 @@ _Captured: 2026-09-21 · 3 file change(s)_ --- +## T08 — The error-reporting row in Settings + +Added the opt-in "Send error reports" row and updated the usage-stats copy to name the random install ID, which is the disclosure D1 traded for keeping the default-on model. **Went one file outside plan.md's Files**: `src/renderer/styles/components/settings-modal.css` had a disabled state for selects and buttons but none for a switch, and without it the gated row would have looked operable while doing nothing — the two rules follow the file's existing 0.5-opacity convention. Files touched: `index.html`, `src/renderer/frameSettingsModal.js`, `src/renderer/styles/components/settings-modal.css`. + +_Captured: 2026-09-21 · 3 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index f15f417c..2da8f27b 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8458,13 +8458,13 @@ "title": "Add the error-reporting row to the Privacy & Analytics section of `index.html` and wire it in `src/renderer/frameSettingsModal.js` beside the crash-dumps toggle", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T08", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:47:32Z", + "completedAt": "2026-09-21T13:47:32Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T09", diff --git a/index.html b/index.html index 5b79df75..3e35b126 100644 --- a/index.html +++ b/index.html @@ -1582,7 +1582,8 @@

Privacy & Analytics

- Frame collects only your app version and OS to count active users. + A random ID for this install, plus your app version and OS, so we can + count active users and see which features get used. No code, file paths, prompts, or personal data — ever.

@@ -1591,6 +1592,20 @@

Privacy & Analytics

+
+
+ +

+ Off by default. When on, crashes are sent with their message and stack, + stripped of secrets and of every path above the filename. + Requires anonymous usage stats to be on. +

+
+ +
diff --git a/src/renderer/frameSettingsModal.js b/src/renderer/frameSettingsModal.js index 23729e06..fbaefd05 100644 --- a/src/renderer/frameSettingsModal.js +++ b/src/renderer/frameSettingsModal.js @@ -22,11 +22,13 @@ const uiZoom = require('../shared/uiZoom'); const TELEMETRY_KEY = 'telemetryEnabled'; const CRASH_DUMPS_KEY = 'crashDumpsEnabled'; +const ERROR_REPORTING_KEY = 'errorReportingEnabled'; const DISMISSED_VERSION_KEY = 'dismissedUpdateVersion'; let overlay = null; let toggleEl = null; let crashDumpsToggleEl = null; +let errorReportingToggleEl = null; // Appearance › Interface size (ui-zoom-steps spec). Main owns the step; this // select shows it and asks for another over UI_ZOOM_SET. let zoomSelectEl = null; @@ -45,6 +47,7 @@ let currentUpdateInfo = null; function init() { toggleEl = document.getElementById('settings-telemetry-toggle'); crashDumpsToggleEl = document.getElementById('settings-crash-dumps-toggle'); + errorReportingToggleEl = document.getElementById('settings-error-reporting-toggle'); zoomSelectEl = document.getElementById('settings-ui-zoom'); initZoomSelect(); @@ -68,13 +71,25 @@ function init() { syncToggleFromSettings(); initAboutSection(); - // Toggle: persist + tell main process to enable/disable Aptabase + // Toggle: persist + tell main to enable/disable sending (and to mint or + // delete the install id, which setEnabled does on the spot) toggleEl.addEventListener('change', async () => { const enabled = toggleEl.checked; await ipcRenderer.invoke(IPC.SET_USER_SETTING, TELEMETRY_KEY, enabled); await ipcRenderer.invoke(IPC.TELEMETRY_SET_ENABLED, enabled); + // Error reporting rides on analytics being on, so the row follows it + // rather than sitting enabled over a switch that silences it. + syncErrorReportingAvailability(enabled); }); + // Error reports: opt-in, persisted setting only — telemetry.captureException + // reads it per call, so a change takes effect immediately. + if (errorReportingToggleEl) { + errorReportingToggleEl.addEventListener('change', async () => { + await ipcRenderer.invoke(IPC.SET_USER_SETTING, ERROR_REPORTING_KEY, errorReportingToggleEl.checked); + }); + } + // Crash dumps: persisted setting only — crashGuard reads it at startup // (the reporter can't be stopped once started, so changes apply on next launch) if (crashDumpsToggleEl) { @@ -315,6 +330,19 @@ async function syncZoomFromMain() { } } +/** + * Error reporting is gated on analytics in main (telemetry.captureException + * requires both), so the row is disabled rather than left looking live over a + * switch that silences it. The stored value is untouched — turning analytics + * back on restores whatever the user had chosen here. + */ +function syncErrorReportingAvailability(analyticsOn) { + if (!errorReportingToggleEl) return; + errorReportingToggleEl.disabled = !analyticsOn; + const row = errorReportingToggleEl.closest('.settings-row'); + if (row) row.classList.toggle('settings-row-disabled', !analyticsOn); +} + async function syncToggleFromSettings() { if (!toggleEl) return; syncZoomFromMain(); @@ -322,6 +350,13 @@ async function syncToggleFromSettings() { // Default ON when unset (opt-out semantics) toggleEl.checked = value !== false; + if (errorReportingToggleEl) { + const errors = await ipcRenderer.invoke(IPC.GET_USER_SETTING, ERROR_REPORTING_KEY); + // Default OFF when unset — this one is opt-in + errorReportingToggleEl.checked = errors === true; + syncErrorReportingAvailability(toggleEl.checked); + } + if (crashDumpsToggleEl) { const dumps = await ipcRenderer.invoke(IPC.GET_USER_SETTING, CRASH_DUMPS_KEY); // Default ON when unset (local-only; nothing is uploaded) diff --git a/src/renderer/styles/components/settings-modal.css b/src/renderer/styles/components/settings-modal.css index c378751f..da91e297 100644 --- a/src/renderer/styles/components/settings-modal.css +++ b/src/renderer/styles/components/settings-modal.css @@ -261,6 +261,20 @@ transition: transform 0.18s, background 0.18s; } +/* A switch whose channel is gated on another switch. Error reporting rides + on anonymous usage stats being on, so when that is off this one reads as + unavailable rather than sitting live over something that silences it. The + stored value is untouched; turning stats back on restores the choice. + Matches the opacity the disabled select and action button already use. */ +.settings-switch input:disabled + .settings-switch-slider { + opacity: 0.5; + cursor: default; +} + +.settings-row-disabled .settings-row-text { + opacity: 0.5; +} + .settings-switch input:checked + .settings-switch-slider { background: var(--accent-primary); border-color: var(--accent-primary); From 0d3346e96cd99b983210e80d2f0794a973b3de8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:50:43 +0300 Subject: [PATCH 10/23] feat(telemetry): version the disclosure notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit People acknowledged a system that carried no identifier, and it now sends a random install id. A boolean could only ever show that once; a version bump shows the changed text once to everyone, including the users who dismissed the previous one. The old boolean is still read, so an existing install counts as having seen version 1 rather than nothing. The decision is main's — the renderer draws and dismisses, and reaches main for it over TELEMETRY_NOTICE_STATE. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 66 ++++++++++++------- .../outcome.md | 8 +++ .frame/tasks.json | 6 +- index.html | 4 +- src/main/index.js | 4 ++ src/main/telemetry.js | 24 +++++++ src/main/telemetryEvents.js | 35 ++++++++++ src/renderer/telemetryNotice.js | 30 ++++++--- src/shared/ipcChannels.js | 1 + test/telemetry.test.js | 29 +++++++- 10 files changed, 171 insertions(+), 36 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 6d9900c8..d3fa49a3 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -2317,11 +2317,11 @@ "purpose": "Setup all IPC handlers" }, "init": { - "line": 289, + "line": 293, "purpose": "Initialize application" }, "initModulesWithWindow": { - "line": 335, + "line": 339, "params": [ "window" ], @@ -2335,6 +2335,7 @@ "TELEMETRY_SET_ENABLED", "TELEMETRY_TRACK", "TELEMETRY_EXCEPTION", + "TELEMETRY_NOTICE_STATE", "GET_LOG_INFO", "TERMINAL_INPUT", "RECONCILE_TERMINALS" @@ -4337,6 +4338,7 @@ "track", "trackAppStarted", "captureException", + "noticeState", "setEnabled", "isEnabled", "isErrorReportingEnabled", @@ -4351,19 +4353,19 @@ ], "functions": { "init": { - "line": 68, + "line": 72, "purpose": "Build the PostHog client." }, "distinctId": { - "line": 88, + "line": 92, "purpose": "This install's distinct id, resolved on first use and cached for the run." }, "personProperties": { - "line": 107, + "line": 111, "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 120, + "line": 124, "params": [ "name", "props" @@ -4371,37 +4373,41 @@ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, "isErrorReportingEnabled": { - "line": 148, + "line": 152, "purpose": "Whether exception detail may be sent." }, "captureException": { - "line": 165, + "line": 169, "params": [ "err" ], "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." }, + "noticeState": { + "line": 196, + "purpose": "Whether the disclosure notice is due, and the version to store when it is" + }, "trackAppStarted": { - "line": 186, + "line": 209, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 200, + "line": 223, "params": [ "enabled" ], "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" }, "enforceFailClosed": { - "line": 221, + "line": 244, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 232, + "line": 255, "purpose": "Effective enabled state. Default ON when the setting has never been" }, "shutdown": { - "line": 248, + "line": 271, "purpose": "Flush the batcher and close the client, for the quit path." } } @@ -4416,6 +4422,8 @@ "effectiveEnabled", "resolveInstallId", "sanitizeException", + "NOTICE_VERSION", + "shouldShowNotice", "createRateLimiter", "DEFAULT_RATE_LIMIT", "diffSpecLifecycle", @@ -4449,21 +4457,30 @@ ], "purpose": "Effective telemetry state from the persisted setting plus settings-load" }, + "shouldShowNotice": { + "line": 135, + "params": [ + "{ storedVersion", + "legacyShown", + "currentVersion }" + ], + "purpose": "Whether the disclosure notice should be shown." + }, "stripAbsolutePaths": { - "line": 130, + "line": 163, "params": [ "text" ] }, "sanitizeException": { - "line": 146, + "line": 179, "params": [ "err" ], "purpose": "Make an exception safe to send." }, "resolveInstallId": { - "line": 187, + "line": 220, "params": [ "{ stored", "enabled }", @@ -4472,21 +4489,21 @@ "purpose": "Decide this launch's install id from what is stored and whether telemetry" }, "createRateLimiter": { - "line": 218, + "line": 251, "params": [ "options" ], "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" }, "stampedSince": { - "line": 278, + "line": 311, "params": [ "stamp", "since" ] }, "diffSpecLifecycle": { - "line": 297, + "line": 330, "params": [ "previous", "specs", @@ -4495,7 +4512,7 @@ "purpose": "Compare one push of a project's specs with this run's earlier looks at it." }, "renameSpecLifecycle": { - "line": 336, + "line": 369, "params": [ "state", "oldSlug", @@ -12210,13 +12227,13 @@ ], "functions": { "init": { - "line": 21, + "line": 31, "params": [ "openSettings" ] }, "dismiss": { - "line": 48 + "line": 60 } } }, @@ -14438,6 +14455,11 @@ "name": "telemetry-exception", "direction": "", "description": "" + }, + "TELEMETRY_NOTICE_STATE": { + "name": "telemetry-notice-state", + "direction": "", + "description": "" } }, "spec": { diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 3f47ad51..1ff19545 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -66,3 +66,11 @@ _Captured: 2026-09-21 · 3 file change(s)_ --- +## T09 — The disclosure notice becomes versioned + +Replaced the notice's `telemetryNoticeShown` boolean with a `telemetryNoticeVersion` number driven by `shouldShowNotice`, so the changed disclosure reaches the people who dismissed the old one — which is what D4 traded for keeping the default-on model. Corrected course mid-task: the first wiring had the renderer requiring `../main/telemetryEvents`, the only renderer→main require in the codebase, so the decision moved into `telemetry.noticeState()` behind a new `TELEMETRY_NOTICE_STATE` channel and the renderer now only draws and dismisses. Files touched: `src/main/telemetryEvents.js`, `src/main/telemetry.js`, `src/main/index.js`, `src/shared/ipcChannels.js`, `src/renderer/telemetryNotice.js`, `index.html`, `test/telemetry.test.js`. + +_Captured: 2026-09-21 · 7 file change(s)_ + +--- + diff --git a/.frame/tasks.json b/.frame/tasks.json index 2da8f27b..39818fee 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8471,13 +8471,13 @@ "title": "Add `NOTICE_VERSION` and `shouldShowNotice({ storedVersion, currentVersion })` to `src/main/telemetryEvents.js` with tests for fresh install, legacy boolean-only client and already-seen current version, and read the versioned key in `src/renderer/telemetryNotice.js` with copy that names the install ID", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T09", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:50:43Z", + "completedAt": "2026-09-21T13:50:43Z" }, { "id": "task-spec-posthog-analytics-identity-funnels-and-errors-T10", diff --git a/index.html b/index.html index 3e35b126..b3d053ae 100644 --- a/index.html +++ b/index.html @@ -32,7 +32,9 @@

- Frame sends your app version and OS to count active users. No code or file paths. + Frame sends a random ID for this install, your app version and OS, so we can count + active users and see which features get used. No code, file paths or prompts. Turning + this off in Settings deletes the ID.

Manage in Settings diff --git a/src/main/index.js b/src/main/index.js index 62e67fa5..11368761 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -255,6 +255,10 @@ function setupAllIPC() { telemetry.captureException(err); }); + // Telemetry — is the disclosure notice due? The renderer draws it; the + // decision stays here, beside the settings it reads. + ipcMain.handle(IPC.TELEMETRY_NOTICE_STATE, () => telemetry.noticeState()); + // Diagnostics — Settings "Open Logs Folder" ipcMain.handle(IPC.GET_LOG_INFO, () => ({ logPath: logger.getLogPath(), diff --git a/src/main/telemetry.js b/src/main/telemetry.js index 50df4ca4..24e72753 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -42,6 +42,10 @@ const INSTALL_ID_KEY = 'telemetryInstallId'; // that any ability to *send* crash detail would be a separate opt-in // setting; absent means off, and only an explicit true turns it on. const ERROR_REPORTING_KEY = 'errorReportingEnabled'; +const NOTICE_VERSION_KEY = 'telemetryNoticeVersion'; +// Superseded by NOTICE_VERSION_KEY; still read so an existing install is not +// mistaken for a fresh one. Never written again. +const NOTICE_SHOWN_KEY = 'telemetryNoticeShown'; let client = null; let installId = null; @@ -180,6 +184,25 @@ function captureException(err) { } } +/** + * Whether the disclosure notice is due, and the version to store when it is + * acknowledged. + * + * Decided here rather than in the renderer: the policy lives in + * telemetryEvents.js next to everything else telemetry decides, and the + * renderer reaches main for it the way it reaches main for every other + * telemetry call. + */ +function noticeState() { + return { + show: telemetryEvents.shouldShowNotice({ + storedVersion: userSettings.get(NOTICE_VERSION_KEY), + legacyShown: userSettings.get(NOTICE_SHOWN_KEY) + }), + version: telemetryEvents.NOTICE_VERSION + }; +} + /** * Anonymous event marking this launch. */ @@ -261,6 +284,7 @@ module.exports = { track, trackAppStarted, captureException, + noticeState, setEnabled, isEnabled, isErrorReportingEnabled, diff --git a/src/main/telemetryEvents.js b/src/main/telemetryEvents.js index 2ad15216..292317dc 100644 --- a/src/main/telemetryEvents.js +++ b/src/main/telemetryEvents.js @@ -107,6 +107,39 @@ function effectiveEnabled({ value, loadFailed }) { return value !== false; } +// ─── The disclosure notice ──────────────────────────────── +// +// The notice used to be a boolean: shown once, dismissed forever. That was +// honest while telemetry carried no identifier, but people acknowledged a +// system that has since changed — a stable install id now rides along. So +// the flag became a version, and bumping it shows the new text once to +// everyone, including the users who dismissed the old one. +// +// Bump NOTICE_VERSION only when the disclosure itself changes. It is not a +// release counter, and a bump interrupts every user exactly once. + +const NOTICE_VERSION = 2; + +/** + * Whether the disclosure notice should be shown. + * + * @param {{ storedVersion: any, legacyShown?: any, currentVersion?: number }} state + * storedVersion — telemetryNoticeVersion, absent before this existed + * legacyShown — telemetryNoticeShown, the boolean this replaced + * @returns {boolean} + * + * A client that stored only the old boolean is treated as having seen + * version 1: it has read *a* disclosure, just not this one. A fresh install + * has neither and sees the current text once, like everyone else. + */ +function shouldShowNotice({ storedVersion, legacyShown, currentVersion }) { + const current = typeof currentVersion === 'number' ? currentVersion : NOTICE_VERSION; + const seen = Number.isInteger(storedVersion) + ? storedVersion + : (legacyShown === true ? 1 : 0); + return seen < current; +} + // ─── Exception sanitization ─────────────────────────────── // // Exception detail travels a channel of its own — a separate opt-in @@ -350,6 +383,8 @@ module.exports = { effectiveEnabled, resolveInstallId, sanitizeException, + NOTICE_VERSION, + shouldShowNotice, createRateLimiter, DEFAULT_RATE_LIMIT, diffSpecLifecycle, diff --git a/src/renderer/telemetryNotice.js b/src/renderer/telemetryNotice.js index 8d3ea864..bc5b79d5 100644 --- a/src/renderer/telemetryNotice.js +++ b/src/renderer/telemetryNotice.js @@ -1,17 +1,27 @@ /** * Telemetry Notice Banner * - * One-time card in the bottom-right corner, above the status bar, shown on - * the first launch after telemetry was introduced. Independent of the onboarding screen so users - * who never see that screen still get this disclosure. + * Card in the bottom-right corner, above the status bar, carrying the + * telemetry disclosure. Independent of the onboarding screen so users who + * never see that screen still get it. * - * Once acknowledged, never shows again. Persisted via userSettings. + * Versioned rather than once-and-done: people acknowledged a system that + * carried no identifier, and it now sends a random install id. Bumping + * NOTICE_VERSION (telemetryEvents.js) shows the changed text once to + * everyone, including users who dismissed the previous one. + * + * This module draws and dismisses; whether the notice is due is main's call + * (telemetry.noticeState), so the policy sits with the settings it reads. */ const { ipcRenderer } = require('electron'); const { IPC } = require('../shared/ipcChannels'); -const NOTICE_SHOWN_KEY = 'telemetryNoticeShown'; +const NOTICE_VERSION_KEY = 'telemetryNoticeVersion'; + +// The version main said was current when it decided the notice was due — +// stored on dismiss, so acknowledging records exactly what was read. +let noticeVersion = null; let bannerEl = null; let acknowledgeBtn = null; @@ -26,12 +36,14 @@ async function init(openSettings) { if (!bannerEl) return; - // Show only if user hasn't seen it before - const seen = await ipcRenderer.invoke(IPC.GET_USER_SETTING, NOTICE_SHOWN_KEY); - if (seen === true) { + // Show only if this version of the disclosure hasn't been seen. Main + // decides — see telemetry.noticeState. + const { show, version } = await ipcRenderer.invoke(IPC.TELEMETRY_NOTICE_STATE); + if (!show) { bannerEl.remove(); return; } + noticeVersion = version; bannerEl.classList.add('visible'); @@ -48,7 +60,7 @@ async function init(openSettings) { function dismiss() { if (!bannerEl) return; ipcRenderer - .invoke(IPC.SET_USER_SETTING, NOTICE_SHOWN_KEY, true) + .invoke(IPC.SET_USER_SETTING, NOTICE_VERSION_KEY, noticeVersion) .catch((err) => console.error('Telemetry notice: failed to persist dismiss', err) ); diff --git a/src/shared/ipcChannels.js b/src/shared/ipcChannels.js index 9b80e087..fb1baf92 100644 --- a/src/shared/ipcChannels.js +++ b/src/shared/ipcChannels.js @@ -161,6 +161,7 @@ const IPC = { TELEMETRY_SET_ENABLED: 'telemetry-set-enabled', TELEMETRY_TRACK: 'telemetry-track', TELEMETRY_EXCEPTION: 'telemetry-exception', + TELEMETRY_NOTICE_STATE: 'telemetry-notice-state', // Health / crash recovery (main → renderer notices) MAIN_PROCESS_ERROR: 'main-process-error', diff --git a/test/telemetry.test.js b/test/telemetry.test.js index 6441f321..7bfd72a4 100644 --- a/test/telemetry.test.js +++ b/test/telemetry.test.js @@ -9,7 +9,7 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, sanitizeException, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); +const { EVENTS, normalizeTool, validateEvent, effectiveEnabled, resolveInstallId, sanitizeException, NOTICE_VERSION, shouldShowNotice, createRateLimiter, DEFAULT_RATE_LIMIT } = require('../src/main/telemetryEvents'); // ─── effectiveEnabled — the re-opt-in regression ────────── @@ -167,6 +167,33 @@ test('sanitizeException never throws and never returns undefined fields', () => } }); +// ─── shouldShowNotice — a disclosure that can change ────── + +test('a fresh install sees the notice', () => { + assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: undefined }), true); +}); + +test('a client that only stored the old boolean sees the new notice once', () => { + // It read *a* disclosure — the one without an install id — so it counts as + // version 1 and the current version is still ahead of it. + assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: true }), true); + assert.equal(shouldShowNotice({ storedVersion: undefined, legacyShown: true, currentVersion: 1 }), false); +}); + +test('a client that has seen the current version is left alone', () => { + assert.equal(shouldShowNotice({ storedVersion: NOTICE_VERSION }), false); +}); + +test('a stored version ahead of the current one does not re-show', () => { + assert.equal(shouldShowNotice({ storedVersion: NOTICE_VERSION + 1 }), false); +}); + +test('a garbage stored version is treated as never seen', () => { + for (const junk of ['2', null, {}, NaN, 1.5]) { + assert.equal(shouldShowNotice({ storedVersion: junk }), true, `${String(junk)} should re-show`); + } +}); + // ─── The registry is enum-only ──────────────────────────── test('registry props are arrays of fixed strings — no free-form values possible', () => { From d8d4220cce8aa1e743ee675737f59eb2f273d589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:52:51 +0300 Subject: [PATCH 11/23] docs(privacy): PostHog, the install id, and the error opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRIVACY.md is a trust asset, so it moves in the same change as the code it describes. New: what the install id is and is not, that opting out deletes it, the opt-in error channel with a worked example of path stripping, and geolocation and session replay on the never-collected list — PostHog offers both and Frame uses neither. The event table was checked against the registry in both directions: nothing listed that Frame does not send, nothing sent that is not listed. Records the reversal of audit-q3-product-analytics's Aptabase decision in PROJECT_NOTES.md. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/PROJECT_NOTES.md | 47 +++++++++++++++++++ .../outcome.md | 8 ++++ .../status.json | 6 +-- .frame/tasks.json | 6 +-- PRIVACY.md | 39 +++++++++++---- 5 files changed, 92 insertions(+), 14 deletions(-) diff --git a/.frame/PROJECT_NOTES.md b/.frame/PROJECT_NOTES.md index e805b62d..6e24d74b 100644 --- a/.frame/PROJECT_NOTES.md +++ b/.frame/PROJECT_NOTES.md @@ -425,6 +425,53 @@ mainWindow.webContents.openDevTools(); ## Session Notes +### [2026-09-21] Analytics moved to PostHog — reversing the Aptabase decision + +**Context:** `audit-q3-product-analytics` (July) chose Aptabase deliberately and +recorded a rejection of PostHog: *"PostHog's funnels/identity features exceed the +spec's out-of-scope line (no per-user tracking)"* (`plan.md:11`). It measured +activation as unique-users-per-event and accepted the imprecision. Two months of +using that dashboard showed the cost: Aptabase attaches no identifier at all, so +`app_started` cannot distinguish one person launching forty times from forty +people launching once, and there is no funnel, no retention and no per-user +feature usage. Every question worth asking about the roadmap turned out to be +user-level. + +**Decision:** Reverse it. PostHog EU cloud, with a random per-install UUID as the +distinct id. The July constraint was right about the trade — identity is a real +privacy cost — and wrong about which side of it to take, because the alternative +was not "less data", it was "no answers". + +Four choices shaped how the cost gets paid rather than avoided: + +- **Default-on opt-out kept**, not switched to opt-in. On a developer tool opt-in + yields 5–15% participation, and none of the questions survive that sample. The + cost is paid by disclosure instead. +- **Anonymous only.** No self-identify field, no email. The PRIVACY.md line "no + email addresses or any personally identifiable information" stays true as + written. +- **The notice re-shows.** People acknowledged a system with no identifier; + `telemetryNoticeShown` became `telemetryNoticeVersion` so the changed text + reaches them once. Default-on is only honest if the change is surfaced. +- **Error detail on a separate opt-in channel**, not a redacted `message` + property on `error_occurred`. A free-form property would end the registry's + mechanically enum-only guarantee, and nothing would stop the next widening. + +**What did not change:** the `telemetryEvents.js` registry and `validateEvent` +gate, the fail-closed opt-out, and the renderer-revalidated-in-main rule. Only +`track()`'s final send call moved. No event was added — the user-level views come +entirely from attaching the install id to the eleven events that already existed. + +**Rules established:** +- Opting out **deletes** the install id; re-enabling mints a new one. An opt-out + that leaves a resumable identifier is not an opt-out. +- `NOTICE_VERSION` is bumped only when the disclosure itself changes — it is not + a release counter, and a bump interrupts every user once. +- The error channel never becomes an event. If exception detail is ever wanted in + the registry, that is a decision to re-open here, not a property to add. + +--- + ### [2026-01-25] Project Navigation System **Context:** When Claude Code enters a project, it needs to quickly capture the context. diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md index 1ff19545..fe5a31e7 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -74,3 +74,11 @@ _Captured: 2026-09-21 · 7 file change(s)_ --- +## T10 — PRIVACY.md and the recorded reversal + +Rewrote `PRIVACY.md` around PostHog, the install ID, and the opt-in error channel, and recorded the reversal of `audit-q3-product-analytics`'s vendor decision in `PROJECT_NOTES.md` with the four choices that shaped how the privacy cost is paid. Verified the event table against the live registry in both directions — nothing listed that Frame does not send, nothing sent that is not listed. Added geolocation and session replay to the never-collected list, since PostHog offers both and Frame uses neither. Files touched: `PRIVACY.md`, `.frame/PROJECT_NOTES.md`. + +_Captured: 2026-09-21 · 2 file change(s)_ + +--- + diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json index 1b193423..d93d6da7 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json @@ -1,7 +1,7 @@ { "slug": "posthog-analytics-identity-funnels-and-errors", "title": "PostHog analytics — identity, funnels and errors", - "phase": "implementing", + "phase": "done", "generated_task_ids": [ "task-spec-posthog-analytics-identity-funnels-and-errors-T01", "task-spec-posthog-analytics-identity-funnels-and-errors-T02", @@ -16,7 +16,7 @@ ], "ai_tool": "claude-code", "created_at": "2026-09-21T12:43:40Z", - "updated_at": "2026-09-21T13:36:43.547Z", - "last_phase_at": "2026-09-21T13:36:43.547Z", + "updated_at": "2026-09-21T13:52:51.532Z", + "last_phase_at": "2026-09-21T13:52:51.532Z", "implement_mode": "guided" } diff --git a/.frame/tasks.json b/.frame/tasks.json index 39818fee..24d4a89f 100644 --- a/.frame/tasks.json +++ b/.frame/tasks.json @@ -8484,13 +8484,13 @@ "title": "Rewrite `PRIVACY.md` for PostHog, EU residency, the install ID and what it is not, the registry's eleven events unchanged, and the error-reporting opt-in — and record the reversal of `audit-q3-product-analytics`'s vendor decision as a dated entry in `.frame/PROJECT_NOTES.md`", "description": "", "source": "spec:posthog-analytics-identity-funnels-and-errors:T10", - "status": "pending", + "status": "completed", "priority": "medium", "category": "feature", "context": "From spec: posthog-analytics-identity-funnels-and-errors", "createdAt": "2026-09-21T13:24:03.022Z", - "updatedAt": "2026-09-21T13:24:03.022Z", - "completedAt": null + "updatedAt": "2026-09-21T13:52:51Z", + "completedAt": "2026-09-21T13:52:51Z" } ], "metadata": { diff --git a/PRIVACY.md b/PRIVACY.md index 43a27626..fd90941c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,9 @@ Frame is a developer tool. We respect your privacy and try to be transparent abo ## What we collect -When telemetry is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. Aptabase adds the app version and operating system to each event, with no personal identifier. +When telemetry is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. + +Alongside each event go three things and nothing else: a **random install ID** (see below), your **app version**, and your **operating system** (`darwin`, `win32`, `linux`). The complete event list: @@ -26,6 +28,15 @@ This list is enforced in code: events and property values outside this registry This is enough for us to understand how many people use Frame, which features get used, whether new users activate, what fails most often, and which platforms / versions are in use. +## The install ID + +Each install generates a **random ID** the first time it sends anything — a UUID, produced by your machine's random number generator and nothing else. + +- It is **not** derived from your hardware, your machine name, your username, your email, or anything else about you. Two installs on the same computer get different IDs. +- It lets us tell one install from another, which is the only way to count actual users rather than launches, see whether people come back, and find where they get stuck. +- It is stored in Frame's own settings file on your machine and sent with each event. Nothing we hold can connect it to a person. +- **Turning telemetry off deletes it.** Turning telemetry back on generates a new one — the old one is not recoverable, by us or by you. + ## What we do **not** collect - ❌ File paths @@ -33,20 +44,32 @@ This is enough for us to understand how many people use Frame, which features ge - ❌ Project names - ❌ AI prompts or responses - ❌ Terminal input or output -- ❌ Email addresses or any personally identifiable information -- ❌ IP address (Aptabase strips this before storage) +- ❌ Email addresses, names, or any personally identifiable information +- ❌ Location — IP-based geolocation is disabled on every request we make +- ❌ Screen recordings or session replay of any kind ## How to opt out -Open **Settings** (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux) and toggle **"Send anonymous usage stats"** off. The setting takes effect immediately and persists across launches. +Open **Settings** (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux) and toggle **"Send anonymous usage stats"** off. The setting takes effect immediately and persists across launches, and switching it off deletes the install ID at the same moment. + +Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, telemetry **fails closed** — Frame records it as switched off, nothing is sent, and no install ID is kept. Corruption can never silently re-enable telemetry. + +You can also dismiss the one-time notice card — that doesn't change the setting itself, only hides the card. If what we collect changes materially, the card reappears once with the new text, even if you dismissed the previous one. + +## Error reports (opt-in, off by default) + +Separately from usage stats, Frame can send **crash and exception detail** so bugs can be diagnosed instead of merely counted. This is **off unless you turn it on**, under **Settings → Privacy & Analytics → "Send error reports"**, and it also requires anonymous usage stats to be on. + +When it is on, an exception's message and stack are sent after two passes: -Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, telemetry **fails closed** — Frame records it as switched off, and nothing is sent until you turn it back on in Settings. Corruption can never silently re-enable telemetry. +- **Secret redaction** — the same scrubbing the local log file uses: API keys (`sk-…`), GitHub/Slack tokens, AWS keys, JWTs, `Bearer` headers, and `password=`/`token=`/`api_key=` values become `[REDACTED]`. +- **Path stripping** — every absolute path is reduced to its filename. `open '/Users/you/your-startup/src/billing.js'` is sent as `open 'billing.js'`. The filename says which file broke; nothing says who you are or what you are building. -You can also dismiss the one-time notice banner at the top of the app — that doesn't change the setting itself, only hides the banner. +This channel never touches the event list above, and nothing on it is sent while the toggle is off. ## Where the data goes -Telemetry is sent to [Aptabase](https://aptabase.com), an open-source, privacy-focused analytics service. They do not retain IP addresses and do not share data with third parties. +Telemetry is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). IP-based geolocation is disabled on every request, so your approximate location is never derived or stored. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust. ## Local logging @@ -72,7 +95,7 @@ Frame does a lot of work on its own — watching files, reconciling spec phases, On a crash, Frame saves a **minidump on your machine** using Electron's built-in crash reporter with uploading **disabled** — nothing is ever transmitted, to us or anyone else. This exists purely so a crash can be diagnosed locally. - Toggle it off under **Settings → Privacy & Analytics → "Keep local crash dumps"** (takes effect on next launch). -- If Frame ever gains the ability to *send* crash reports, that will be a separate, **opt-in** setting and will be disclosed here first. +- This is separate from **"Send error reports"** above: minidumps stay on disk whatever that setting says. ## Questions From 4dda9515550eb13323ba88491f448480be01c39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:53:14 +0300 Subject: [PATCH 12/23] docs(spec): posthog-analytics-identity-funnels-and-errors done Co-Authored-By: Claude Opus 5 (1M context) --- .../digest.md | 21 +++++++++++++++++++ .../status.json | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 .frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md new file mode 100644 index 00000000..93004806 --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/digest.md @@ -0,0 +1,21 @@ +--- +keywords: posthog, analytics, install id, funnels, retention, error tracking, telemetry, opt-out, notice version +related: audit-q3-product-analytics, audit-q3-reliability-recovery +--- +Moved analytics off Aptabase onto PostHog EU (`posthog-node`, geoip disabled), +explicitly reversing audit-q3-product-analytics's vendor decision: Aptabase +attaches no identifier, so unique users, funnels and retention were structurally +unanswerable. No event was added — the user-level views come from attaching a +random per-install UUID to the eleven events that already existed; `track()`'s +gate order and `validateEvent` are untouched. Opting out deletes the id +(re-enable mints a new one). Rejected: opt-in analytics (5-15% sample kills the +questions), self-identify (keeps PII out), personless mode, dual-send, and a +redacted `message` prop on `error_occurred` — that last one would have ended the +registry's enum-only guarantee, so exception detail got a separate opt-in channel +(`errorReportingEnabled`, default off) that sanitizes via redact.js plus absolute +path→basename and never touches the registry. Notice became versioned so the +changed disclosure reaches people who dismissed the old one. Rules: the error +channel never becomes an event; `NOTICE_VERSION` bumps only when the disclosure +changes; new policy goes in the pure module (CI runs without `npm ci`). + +Chain: spec.md → plan.md → tasks.md → outcome.md diff --git a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json index d93d6da7..6d32be92 100644 --- a/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/status.json @@ -16,7 +16,7 @@ ], "ai_tool": "claude-code", "created_at": "2026-09-21T12:43:40Z", - "updated_at": "2026-09-21T13:52:51.532Z", - "last_phase_at": "2026-09-21T13:52:51.532Z", + "updated_at": "2026-09-21T13:53:09Z", + "last_phase_at": "2026-09-21T13:53:09Z", "implement_mode": "guided" } From c67926f43d25d57b3a46d5f97a075a348833b037 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 16:54:48 +0300 Subject: [PATCH 13/23] docs(telemetry): the lazy-require comment names posthog-node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reason still holds — telemetry reaches node_modules and electron, and CI runs the suite with neither — only the package changed. Also corrects "below" to "above": the aiToolManager require it points at is earlier in the file. Co-Authored-By: Claude Opus 5 (1M context) --- src/main/frameProject.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/frameProject.js b/src/main/frameProject.js index 89d13749..7a9ed634 100644 --- a/src/main/frameProject.js +++ b/src/main/frameProject.js @@ -1550,8 +1550,8 @@ function setupIPC(ipcMain) { } const config = await initializeFrameProject(projectPath, projectName, { gitSharing }); - // Lazy require, same reason as aiToolManager below: telemetry pulls - // @aptabase/electron → electron, and CI runs the suite with no + // Lazy require, same reason as aiToolManager above: telemetry pulls + // posthog-node and electron, and CI runs the suite with no // node_modules. Keep this module's load graph Electron-free. require('./telemetry').track('project_initialized'); event.sender.send(IPC.FRAME_PROJECT_INITIALIZED, { From a781667e752b8b934cddc6a5e419f40f7b084b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 17:06:38 +0300 Subject: [PATCH 14/23] feat(telemetry): wire the PostHog project token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EU Cloud, project 280352. Write-only and safe in a public app — it can send events and nothing else. The personal and project secret keys can read, change and delete, and must never appear in the source. Telemetry is live from this commit: init() now builds the client instead of refusing on the placeholder. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 24 ++++++++++++------------ src/main/telemetry.js | 10 ++++++---- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index d3fa49a3..1a6301df 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4353,19 +4353,19 @@ ], "functions": { "init": { - "line": 72, + "line": 74, "purpose": "Build the PostHog client." }, "distinctId": { - "line": 92, + "line": 94, "purpose": "This install's distinct id, resolved on first use and cached for the run." }, "personProperties": { - "line": 111, + "line": 113, "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 124, + "line": 126, "params": [ "name", "props" @@ -4373,41 +4373,41 @@ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, "isErrorReportingEnabled": { - "line": 152, + "line": 154, "purpose": "Whether exception detail may be sent." }, "captureException": { - "line": 169, + "line": 171, "params": [ "err" ], "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." }, "noticeState": { - "line": 196, + "line": 198, "purpose": "Whether the disclosure notice is due, and the version to store when it is" }, "trackAppStarted": { - "line": 209, + "line": 211, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 223, + "line": 225, "params": [ "enabled" ], "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" }, "enforceFailClosed": { - "line": 244, + "line": 246, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 255, + "line": 257, "purpose": "Effective enabled state. Default ON when the setting has never been" }, "shutdown": { - "line": 271, + "line": 273, "purpose": "Flush the batcher and close the client, for the quit path." } } diff --git a/src/main/telemetry.js b/src/main/telemetry.js index 24e72753..3aa4e900 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -24,10 +24,12 @@ const { app } = require('electron'); const userSettings = require('./userSettings'); const telemetryEvents = require('./telemetryEvents'); -// Replace with the project API key from PostHog → Settings → Project API key. -// Until it is a real `phc_…` key, init() leaves the client unbuilt and every -// track() call is a no-op — Frame works, nothing is sent. -const POSTHOG_API_KEY = 'phc_REPLACE_WITH_PROJECT_API_KEY'; +// PostHog project token (Settings → Project token & ID, project 280352, EU +// Cloud). Write-only and safe in a public app — it can send events and +// nothing else: it cannot read the dashboard, change settings or delete +// data. The personal and project *secret* keys can do all three and must +// never appear here. +const POSTHOG_API_KEY = 'phc_w4KGHkLoGyutiNQVYoXnzXJmdUKXc7WhzvGXKKt2dzez'; // EU residency. PRIVACY.md promises the IP is not retained, so geo lookup is // off globally rather than per call. const POSTHOG_HOST = 'https://eu.i.posthog.com'; From 1bcb64f1b8c4a8efe020c1c271e6a7cb442fc5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 17:20:45 +0300 Subject: [PATCH 15/23] feat(telemetry): derive country and region from the request IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses this file's first version, which disabled geo lookup, and the PRIVACY.md line that promised location is never derived. PostHog resolves the IP to country/region on arrival and discards the IP itself. PRIVACY.md gains an "Approximate location" section stating what is derived rather than a line saying it is not, and NOTICE_VERSION goes to 3 — collecting location under a card that says location is not collected would have been the real failure. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/PROJECT_NOTES.md | 31 +++++++++++++++++++++++++++++++ .frame/STRUCTURE.json | 24 ++++++++++++------------ PRIVACY.md | 16 +++++++++++++--- index.html | 10 +++++----- src/main/telemetry.js | 9 ++++++--- src/main/telemetryEvents.js | 2 +- 6 files changed, 68 insertions(+), 24 deletions(-) diff --git a/.frame/PROJECT_NOTES.md b/.frame/PROJECT_NOTES.md index 6e24d74b..03619124 100644 --- a/.frame/PROJECT_NOTES.md +++ b/.frame/PROJECT_NOTES.md @@ -425,6 +425,37 @@ mainWindow.webContents.openDevTools(); ## Session Notes +### [2026-09-21] IP-based location turned on, one day after turning it off + +**Context:** The PostHog migration shipped with `disableGeoip: true` and +PRIVACY.md said, in two places, that location is never derived or stored. Asked +the same day whether country was visible in the dashboard, the answer was no — +by our own choice. + +**Decision:** Reverse it. Geo lookup is on; PostHog resolves the request IP to +country/region on arrival and discards the IP. The alternative offered was +device timezone (`Europe/Istanbul`), which answers the same question without +touching a stated promise; it was declined in favour of the IP path. + +**What this cost, recorded so it is not forgotten:** a promise published in the +morning was withdrawn in the afternoon. PRIVACY.md now has an **Approximate +location** section stating what is derived rather than a line saying it is not — +the doc is accurate again, but it is the second version of a claim about the same +subject in one day. `NOTICE_VERSION` went to 3, so every user is interrupted a +second time within days of the first card; the second card is what makes the +change honest and also what makes the first one look provisional. + +**Rule reaffirmed (it held):** `NOTICE_VERSION` bumps when the disclosure +changes. This is exactly that case, and bumping was not optional — collecting +location under a card that says location is not collected would have been the +real failure. + +**Open:** if location is ever narrowed or removed again, do not quietly drop the +PRIVACY.md section — a third revision of the same claim needs to say what changed +and why, or the document stops being read as a commitment. + +--- + ### [2026-09-21] Analytics moved to PostHog — reversing the Aptabase decision **Context:** `audit-q3-product-analytics` (July) chose Aptabase deliberately and diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 1a6301df..6347240c 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4353,19 +4353,19 @@ ], "functions": { "init": { - "line": 74, + "line": 73, "purpose": "Build the PostHog client." }, "distinctId": { - "line": 94, + "line": 97, "purpose": "This install's distinct id, resolved on first use and cached for the run." }, "personProperties": { - "line": 113, + "line": 116, "purpose": "Person properties, sent once per run alongside the first event." }, "track": { - "line": 126, + "line": 129, "params": [ "name", "props" @@ -4373,41 +4373,41 @@ "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." }, "isErrorReportingEnabled": { - "line": 154, + "line": 157, "purpose": "Whether exception detail may be sent." }, "captureException": { - "line": 171, + "line": 174, "params": [ "err" ], "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." }, "noticeState": { - "line": 198, + "line": 201, "purpose": "Whether the disclosure notice is due, and the version to store when it is" }, "trackAppStarted": { - "line": 211, + "line": 214, "purpose": "Anonymous event marking this launch." }, "setEnabled": { - "line": 225, + "line": 228, "params": [ "enabled" ], "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" }, "enforceFailClosed": { - "line": 246, + "line": 249, "purpose": "Make a failed settings load's fail-closed state stick. Called right after" }, "isEnabled": { - "line": 257, + "line": 260, "purpose": "Effective enabled state. Default ON when the setting has never been" }, "shutdown": { - "line": 273, + "line": 276, "purpose": "Flush the batcher and close the client, for the quit path." } } diff --git a/PRIVACY.md b/PRIVACY.md index fd90941c..1267e848 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,7 +6,7 @@ Frame is a developer tool. We respect your privacy and try to be transparent abo When telemetry is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. -Alongside each event go three things and nothing else: a **random install ID** (see below), your **app version**, and your **operating system** (`darwin`, `win32`, `linux`). +Alongside each event go four things and nothing else: a **random install ID** (see below), your **app version**, your **operating system** (`darwin`, `win32`, `linux`), and your **approximate location** (see below). The complete event list: @@ -37,6 +37,15 @@ Each install generates a **random ID** the first time it sends anything — a UU - It is stored in Frame's own settings file on your machine and sent with each event. Nothing we hold can connect it to a person. - **Turning telemetry off deletes it.** Turning telemetry back on generates a new one — the old one is not recoverable, by us or by you. +## Approximate location + +Each event is tagged with the **country and region** it came from, worked out from the IP address of the request. + +- We use it to see which parts of the world Frame is used in, so documentation, defaults and support hours can follow real usage rather than guesswork. +- It is **country and region level** — the same granularity a weather site infers. No city-block accuracy, no GPS, no street address. +- **The IP address itself is not stored.** PostHog resolves it to a location on arrival and discards it; it is never written to the event, and we never see it. +- Turning telemetry off stops this like everything else — no event, no location. + ## What we do **not** collect - ❌ File paths @@ -45,7 +54,8 @@ Each install generates a **random ID** the first time it sends anything — a UU - ❌ AI prompts or responses - ❌ Terminal input or output - ❌ Email addresses, names, or any personally identifiable information -- ❌ Location — IP-based geolocation is disabled on every request we make +- ❌ Your IP address — it is used to derive country and region on arrival, then discarded, never stored +- ❌ Precise location — no GPS, no street-level or city-block accuracy - ❌ Screen recordings or session replay of any kind ## How to opt out @@ -69,7 +79,7 @@ This channel never touches the event list above, and nothing on it is sent while ## Where the data goes -Telemetry is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). IP-based geolocation is disabled on every request, so your approximate location is never derived or stored. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust. +Telemetry is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). PostHog derives country and region from the request IP and discards the IP itself — see **Approximate location** above. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust. ## Local logging diff --git a/index.html b/index.html index b3d053ae..3f158a68 100644 --- a/index.html +++ b/index.html @@ -32,9 +32,9 @@

- Frame sends a random ID for this install, your app version and OS, so we can count - active users and see which features get used. No code, file paths or prompts. Turning - this off in Settings deletes the ID. + Frame sends a random ID for this install, your app version, OS, and the country + your request came from, so we can count active users and see which features get + used. No code, file paths or prompts. Turning this off in Settings deletes the ID.

Manage in Settings @@ -1584,8 +1584,8 @@

Privacy & Analytics

- A random ID for this install, plus your app version and OS, so we can - count active users and see which features get used. + A random ID for this install, plus your app version, OS and country, so we + can count active users and see which features get used. No code, file paths, prompts, or personal data — ever.

diff --git a/src/main/telemetry.js b/src/main/telemetry.js index 3aa4e900..0b72976c 100644 --- a/src/main/telemetry.js +++ b/src/main/telemetry.js @@ -30,8 +30,7 @@ const telemetryEvents = require('./telemetryEvents'); // data. The personal and project *secret* keys can do all three and must // never appear here. const POSTHOG_API_KEY = 'phc_w4KGHkLoGyutiNQVYoXnzXJmdUKXc7WhzvGXKKt2dzez'; -// EU residency. PRIVACY.md promises the IP is not retained, so geo lookup is -// off globally rather than per call. +// EU residency. const POSTHOG_HOST = 'https://eu.i.posthog.com'; // Bounds the quit path: a dead network must not be able to hold the app @@ -78,7 +77,11 @@ function init() { return; } try { - client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST, disableGeoip: true }); + // Geo lookup is ON: PostHog resolves the request IP to country/region and + // discards the IP itself. That is a deliberate reversal of this file's + // first version, which disabled it — see PRIVACY.md, which states what is + // derived, and PROJECT_NOTES for why the trade was re-taken. + client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST }); } catch (err) { console.error('Telemetry: PostHog init failed', err); } diff --git a/src/main/telemetryEvents.js b/src/main/telemetryEvents.js index 292317dc..2e551ce9 100644 --- a/src/main/telemetryEvents.js +++ b/src/main/telemetryEvents.js @@ -118,7 +118,7 @@ function effectiveEnabled({ value, loadFailed }) { // Bump NOTICE_VERSION only when the disclosure itself changes. It is not a // release counter, and a bump interrupts every user exactly once. -const NOTICE_VERSION = 2; +const NOTICE_VERSION = 3; /** * Whether the disclosure notice should be shown. From b42569ad5de39f0d9402b63ab3d453993ad452c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 17:24:22 +0300 Subject: [PATCH 16/23] feat(telemetry): six behaviour events on chokepoints, not buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers what the first eleven could not: which dock panels get used, how people run an implementation, whether the tour lands, whether resume and the task board earn their place, and which settings get touched. Each sits on a chokepoint rather than a button — panel_opened is wired once in dock.js and covers every panel, so adding a panel adds an enum value, not an event. Command names stay out entirely, for the same cardinality reason plugin ids stay out of plugin_toggled. PRIVACY.md gains all six rows in this change, per the standing rule. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 229 ++++++++++++++++------------- PRIVACY.md | 6 + src/main/tasksManager.js | 7 + src/main/telemetryEvents.js | 18 +++ src/renderer/agentDispatch.js | 5 + src/renderer/dock.js | 6 + src/renderer/frameSettingsModal.js | 9 ++ src/renderer/guidedTour.js | 3 + src/renderer/sessionsPanel.js | 4 + 9 files changed, 186 insertions(+), 101 deletions(-) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 6347240c..1cbb8619 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -4179,7 +4179,8 @@ "frameStore", "activityLog", "shared/ipcChannels", - "shared/frameConstants" + "shared/frameConstants", + "telemetry" ], "functions": { "cacheKeyFor": { @@ -4261,14 +4262,14 @@ ] }, "deleteTask": { - "line": 245, + "line": 252, "params": [ "projectPath", "taskId" ] }, "reorderTasks": { - "line": 264, + "line": 271, "params": [ "projectPath", "order" @@ -4276,42 +4277,42 @@ "purpose": "Reorder tasks (used by the Kanban dashboard for drag-and-drop). The renderer" }, "startWatching": { - "line": 322, + "line": 329, "params": [ "projectPath" ] }, "onMetaFileChange": { - "line": 371, + "line": 378, "params": [ "listener" ] }, "notifyMetaChange": { - "line": 375, + "line": 382, "params": [ "projectPath", "filename" ] }, "restartWatching": { - "line": 390, + "line": 397, "params": [ "projectPath" ], "purpose": "Re-arm the watcher on the project's current meta directory. Migration moves" }, "stopWatching": { - "line": 395 + "line": 402 }, "setupIPC": { - "line": 405, + "line": 412, "params": [ "ipcMain" ] }, "getLastSelfWriteAt": { - "line": 465, + "line": 472, "purpose": "Timestamp of this module's last tasks.json write — exported so other" } }, @@ -4435,14 +4436,14 @@ ], "functions": { "normalizeTool": { - "line": 66, + "line": 84, "params": [ "id" ], "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" }, "validateEvent": { - "line": 81, + "line": 99, "params": [ "name", "props" @@ -4450,7 +4451,7 @@ "purpose": "Validate an (event, props) pair against the registry." }, "effectiveEnabled": { - "line": 105, + "line": 123, "params": [ "{ value", "loadFailed }" @@ -4458,7 +4459,7 @@ "purpose": "Effective telemetry state from the persisted setting plus settings-load" }, "shouldShowNotice": { - "line": 135, + "line": 153, "params": [ "{ storedVersion", "legacyShown", @@ -4467,20 +4468,20 @@ "purpose": "Whether the disclosure notice should be shown." }, "stripAbsolutePaths": { - "line": 163, + "line": 181, "params": [ "text" ] }, "sanitizeException": { - "line": 179, + "line": 197, "params": [ "err" ], "purpose": "Make an exception safe to send." }, "resolveInstallId": { - "line": 220, + "line": 238, "params": [ "{ stored", "enabled }", @@ -4489,21 +4490,21 @@ "purpose": "Decide this launch's install id from what is stored and whether telemetry" }, "createRateLimiter": { - "line": 251, + "line": 269, "params": [ "options" ], "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" }, "stampedSince": { - "line": 311, + "line": 329, "params": [ "stamp", "since" ] }, "diffSpecLifecycle": { - "line": 330, + "line": 348, "params": [ "previous", "specs", @@ -4512,7 +4513,7 @@ "purpose": "Compare one push of a project's specs with this run's earlier looks at it." }, "renameSpecLifecycle": { - "line": 369, + "line": 387, "params": [ "state", "oldSlug", @@ -4984,107 +4985,107 @@ "purpose": "The spec.implement path: modal → record implement_mode → stage → dispatch." }, "_isSpecCreatorLane": { - "line": 615, + "line": 620, "params": [ "terminalId" ], "purpose": "Still open, and still carrying the transient name we gave it." }, "_onSpecData": { - "line": 631, + "line": 636, "params": [ "specs" ], "purpose": "A new slug appeared — attach it to the lane that produced it." }, "_bindSpecCreatorLane": { - "line": 651, + "line": 656, "params": [ "terminalId", "slug" ] }, "dispatchSpecNew": { - "line": 687, + "line": 692, "params": [ "description" ], "purpose": "Hand the user's free-form text to the standard `spec.new` flow." }, "getSpecLaneInfo": { - "line": 775, + "line": 780, "params": [ "slug" ], "purpose": "Live info about the lane a spec is assigned to, or null when unassigned" }, "getTaskLaneInfo": { - "line": 799, + "line": 804, "params": [ "taskId" ], "purpose": "Live info about the lane working on a task, or null. Unlike specs there" }, "specStatusDotHtml": { - "line": 820, + "line": 825, "params": [ "slug" ], "purpose": "Activity dot for spec/task rows and detail headers: pulsing while the" }, "taskStatusDotHtml": { - "line": 824, + "line": 829, "params": [ "taskId" ] }, "_activityDotHtml": { - "line": 828, + "line": 833, "params": [ "info" ] }, "onSpecLaneActivity": { - "line": 845, + "line": 850, "params": [ "callback" ], "purpose": "Subscribe to spec-lane activity. Callback: (slug) — consumers re-read" }, "_notifySpecLane": { - "line": 853, + "line": 858, "params": [ "slug" ] }, "onTaskLaneActivity": { - "line": 876, + "line": 881, "params": [ "callback" ], "purpose": "Subscribe to task-lane activity. Callback: (taskId|null) — null means" }, "_notifyTaskLane": { - "line": 884, + "line": 889, "params": [ "taskId" ] }, "_assignedLane": { - "line": 902, + "line": 907, "params": [ "slug" ], "purpose": "Assigned lane for a slug, validated against open terminals — a closed" }, "_laneName": { - "line": 912, + "line": 917, "params": [ "terminalId" ] }, "_askContinueOrNew": { - "line": 921, + "line": 926, "params": [ "frameName", "specName" @@ -5092,14 +5093,14 @@ "purpose": "\"Continue in / Open a new Frame\" — always asked when the spec" }, "_waitForAgentReady": { - "line": 962, + "line": 967, "params": [ "terminalId" ], "purpose": "Cold-start readiness gate: resolves true once laneStatus sees a known" }, "_fail": { - "line": 991, + "line": 996, "params": [ "terminalId", "message" @@ -5112,6 +5113,7 @@ "TERMINAL_DESTROYED" ], "emits": [ + "TELEMETRY_TRACK", "TELEMETRY_TRACK", "TELEMETRY_TRACK", "SPEC_AGENT_ACTIVITY" @@ -5745,6 +5747,8 @@ "lucideIcon" ], "depends": [ + "electron", + "shared/ipcChannels", "dock/dockState", "dnd/sortable", "tooltip", @@ -5760,7 +5764,7 @@ ], "functions": { "lucideIcon": { - "line": 44, + "line": 46, "params": [ "data", "size = 14" @@ -5768,7 +5772,7 @@ "purpose": "Inline SVG for a lucide icon's data (the shape sectionRail.js uses)." }, "emptyState": { - "line": 53, + "line": 55, "params": [ "title", "message" @@ -5776,7 +5780,7 @@ "purpose": "The dock's inline empty state — the shape decisionsView.renderEmpty uses." }, "panelTab": { - "line": 72, + "line": 74, "params": [ "{ label", "icon", @@ -5786,114 +5790,120 @@ "purpose": "A tab hosting one of the legacy side panels (D9). The element keeps its" }, "loadState": { - "line": 190 + "line": 192 }, "persist": { - "line": 198 + "line": 200 }, "init": { - "line": 208 + "line": 210 }, "available": { - "line": 280, + "line": 282, "params": [ "position = state.position" ], "purpose": "The center's extent on the dock's axis, for the size ceiling." }, "applySize": { - "line": 289 + "line": 291 }, "apply": { - "line": 306, + "line": 308, "params": [ "{ refit = false } = {}" ], "purpose": "Paint `state`: side class, size, open/closed, active tab, mounted" }, "mountTab": { - "line": 344, + "line": 346, "params": [ "tab" ] }, "unmountTab": { - "line": 358 + "line": 364 }, "refitCenter": { - "line": 373, + "line": 379, "purpose": "Terminals refit through the existing fit path (D13)." }, "refitTab": { - "line": 380, + "line": 386, "purpose": "The active tab's own refit (Structure re-renders its graph to the new box)." }, "emit": { - "line": 391 + "line": 397 }, "snapshotState": { - "line": 402 + "line": 408 }, "bindResize": { - "line": 408, + "line": 414, "params": [ "handle" ] }, "ensureReady": { - "line": 468 + "line": 474 }, "open": { - "line": 477, + "line": 483, "params": [ "tab" ], "purpose": "Open on `tab` (or the last tab). A tab switch on an open dock is not a size change." }, "close": { - "line": 484 + "line": 490 }, "toggle": { - "line": 492, + "line": 498, "purpose": "⌘J: open ↔ closed, reopening on the last tab at the last size." }, "toggleTab": { - "line": 498, + "line": 504, "params": [ "tab" ], "purpose": "Status-bar / menu semantics: on it → close; on another → switch; closed → open on it." }, "setPosition": { - "line": 505, + "line": 511, "params": [ "position" ] }, "isOpen": { - "line": 513 + "line": 519 }, "activeTab": { - "line": 517 + "line": 523 }, "position": { - "line": 521 + "line": 527 }, "remountActive": { - "line": 530, + "line": 536, "purpose": "Re-run the active tab's mount (the project changed under an open" }, "tabOrder": { - "line": 538, + "line": 544, "purpose": "The strip's current order of tab ids (the user's arrangement)." }, "onChange": { - "line": 543, + "line": 549, "params": [ "fn" ], "purpose": "Follow the dock: fn({ open, position, tab, order }) after every change." } + }, + "ipc": { + "listens": [], + "emits": [ + "TELEMETRY_TRACK" + ] } }, "renderer/dock/dockState": { @@ -6628,16 +6638,16 @@ "line": 47 }, "initAboutSection": { - "line": 119 + "line": 124 }, "runCheck": { - "line": 204, + "line": 209, "params": [ "userInitiated" ] }, "renderUpdateState": { - "line": 243, + "line": 248, "params": [ "{ checked", "found", @@ -6649,39 +6659,39 @@ ] }, "showUpdateBanner": { - "line": 267, + "line": 272, "params": [ "info" ] }, "hideUpdateBanner": { - "line": 278 + "line": 283 }, "hideSidebarDot": { - "line": 282 + "line": 287 }, "formatRelative": { - "line": 288, + "line": 293, "params": [ "date" ] }, "initZoomSelect": { - "line": 304, + "line": 309, "purpose": "Fill the Interface size select from the shared ladder — one option per" }, "syncZoomFromMain": { - "line": 323 + "line": 332 }, "syncErrorReportingAvailability": { - "line": 339, + "line": 348, "params": [ "analyticsOn" ], "purpose": "Error reporting is gated on analytics in main (telemetry.captureException" }, "syncToggleFromSettings": { - "line": 346 + "line": 355 } }, "ipc": { @@ -6690,7 +6700,12 @@ "OPEN_SETTINGS", "UPDATE_AVAILABLE" ], - "emits": [] + "emits": [ + "TELEMETRY_TRACK", + "TELEMETRY_TRACK", + "TELEMETRY_TRACK", + "TELEMETRY_TRACK" + ] } }, "renderer/gitChangesPanel": { @@ -7643,30 +7658,30 @@ "purpose": "End the tour and remember it. `outcome` is 'finished' or 'skipped'. A write" }, "hasProject": { - "line": 152 + "line": 155 }, "start": { - "line": 160, + "line": 163, "purpose": "Start (or restart) the tour from the first step that applies: step 1 with" }, "close": { - "line": 179, + "line": 182, "purpose": "Leave the tour: remove the layer and every listener it bound." }, "next": { - "line": 189 + "line": 192 }, "ensureNodes": { - "line": 200 + "line": 203 }, "isVisible": { - "line": 228, + "line": 231, "params": [ "el" ] }, "resolveTarget": { - "line": 242, + "line": 245, "params": [ "step", "{ reveal = false } = {}" @@ -7674,47 +7689,47 @@ "purpose": "The step's first usable target, with its entry." }, "isAvailable": { - "line": 255, + "line": 258, "params": [ "step" ] }, "revealNav": { - "line": 259, + "line": 262, "params": [ "view" ] }, "show": { - "line": 272, + "line": 275, "params": [ "i" ] }, "skipMissing": { - "line": 293, + "line": 296, "params": [ "i" ], "purpose": "A step whose target is gone: log it once, move on, never end the tour on it." }, "countedSteps": { - "line": 312, + "line": 315, "purpose": "The steps this run of the tour counts: step 1 only if it began without a project." }, "renderCard": { - "line": 316, + "line": 319, "params": [ "step", "i" ] }, "focusCard": { - "line": 363, + "line": 366, "purpose": "Put focus on the card's primary control so Enter, → and Escape reach it." }, "el": { - "line": 369, + "line": 372, "params": [ "tag", "className", @@ -7722,35 +7737,41 @@ ] }, "onCardClick": { - "line": 376, + "line": 379, "params": [ "e" ] }, "onCardKeydown": { - "line": 395, + "line": 398, "params": [ "e" ], "purpose": "Bound on the card, not the document: the keys answer only while focus is in" }, "position": { - "line": 412, + "line": 415, "purpose": "Measure the current target and move the cutout and the card to it." }, "queueReposition": { - "line": 449, + "line": 452, "purpose": "Coalesce bursts (a sidebar drag, a scroll) into one measurement per frame." }, "observeTarget": { - "line": 458, + "line": 461, "params": [ "target" ] }, "bindWhileOpen": { - "line": 465 + "line": 468 } + }, + "ipc": { + "listens": [], + "emits": [ + "TELEMETRY_TRACK" + ] } }, "renderer/healthNotice": { @@ -9972,12 +9993,18 @@ "purpose": "Resume a session in a terminal of its own." }, "formatRelativeTime": { - "line": 209, + "line": 213, "params": [ "dateString" ], "purpose": "Format a date string to relative time" } + }, + "ipc": { + "listens": [], + "emits": [ + "TELEMETRY_TRACK" + ] } }, "renderer/settingsOverlay": { diff --git a/PRIVACY.md b/PRIVACY.md index 1267e848..d974446a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -21,6 +21,12 @@ The complete event list: | `orchestration_run_started` | — | a parallel orchestration run began | | `plugin_toggled` | `action`: `enabled` / `disabled` | the plugin system is being used (never which plugin) | | `ai_tool_selected` | `tool`: `claude` / `codex` / `gemini` / `custom` | the active AI tool was switched, and to which | +| `panel_opened` | `panel`: `decisions` / `structure` / `prompts` / `activity` | which dock panels get used, and which are dead weight | +| `implement_mode_selected` | `mode`: `step-by-step` / `guided` / `autonomous` / `custom` | how people prefer to run a spec's implementation | +| `tour_finished` | `outcome`: `finished` / `skipped` | whether the first-run tour lands or gets dismissed | +| `session_resumed` | — | the resume-a-session path was used (never which session) | +| `task_completed` | — | a task was marked done (never its title, id, or project) | +| `settings_changed` | `setting`: `telemetry` / `error_reporting` / `crash_dumps` / `ui_zoom` | which setting was touched — never the value it was set to | | `feedback_submitted` | `channel`: `github_issue` / `github_discussion` / `email` | which route carried an in-app feedback report — never its title, description, or the diagnostics shown in the form | | `error_occurred` | `category`: a fixed list of failure types (e.g. `agent_cli_not_found`, `orch_merge_failed`) | what breaks in the wild — counts only, never error messages, stack traces, or file paths | diff --git a/src/main/tasksManager.js b/src/main/tasksManager.js index 7c04bc61..db4dea39 100644 --- a/src/main/tasksManager.js +++ b/src/main/tasksManager.js @@ -236,6 +236,13 @@ function updateTask(projectPath, taskId, updates) { task.completedAt = new Date().toISOString(); tasksData.metadata = tasksData.metadata || {}; tasksData.metadata.totalCompleted = (tasksData.metadata.totalCompleted || 0) + 1; + // Lazy require, same reason as frameProject: telemetry pulls + // posthog-node and electron, and CI runs the suite with no + // node_modules. Keep this module's load graph Electron-free. + // Nothing about the task is sent — not its title, not its id. + try { + require('./telemetry').track('task_completed'); + } catch (_) { /* telemetry is never allowed to break a task write */ } } } diff --git a/src/main/telemetryEvents.js b/src/main/telemetryEvents.js index 2e551ce9..2ead8d11 100644 --- a/src/main/telemetryEvents.js +++ b/src/main/telemetryEvents.js @@ -38,6 +38,24 @@ const EVENTS = { // no title, no description, no diagnostic value, and validateEvent strips // anything outside the enum even if a call site tried. feedback_submitted: { channel: ['github_issue', 'github_discussion', 'email'] }, + // ── Behaviour: which surfaces and flows people actually use ── + // + // Each of these sits on a chokepoint rather than on a button, so one + // event with an enum covers what would otherwise be a handful of + // near-identical events. `panel_opened` is mounted once in dock.js and + // covers every dock panel; adding a panel adds a value here, not an + // event. The same reasoning that keeps plugin ids out of + // `plugin_toggled` keeps command names out of this section entirely. + panel_opened: { panel: ['decisions', 'structure', 'prompts', 'activity'] }, + implement_mode_selected: { mode: ['step-by-step', 'guided', 'autonomous', 'custom'] }, + tour_finished: { outcome: ['finished', 'skipped'] }, + session_resumed: {}, + task_completed: {}, + // Which setting was touched, never its value — the value of the one that + // matters most is already carried by the opt-out itself. Switching + // telemetry *off* deliberately never arrives: track() is gated on the new + // state, which is the correct behaviour for an opt-out. + settings_changed: { setting: ['telemetry', 'error_reporting', 'crash_dumps', 'ui_zoom'] }, error_occurred: { category: [ 'agent_cli_not_found', diff --git a/src/renderer/agentDispatch.js b/src/renderer/agentDispatch.js index d40b6ba9..74d4903e 100644 --- a/src/renderer/agentDispatch.js +++ b/src/renderer/agentDispatch.js @@ -528,6 +528,11 @@ async function _dispatchImplement({ slug, title, projectPath, assignment }) { return { success: false, terminalId: null, error: null }; } + // Which mode people actually pick is a product question the spec flow + // cannot answer from the filesystem: autonomous runs are launched + // elsewhere, so status.json alone under-counts them. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'implement_mode_selected', { mode: choice.mode }); + // Record the chosen mode *before* staging so the flags follow the choice. try { const res = await ipcRenderer.invoke(IPC.UPDATE_SPEC_STATUS, { diff --git a/src/renderer/dock.js b/src/renderer/dock.js index 1cb5f63e..44ea404d 100644 --- a/src/renderer/dock.js +++ b/src/renderer/dock.js @@ -31,6 +31,8 @@ * main. */ +const { ipcRenderer } = require('electron'); +const { IPC } = require('../shared/ipcChannels'); const dockState = require('./dock/dockState'); const { makeSortable } = require('./dnd/sortable'); const tooltip = require('./tooltip'); @@ -348,6 +350,10 @@ function mountTab(tab) { const slot = slots.get(tab); if (!entry || !slot) return; mountedTab = tab; + // One event for every dock panel. mountTab is the only way a panel is + // shown, and the early return above means a re-render of the panel you + // are already on does not count as opening it. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'panel_opened', { panel: tab }); try { entry.mount(slot); } catch (err) { diff --git a/src/renderer/frameSettingsModal.js b/src/renderer/frameSettingsModal.js index fbaefd05..642d36e3 100644 --- a/src/renderer/frameSettingsModal.js +++ b/src/renderer/frameSettingsModal.js @@ -77,6 +77,9 @@ function init() { const enabled = toggleEl.checked; await ipcRenderer.invoke(IPC.SET_USER_SETTING, TELEMETRY_KEY, enabled); await ipcRenderer.invoke(IPC.TELEMETRY_SET_ENABLED, enabled); + // Only arrives when switching ON: track() is gated on the new state, so + // an opt-out sends nothing, which is the point of an opt-out. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'settings_changed', { setting: 'telemetry' }); // Error reporting rides on analytics being on, so the row follows it // rather than sitting enabled over a switch that silences it. syncErrorReportingAvailability(enabled); @@ -87,6 +90,7 @@ function init() { if (errorReportingToggleEl) { errorReportingToggleEl.addEventListener('change', async () => { await ipcRenderer.invoke(IPC.SET_USER_SETTING, ERROR_REPORTING_KEY, errorReportingToggleEl.checked); + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'settings_changed', { setting: 'error_reporting' }); }); } @@ -95,6 +99,7 @@ function init() { if (crashDumpsToggleEl) { crashDumpsToggleEl.addEventListener('change', async () => { await ipcRenderer.invoke(IPC.SET_USER_SETTING, CRASH_DUMPS_KEY, crashDumpsToggleEl.checked); + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'settings_changed', { setting: 'crash_dumps' }); }); } @@ -314,6 +319,10 @@ function initZoomSelect() { const step = uiZoom.clampStep(Number(zoomSelectEl.value)); try { await ipcRenderer.invoke(IPC.UI_ZOOM_SET, step); + // From this control only. Zoom also changes by shortcut, menu, status + // bar and trackpad; counting those too would say "people zoom", not + // "people go to Settings to zoom", which is the question here. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'settings_changed', { setting: 'ui_zoom' }); } catch (err) { console.error('Frame settings: could not set the interface size', err); } diff --git a/src/renderer/guidedTour.js b/src/renderer/guidedTour.js index 0cc2ba65..6af9be5a 100644 --- a/src/renderer/guidedTour.js +++ b/src/renderer/guidedTour.js @@ -137,6 +137,9 @@ function noticesGone() { async function finish(outcome) { if (!isOpen) return; close(); + // Finished vs skipped is the first honest activation signal Frame has: + // both mean the tour was reached, only one means it landed. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'tour_finished', { outcome }); try { const ok = await ipcRenderer.invoke(IPC.SET_USER_SETTING, tourSteps.SETTING_KEY, { outcome, diff --git a/src/renderer/sessionsPanel.js b/src/renderer/sessionsPanel.js index 0044e1b3..6472ee79 100644 --- a/src/renderer/sessionsPanel.js +++ b/src/renderer/sessionsPanel.js @@ -200,6 +200,10 @@ function renderSessionsEmpty(message) { */ function resumeSession(sessionId) { hide(); + // No session id, no transcript, nothing about the work — only that the + // resume path was used at all, which is what says the feature earns its + // place. + ipcRenderer.send(IPC.TELEMETRY_TRACK, 'session_resumed'); require('./agentDispatch').resumeClaudeSession(sessionId); } From 37b9cbad79bbd87d4a14bbe3ab552931fe6c616b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20Murato=C4=9Flu?= Date: Mon, 21 Sep 2026 17:28:38 +0300 Subject: [PATCH 17/23] refactor: telemetry is called analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Telemetry" is the word vendors use; "analytics" is the word everyone else does. Modules, IPC channels, DOM ids, stylesheet, tests and prose all move: analytics.js, analyticsEvents.js, analyticsNotice.js, ANALYTICS_TRACK, #analytics-notice. Settings on disk do not rename themselves, so three keys are copied forward once at startup: an opt-out read as "never set" would default back ON — the exact re-opt-in bug effectiveEnabled exists to prevent — a lost install id would split one user into two, and a lost notice version would interrupt someone who had already read the card. The old keys are kept, never deleted, so an older build still reads its own settings, and the migration is skipped outright when the settings file is unreadable, because a write there would clear the fail-closed flag. find-module keeps "telemetry" as a synonym, so the old word still lands — every closed spec uses it. Co-Authored-By: Claude Opus 5 (1M context) --- .frame/STRUCTURE.json | 556 ++++++++++-------- PRIVACY.md | 16 +- index.html | 24 +- scripts/intent-map.json | 269 +++++++-- src/main/activityLog.js | 2 +- src/main/aiToolManager.js | 6 +- src/main/{telemetry.js => analytics.js} | 90 +-- ...{telemetryEvents.js => analyticsEvents.js} | 68 ++- src/main/crashGuard.js | 10 +- src/main/frameProject.js | 6 +- src/main/index.js | 47 +- src/main/orchestrationManager.js | 10 +- src/main/pluginsManager.js | 6 +- src/main/ptyManager.js | 4 +- src/main/specManager.js | 12 +- src/main/tasksManager.js | 6 +- src/main/userSettings.js | 6 +- src/renderer/agentDispatch.js | 6 +- ...{telemetryNotice.js => analyticsNotice.js} | 24 +- src/renderer/dock.js | 2 +- src/renderer/feedbackPanel.js | 4 +- src/renderer/frameSettingsModal.js | 22 +- src/renderer/guidedTour.js | 4 +- src/renderer/index.js | 6 +- src/renderer/orchestrator.js | 2 +- src/renderer/sessionsPanel.js | 2 +- ...emetry-notice.css => analytics-notice.css} | 30 +- src/renderer/styles/main.css | 4 +- src/shared/activityEvents.js | 4 +- src/shared/feedbackReport.js | 2 +- src/shared/ipcChannels.js | 10 +- test/{telemetry.test.js => analytics.test.js} | 57 +- test/codexHookInstall.test.js | 2 +- test/frameProjectInit.test.js | 2 +- test/frameProjectOpen.test.js | 2 +- test/spec-command-hint.test.js | 4 +- test/specNewStaging.test.js | 4 +- test/specStatusRepair.test.js | 4 +- test/specTasksSync.test.js | 4 +- 39 files changed, 830 insertions(+), 509 deletions(-) rename src/main/{telemetry.js => analytics.js} (76%) rename src/main/{telemetryEvents.js => analyticsEvents.js} (86%) rename src/renderer/{telemetryNotice.js => analyticsNotice.js} (71%) rename src/renderer/styles/components/{telemetry-notice.css => analytics-notice.css} (81%) rename test/{telemetry.test.js => analytics.test.js} (87%) diff --git a/.frame/STRUCTURE.json b/.frame/STRUCTURE.json index 1cbb8619..f92cb282 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -165,7 +165,7 @@ "child_process", "fsSafe", "logger", - "telemetry", + "analytics", "envPath", "shared/ipcChannels" ], @@ -285,6 +285,212 @@ ] } }, + "main/analytics": { + "file": "src/main/analytics.js", + "description": "Analytics", + "exports": [ + "init", + "track", + "trackAppStarted", + "captureException", + "noticeState", + "setEnabled", + "isEnabled", + "isErrorReportingEnabled", + "migrateLegacySettings", + "enforceFailClosed", + "shutdown" + ], + "depends": [ + "posthog-node", + "electron", + "userSettings", + "analyticsEvents" + ], + "functions": { + "init": { + "line": 74, + "purpose": "Build the PostHog client." + }, + "distinctId": { + "line": 98, + "purpose": "This install's distinct id, resolved on first use and cached for the run." + }, + "personProperties": { + "line": 117, + "purpose": "Person properties, sent once per run alongside the first event." + }, + "track": { + "line": 130, + "params": [ + "name", + "props" + ], + "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." + }, + "isErrorReportingEnabled": { + "line": 158, + "purpose": "Whether exception detail may be sent." + }, + "captureException": { + "line": 175, + "params": [ + "err" + ], + "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." + }, + "noticeState": { + "line": 202, + "purpose": "Whether the disclosure notice is due, and the version to store when it is" + }, + "trackAppStarted": { + "line": 215, + "purpose": "Anonymous event marking this launch." + }, + "setEnabled": { + "line": 229, + "params": [ + "enabled" + ], + "purpose": "Toggle analytics from Settings. Persists the new state, then re-resolves" + }, + "migrateLegacySettings": { + "line": 248, + "purpose": "Copy settings forward from the names this feature used when it was called" + }, + "enforceFailClosed": { + "line": 272, + "purpose": "Make a failed settings load's fail-closed state stick. Called right after" + }, + "isEnabled": { + "line": 283, + "purpose": "Effective enabled state. Default ON when the setting has never been" + }, + "shutdown": { + "line": 299, + "purpose": "Flush the batcher and close the client, for the quit path." + } + } + }, + "main/analyticsEvents": { + "file": "src/main/analyticsEvents.js", + "description": "Analytics events — pure policy module", + "exports": [ + "EVENTS", + "normalizeTool", + "validateEvent", + "effectiveEnabled", + "resolveInstallId", + "LEGACY_SETTING_KEYS", + "planLegacyMigration", + "sanitizeException", + "NOTICE_VERSION", + "shouldShowNotice", + "createRateLimiter", + "DEFAULT_RATE_LIMIT", + "diffSpecLifecycle", + "renameSpecLifecycle" + ], + "depends": [ + "node:crypto", + "../scripts/redact" + ], + "functions": { + "normalizeTool": { + "line": 84, + "params": [ + "id" + ], + "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" + }, + "validateEvent": { + "line": 99, + "params": [ + "name", + "props" + ], + "purpose": "Validate an (event, props) pair against the registry." + }, + "effectiveEnabled": { + "line": 123, + "params": [ + "{ value", + "loadFailed }" + ], + "purpose": "Effective analytics state from the persisted setting plus settings-load" + }, + "shouldShowNotice": { + "line": 153, + "params": [ + "{ storedVersion", + "legacyShown", + "currentVersion }" + ], + "purpose": "Whether the disclosure notice should be shown." + }, + "stripAbsolutePaths": { + "line": 181, + "params": [ + "text" + ] + }, + "sanitizeException": { + "line": 197, + "params": [ + "err" + ], + "purpose": "Make an exception safe to send." + }, + "planLegacyMigration": { + "line": 236, + "params": [ + "snapshot" + ], + "purpose": "What the rename owes this install." + }, + "resolveInstallId": { + "line": 278, + "params": [ + "{ stored", + "enabled }", + "mint" + ], + "purpose": "Decide this launch's install id from what is stored and whether analytics" + }, + "createRateLimiter": { + "line": 309, + "params": [ + "options" + ], + "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" + }, + "stampedSince": { + "line": 369, + "params": [ + "stamp", + "since" + ] + }, + "diffSpecLifecycle": { + "line": 388, + "params": [ + "previous", + "specs", + "now" + ], + "purpose": "Compare one push of a project's specs with this run's earlier looks at it." + }, + "renameSpecLifecycle": { + "line": 427, + "params": [ + "state", + "oldSlug", + "newSlug" + ], + "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read" + } + } + }, "main/claudeSessionsManager": { "file": "src/main/claudeSessionsManager.js", "description": "Claude Sessions Manager Module", @@ -534,7 +740,7 @@ "depends": [ "electron", "logger", - "telemetry", + "analytics", "shared/ipcChannels", "userSettings" ], @@ -939,7 +1145,7 @@ "../scripts/activity-log", "specManager", "fileTree", - "telemetry" + "analytics" ], "functions": { "init": { @@ -2295,7 +2501,7 @@ "uiZoom", "gitStatusManager", "gitDiffManager", - "telemetry", + "analytics", "specManager", "orchestrationManager", "child_process" @@ -2321,7 +2527,7 @@ "purpose": "Initialize application" }, "initModulesWithWindow": { - "line": 339, + "line": 340, "params": [ "window" ], @@ -2332,10 +2538,10 @@ "listens": [ "GET_USER_SETTING", "SET_USER_SETTING", - "TELEMETRY_SET_ENABLED", - "TELEMETRY_TRACK", - "TELEMETRY_EXCEPTION", - "TELEMETRY_NOTICE_STATE", + "ANALYTICS_SET_ENABLED", + "ANALYTICS_TRACK", + "ANALYTICS_EXCEPTION", + "ANALYTICS_NOTICE_STATE", "GET_LOG_INFO", "TERMINAL_INPUT", "RECONCILE_TERMINALS" @@ -2720,7 +2926,7 @@ "ptyManager", "gitBranchesManager", "specManager", - "telemetry", + "analytics", "pollGate" ], "functions": { @@ -3054,7 +3260,7 @@ "os", "child_process", "shared/ipcChannels", - "telemetry" + "analytics" ], "functions": { "execGit": { @@ -3477,7 +3683,7 @@ "shared/ipcChannels", "logger", "promptLogger", - "telemetry", + "analytics", "pollGate", "child_process", "fs" @@ -3662,8 +3868,8 @@ "tasksManager", "commandStaging", "frameProject", - "telemetry", - "telemetryEvents", + "analytics", + "analyticsEvents", "activityLog", "perfMonitor", "../scripts/toolVocabulary", @@ -4180,7 +4386,7 @@ "activityLog", "shared/ipcChannels", "shared/frameConstants", - "telemetry" + "analytics" ], "functions": { "cacheKeyFor": { @@ -4331,198 +4537,6 @@ ] } }, - "main/telemetry": { - "file": "src/main/telemetry.js", - "description": "Telemetry", - "exports": [ - "init", - "track", - "trackAppStarted", - "captureException", - "noticeState", - "setEnabled", - "isEnabled", - "isErrorReportingEnabled", - "enforceFailClosed", - "shutdown" - ], - "depends": [ - "posthog-node", - "electron", - "userSettings", - "telemetryEvents" - ], - "functions": { - "init": { - "line": 73, - "purpose": "Build the PostHog client." - }, - "distinctId": { - "line": 97, - "purpose": "This install's distinct id, resolved on first use and cached for the run." - }, - "personProperties": { - "line": 116, - "purpose": "Person properties, sent once per run alongside the first event." - }, - "track": { - "line": 129, - "params": [ - "name", - "props" - ], - "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." - }, - "isErrorReportingEnabled": { - "line": 157, - "purpose": "Whether exception detail may be sent." - }, - "captureException": { - "line": 174, - "params": [ - "err" - ], - "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." - }, - "noticeState": { - "line": 201, - "purpose": "Whether the disclosure notice is due, and the version to store when it is" - }, - "trackAppStarted": { - "line": 214, - "purpose": "Anonymous event marking this launch." - }, - "setEnabled": { - "line": 228, - "params": [ - "enabled" - ], - "purpose": "Toggle telemetry from Settings. Persists the new state, then re-resolves" - }, - "enforceFailClosed": { - "line": 249, - "purpose": "Make a failed settings load's fail-closed state stick. Called right after" - }, - "isEnabled": { - "line": 260, - "purpose": "Effective enabled state. Default ON when the setting has never been" - }, - "shutdown": { - "line": 276, - "purpose": "Flush the batcher and close the client, for the quit path." - } - } - }, - "main/telemetryEvents": { - "file": "src/main/telemetryEvents.js", - "description": "Telemetry events — pure policy module", - "exports": [ - "EVENTS", - "normalizeTool", - "validateEvent", - "effectiveEnabled", - "resolveInstallId", - "sanitizeException", - "NOTICE_VERSION", - "shouldShowNotice", - "createRateLimiter", - "DEFAULT_RATE_LIMIT", - "diffSpecLifecycle", - "renameSpecLifecycle" - ], - "depends": [ - "node:crypto", - "../scripts/redact" - ], - "functions": { - "normalizeTool": { - "line": 84, - "params": [ - "id" - ], - "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" - }, - "validateEvent": { - "line": 99, - "params": [ - "name", - "props" - ], - "purpose": "Validate an (event, props) pair against the registry." - }, - "effectiveEnabled": { - "line": 123, - "params": [ - "{ value", - "loadFailed }" - ], - "purpose": "Effective telemetry state from the persisted setting plus settings-load" - }, - "shouldShowNotice": { - "line": 153, - "params": [ - "{ storedVersion", - "legacyShown", - "currentVersion }" - ], - "purpose": "Whether the disclosure notice should be shown." - }, - "stripAbsolutePaths": { - "line": 181, - "params": [ - "text" - ] - }, - "sanitizeException": { - "line": 197, - "params": [ - "err" - ], - "purpose": "Make an exception safe to send." - }, - "resolveInstallId": { - "line": 238, - "params": [ - "{ stored", - "enabled }", - "mint" - ], - "purpose": "Decide this launch's install id from what is stored and whether telemetry" - }, - "createRateLimiter": { - "line": 269, - "params": [ - "options" - ], - "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" - }, - "stampedSince": { - "line": 329, - "params": [ - "stamp", - "since" - ] - }, - "diffSpecLifecycle": { - "line": 348, - "params": [ - "previous", - "specs", - "now" - ], - "purpose": "Compare one push of a project's specs with this run's earlier looks at it." - }, - "renameSpecLifecycle": { - "line": 387, - "params": [ - "state", - "oldSlug", - "newSlug" - ], - "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read" - } - } - }, "main/uiZoom": { "file": "src/main/uiZoom.js", "description": "UI zoom — the main-process owner of the interface scale (ui-zoom-steps spec).", @@ -4647,7 +4661,7 @@ "path", "electron", "fsSafe", - "telemetry" + "analytics" ], "functions": { "init": { @@ -5113,9 +5127,9 @@ "TERMINAL_DESTROYED" ], "emits": [ - "TELEMETRY_TRACK", - "TELEMETRY_TRACK", - "TELEMETRY_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", "SPEC_AGENT_ACTIVITY" ] } @@ -5190,6 +5204,28 @@ "emits": [] } }, + "renderer/analyticsNotice": { + "file": "src/renderer/analyticsNotice.js", + "description": "Analytics Notice Banner", + "exports": [ + "init" + ], + "depends": [ + "electron", + "shared/ipcChannels" + ], + "functions": { + "init": { + "line": 31, + "params": [ + "openSettings" + ] + }, + "dismiss": { + "line": 60 + } + } + }, "renderer/appHeader": { "file": "src/renderer/appHeader.js", "description": "App Header — the one row across the top of the window", @@ -5902,7 +5938,7 @@ "ipc": { "listens": [], "emits": [ - "TELEMETRY_TRACK" + "ANALYTICS_TRACK" ] } }, @@ -6401,7 +6437,7 @@ "ipc": { "listens": [], "emits": [ - "TELEMETRY_TRACK" + "ANALYTICS_TRACK" ] } }, @@ -6688,7 +6724,7 @@ "params": [ "analyticsOn" ], - "purpose": "Error reporting is gated on analytics in main (telemetry.captureException" + "purpose": "Error reporting is gated on analytics in main (analytics.captureException" }, "syncToggleFromSettings": { "line": 355 @@ -6701,10 +6737,10 @@ "UPDATE_AVAILABLE" ], "emits": [ - "TELEMETRY_TRACK", - "TELEMETRY_TRACK", - "TELEMETRY_TRACK", - "TELEMETRY_TRACK" + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK" ] } }, @@ -7770,7 +7806,7 @@ "ipc": { "listens": [], "emits": [ - "TELEMETRY_TRACK" + "ANALYTICS_TRACK" ] } }, @@ -8300,7 +8336,7 @@ "doneWindow", "frameSettingsModal", "feedbackPanel", - "telemetryNotice", + "analyticsNotice", "healthNotice", "specDrivenHint", "docsHealthHint", @@ -8364,7 +8400,7 @@ ], "emits": [ "REFRESH_GIT_STATUS", - "TELEMETRY_EXCEPTION" + "ANALYTICS_EXCEPTION" ] } }, @@ -9001,7 +9037,7 @@ "ORCH_STATE" ], "emits": [ - "TELEMETRY_TRACK", + "ANALYTICS_TRACK", "ORCH_WORKER_LANE" ] } @@ -10003,7 +10039,7 @@ "ipc": { "listens": [], "emits": [ - "TELEMETRY_TRACK" + "ANALYTICS_TRACK" ] } }, @@ -12242,28 +12278,6 @@ ] } }, - "renderer/telemetryNotice": { - "file": "src/renderer/telemetryNotice.js", - "description": "Telemetry Notice Banner", - "exports": [ - "init" - ], - "depends": [ - "electron", - "shared/ipcChannels" - ], - "functions": { - "init": { - "line": 31, - "params": [ - "openSettings" - ] - }, - "dismiss": { - "line": 60 - } - } - }, "renderer/terminal": { "file": "src/renderer/terminal.js", "description": "Terminal UI Module", @@ -14676,6 +14690,28 @@ "direction": "", "description": "" } + }, + "analytics": { + "ANALYTICS_SET_ENABLED": { + "name": "analytics-set-enabled", + "direction": "", + "description": "" + }, + "ANALYTICS_TRACK": { + "name": "analytics-track", + "direction": "", + "description": "" + }, + "ANALYTICS_EXCEPTION": { + "name": "analytics-exception", + "direction": "", + "description": "" + }, + "ANALYTICS_NOTICE_STATE": { + "name": "analytics-notice-state", + "direction": "", + "description": "" + } } }, "dataFlow": [], @@ -14723,6 +14759,18 @@ "description": "AI Tool Selector Module" } ], + "analytics": [ + { + "module": "main/analytics", + "file": "src/main/analytics.js", + "description": "Analytics" + }, + { + "module": "renderer/analyticsNotice", + "file": "src/renderer/analyticsNotice.js", + "description": "Analytics Notice Banner" + } + ], "branch": [ { "module": "main/gitBranchRefs", @@ -14848,9 +14896,9 @@ ], "events": [ { - "module": "main/telemetryEvents", - "file": "src/main/telemetryEvents.js", - "description": "Telemetry events — pure policy module" + "module": "main/analyticsEvents", + "file": "src/main/analyticsEvents.js", + "description": "Analytics events — pure policy module" }, { "module": "shared/activityEvents", @@ -15457,18 +15505,6 @@ "description": "Tasks Panel Module" } ], - "telemetry": [ - { - "module": "main/telemetry", - "file": "src/main/telemetry.js", - "description": "Telemetry" - }, - { - "module": "renderer/telemetryNotice", - "file": "src/renderer/telemetryNotice.js", - "description": "Telemetry Notice Banner" - } - ], "terminal": [ { "module": "main/pty", diff --git a/PRIVACY.md b/PRIVACY.md index d974446a..1abdf50e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,7 @@ Frame is a developer tool. We respect your privacy and try to be transparent abo ## What we collect -When telemetry is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. +When analytics is enabled (the default — opt-out via Settings), Frame sends a small set of anonymous usage events. Every event carries only its name plus, where noted, a property whose value comes from a **fixed list** — never free text, never anything typed or generated by you. Alongside each event go four things and nothing else: a **random install ID** (see below), your **app version**, your **operating system** (`darwin`, `win32`, `linux`), and your **approximate location** (see below). @@ -26,7 +26,7 @@ The complete event list: | `tour_finished` | `outcome`: `finished` / `skipped` | whether the first-run tour lands or gets dismissed | | `session_resumed` | — | the resume-a-session path was used (never which session) | | `task_completed` | — | a task was marked done (never its title, id, or project) | -| `settings_changed` | `setting`: `telemetry` / `error_reporting` / `crash_dumps` / `ui_zoom` | which setting was touched — never the value it was set to | +| `settings_changed` | `setting`: `analytics` / `error_reporting` / `crash_dumps` / `ui_zoom` | which setting was touched — never the value it was set to | | `feedback_submitted` | `channel`: `github_issue` / `github_discussion` / `email` | which route carried an in-app feedback report — never its title, description, or the diagnostics shown in the form | | `error_occurred` | `category`: a fixed list of failure types (e.g. `agent_cli_not_found`, `orch_merge_failed`) | what breaks in the wild — counts only, never error messages, stack traces, or file paths | @@ -41,7 +41,7 @@ Each install generates a **random ID** the first time it sends anything — a UU - It is **not** derived from your hardware, your machine name, your username, your email, or anything else about you. Two installs on the same computer get different IDs. - It lets us tell one install from another, which is the only way to count actual users rather than launches, see whether people come back, and find where they get stuck. - It is stored in Frame's own settings file on your machine and sent with each event. Nothing we hold can connect it to a person. -- **Turning telemetry off deletes it.** Turning telemetry back on generates a new one — the old one is not recoverable, by us or by you. +- **Turning analytics off deletes it.** Turning analytics back on generates a new one — the old one is not recoverable, by us or by you. ## Approximate location @@ -50,7 +50,7 @@ Each event is tagged with the **country and region** it came from, worked out fr - We use it to see which parts of the world Frame is used in, so documentation, defaults and support hours can follow real usage rather than guesswork. - It is **country and region level** — the same granularity a weather site infers. No city-block accuracy, no GPS, no street address. - **The IP address itself is not stored.** PostHog resolves it to a location on arrival and discards it; it is never written to the event, and we never see it. -- Turning telemetry off stops this like everything else — no event, no location. +- Turning analytics off stops this like everything else — no event, no location. ## What we do **not** collect @@ -68,7 +68,7 @@ Each event is tagged with the **country and region** it came from, worked out fr Open **Settings** (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux) and toggle **"Send anonymous usage stats"** off. The setting takes effect immediately and persists across launches, and switching it off deletes the install ID at the same moment. -Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, telemetry **fails closed** — Frame records it as switched off, nothing is sent, and no install ID is kept. Corruption can never silently re-enable telemetry. +Your opt-out is durable: if the settings file on disk is ever corrupted beyond repair, analytics **fails closed** — Frame records it as switched off, nothing is sent, and no install ID is kept. Corruption can never silently re-enable analytics. You can also dismiss the one-time notice card — that doesn't change the setting itself, only hides the card. If what we collect changes materially, the card reappears once with the new text, even if you dismissed the previous one. @@ -85,11 +85,11 @@ This channel never touches the event list above, and nothing on it is sent while ## Where the data goes -Telemetry is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). PostHog derives country and region from the request IP and discards the IP itself — see **Approximate location** above. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust. +Analytics is sent to [PostHog](https://posthog.com), an open-source product analytics service, on their **EU cloud** (`eu.i.posthog.com`). PostHog derives country and region from the request IP and discards the IP itself — see **Approximate location** above. PostHog is [self-hostable](https://posthog.com/docs/self-host) and their platform is open source, so what happens to the data is auditable rather than taken on trust. ## Local logging -Frame keeps a **local** log file to make bugs debuggable — it never leaves your machine and is not part of telemetry. +Frame keeps a **local** log file to make bugs debuggable — it never leaves your machine and is not part of analytics. - **Location:** Electron's standard logs directory — on macOS `~/Library/Logs/Frame/main.log` (find it via **Settings → About → Open Logs Folder**). - **Rotation:** capped at 5 MB with up to 3 archived files (`main.old.1.log` … `main.old.3.log`); old content is discarded, never uploaded. @@ -99,7 +99,7 @@ When filing a bug report, attaching the log is the fastest way to help us help y ## Activity record (local only) -Frame does a lot of work on its own — watching files, reconciling spec phases, rebuilding its index, recovering a corrupt state file, running hooks during a commit. The **Activity** panel shows that work, and it reads a record kept **on your machine only**. Nothing in it is transmitted, to us or anyone else; it shares no code path and no storage with telemetry, and turning telemetry off or on changes nothing about it. +Frame does a lot of work on its own — watching files, reconciling spec phases, rebuilding its index, recovering a corrupt state file, running hooks during a commit. The **Activity** panel shows that work, and it reads a record kept **on your machine only**. Nothing in it is transmitted, to us or anyone else; it shares no code path and no storage with analytics, and turning analytics off or on changes nothing about it. - **Location:** `~/.frame/activity//activity.jsonl`, outside your repository — Frame never edits your project's `.gitignore`, so keeping it in the repo would dirty your `git status`. Work that belongs to no project goes to an `app` bucket. - **Retention:** the live file rotates at 2 MB into a single archived generation, and any project's record untouched for 7 days is deleted. diff --git a/index.html b/index.html index 3f158a68..448ca6c8 100644 --- a/index.html +++ b/index.html @@ -18,27 +18,27 @@ - -
-
-