diff --git a/.frame/PROJECT_NOTES.md b/.frame/PROJECT_NOTES.md index e805b62d..03619124 100644 --- a/.frame/PROJECT_NOTES.md +++ b/.frame/PROJECT_NOTES.md @@ -425,6 +425,84 @@ 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 +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/STRUCTURE.json b/.frame/STRUCTURE.json index 3817c028..fda0d075 100644 --- a/.frame/STRUCTURE.json +++ b/.frame/STRUCTURE.json @@ -94,7 +94,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 6928, + "sizeBytes": 11723, "extraction": { "status": "parsed" }, @@ -290,7 +290,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 93321, + "sizeBytes": 94287, "extraction": { "status": "unsupported", "reason": "no-extractor" @@ -551,7 +551,7 @@ "child_process", "fsSafe", "logger", - "telemetry", + "analytics", "envPath", "shared/ipcChannels" ], @@ -695,6 +695,272 @@ } } }, + "main/analytics": { + "file": "src/main/analytics.js", + "description": "Analytics", + "exports": [ + "init", + "track", + "trackAppStarted", + "startActiveTimer", + "captureException", + "noticeState", + "setEnabled", + "isEnabled", + "isErrorReportingEnabled", + "migrateLegacySettings", + "enforceFailClosed", + "shutdown" + ], + "depends": [ + "posthog-node", + "@aptabase/electron/main", + "node:crypto", + "electron", + "userSettings", + "analyticsEvents", + "pollGate" + ], + "functions": { + "init": { + "line": 88, + "purpose": "Initialize Aptabase and build the PostHog client." + }, + "initAptabase": { + "line": 112 + }, + "distinctId": { + "line": 129, + "purpose": "This install's distinct id, resolved on first use and cached for the run." + }, + "personProperties": { + "line": 148, + "purpose": "Person properties, sent once per run alongside the first event." + }, + "track": { + "line": 161, + "params": [ + "name", + "props" + ], + "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." + }, + "isErrorReportingEnabled": { + "line": 197, + "purpose": "Whether exception detail may be sent." + }, + "captureException": { + "line": 214, + "params": [ + "err" + ], + "purpose": "Send one exception, sanitized. No-op unless error reporting is explicitly on." + }, + "noticeState": { + "line": 241, + "purpose": "Whether the disclosure notice is due, and the version to store when it is" + }, + "startActiveTimer": { + "line": 262, + "purpose": "Start counting active time." + }, + "trackAppStarted": { + "line": 275, + "purpose": "Anonymous event marking this launch." + }, + "setEnabled": { + "line": 289, + "params": [ + "enabled" + ], + "purpose": "Toggle analytics from Settings. Persists the new state, then re-resolves" + }, + "migrateLegacySettings": { + "line": 308, + "purpose": "Copy settings forward from the names this feature used when it was called" + }, + "enforceFailClosed": { + "line": 332, + "purpose": "Make a failed settings load's fail-closed state stick. Called right after" + }, + "isEnabled": { + "line": 343, + "purpose": "Effective enabled state. Default ON when the setting has never been" + }, + "shutdown": { + "line": 359, + "purpose": "Flush the batcher and close the client, for the quit path." + } + }, + "sizeBytes": 14849, + "extraction": { + "status": "parsed" + }, + "provenance": { + "description": "94c116ee118a7299", + "purposes": { + "captureException": "9cb169eeec848b86", + "distinctId": "9c915fdaff2fafc5", + "enforceFailClosed": "ecf7a07c2aba26cb", + "init": "209680a766f4ec7c", + "isEnabled": "c193c02a32aa295b", + "isErrorReportingEnabled": "f650940efe39d9d6", + "migrateLegacySettings": "953fe743a0813ce2", + "noticeState": "09bcb4d505ea9526", + "personProperties": "0f5fbb80da306c1d", + "setEnabled": "d32777772151597e", + "shutdown": "6db4121e4d7045c2", + "startActiveTimer": "1132af442a4b3b3b", + "track": "2e86b64f1eda064b", + "trackAppStarted": "9c37f94c9015aeb9" + } + } + }, + "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", + "createActiveTimer", + "createRateLimiter", + "DEFAULT_RATE_LIMIT", + "diffSpecLifecycle", + "renameSpecLifecycle" + ], + "depends": [ + "node:crypto", + "../scripts/redact" + ], + "functions": { + "normalizeTool": { + "line": 90, + "params": [ + "id" + ], + "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" + }, + "validateEvent": { + "line": 105, + "params": [ + "name", + "props" + ], + "purpose": "Validate an (event, props) pair against the registry." + }, + "effectiveEnabled": { + "line": 143, + "params": [ + "{ value", + "loadFailed }" + ], + "purpose": "Effective analytics state from the persisted setting plus settings-load" + }, + "shouldShowNotice": { + "line": 173, + "params": [ + "{ storedVersion", + "legacyShown", + "currentVersion }" + ], + "purpose": "Whether the disclosure notice should be shown." + }, + "stripAbsolutePaths": { + "line": 201, + "params": [ + "text" + ] + }, + "sanitizeException": { + "line": 217, + "params": [ + "err" + ], + "purpose": "Make an exception safe to send." + }, + "createActiveTimer": { + "line": 242, + "params": [ + "options" + ] + }, + "planLegacyMigration": { + "line": 285, + "params": [ + "snapshot" + ], + "purpose": "What the rename owes this install." + }, + "resolveInstallId": { + "line": 327, + "params": [ + "{ stored", + "enabled }", + "mint" + ], + "purpose": "Decide this launch's install id from what is stored and whether analytics" + }, + "createRateLimiter": { + "line": 358, + "params": [ + "options" + ], + "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" + }, + "stampedSince": { + "line": 418, + "params": [ + "stamp", + "since" + ] + }, + "diffSpecLifecycle": { + "line": 437, + "params": [ + "previous", + "specs", + "now" + ], + "purpose": "Compare one push of a project's specs with this run's earlier looks at it." + }, + "renameSpecLifecycle": { + "line": 476, + "params": [ + "state", + "oldSlug", + "newSlug" + ], + "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read" + } + }, + "sizeBytes": 21026, + "extraction": { + "status": "parsed" + }, + "provenance": { + "description": "d2887845f6deb252", + "purposes": { + "createRateLimiter": "f272874802425832", + "diffSpecLifecycle": "3b2433f402a29a53", + "effectiveEnabled": "1d7ad3bc41041a9d", + "normalizeTool": "dc8a3cfd5b8b95ee", + "planLegacyMigration": "252eb4a6737ba78e", + "renameSpecLifecycle": "abb27ce2d87a63f8", + "resolveInstallId": "c3af5851e884d6db", + "sanitizeException": "20a013c9fb3b910b", + "shouldShowNotice": "c5887cebeb237764", + "validateEvent": "bf0ab6cb2283257c" + } + } + }, "main/claudeSessionsManager": { "file": "src/main/claudeSessionsManager.js", "description": "Claude Sessions Manager Module", @@ -995,22 +1261,23 @@ "depends": [ "electron", "logger", + "analytics", "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" @@ -1024,7 +1291,7 @@ "MAIN_PROCESS_ERROR" ] }, - "sizeBytes": 3478, + "sizeBytes": 3864, "extraction": { "status": "parsed" }, @@ -1476,7 +1743,7 @@ "../scripts/activity-log", "specManager", "fileTree", - "telemetry" + "analytics" ], "functions": { "init": { @@ -1827,7 +2094,7 @@ "FRAME_CONFIG_DATA" ] }, - "sizeBytes": 71402, + "sizeBytes": 71396, "extraction": { "status": "parsed" }, @@ -3050,7 +3317,7 @@ "uiZoom", "gitStatusManager", "gitDiffManager", - "telemetry", + "analytics", "specManager", "orchestrationManager", "structureLifecycle", @@ -3058,26 +3325,26 @@ ], "functions": { "confirmQuitWithLiveAgents": { - "line": 57, + "line": 58, "purpose": "Quitting (or closing the window) tears down every PTY — killing running" }, "createWindow": { - "line": 75, + "line": 76, "purpose": "Create main application window" }, "probeCoreDeps": { - "line": 176 + "line": 177 }, "setupAllIPC": { - "line": 206, + "line": 207, "purpose": "Setup all IPC handlers" }, "init": { - "line": 284, + "line": 297, "purpose": "Initialize application" }, "initModulesWithWindow": { - "line": 330, + "line": 345, "params": [ "window" ], @@ -3088,8 +3355,10 @@ "listens": [ "GET_USER_SETTING", "SET_USER_SETTING", - "TELEMETRY_SET_ENABLED", - "TELEMETRY_TRACK", + "ANALYTICS_SET_ENABLED", + "ANALYTICS_TRACK", + "ANALYTICS_EXCEPTION", + "ANALYTICS_NOTICE_STATE", "GET_LOG_INFO", "TERMINAL_INPUT", "RECONCILE_TERMINALS" @@ -3098,7 +3367,7 @@ "MAIN_PROCESS_ERROR" ] }, - "sizeBytes": 14361, + "sizeBytes": 15545, "extraction": { "status": "parsed" }, @@ -3541,7 +3810,7 @@ "ptyManager", "gitBranchesManager", "specManager", - "telemetry", + "analytics", "pollGate" ], "functions": { @@ -3911,7 +4180,7 @@ "os", "child_process", "shared/ipcChannels", - "telemetry" + "analytics" ], "functions": { "execGit": { @@ -4421,7 +4690,7 @@ "shared/ipcChannels", "logger", "promptLogger", - "telemetry", + "analytics", "pollGate", "child_process", "fs" @@ -4632,8 +4901,8 @@ "tasksManager", "commandStaging", "frameProject", - "telemetry", - "telemetryEvents", + "analytics", + "analyticsEvents", "activityLog", "perfMonitor", "../scripts/toolVocabulary", @@ -5369,7 +5638,8 @@ "frameStore", "activityLog", "shared/ipcChannels", - "shared/frameConstants" + "shared/frameConstants", + "analytics" ], "functions": { "cacheKeyFor": { @@ -5451,14 +5721,14 @@ ] }, "deleteTask": { - "line": 245, + "line": 252, "params": [ "projectPath", "taskId" ] }, "reorderTasks": { - "line": 264, + "line": 271, "params": [ "projectPath", "order" @@ -5466,42 +5736,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" } }, @@ -5519,7 +5789,7 @@ "TASK_UPDATED" ] }, - "sizeBytes": 15628, + "sizeBytes": 16046, "extraction": { "status": "parsed" }, @@ -5536,158 +5806,6 @@ } } }, - "main/telemetry": { - "file": "src/main/telemetry.js", - "description": "Telemetry", - "exports": [ - "init", - "track", - "trackAppStarted", - "setEnabled", - "isEnabled", - "enforceFailClosed" - ], - "depends": [ - "@aptabase/electron/main", - "userSettings", - "telemetryEvents" - ], - "functions": { - "init": { - "line": 40, - "purpose": "Initialize Aptabase. MUST be called before app.whenReady() because the" - }, - "track": { - "line": 57, - "params": [ - "name", - "props" - ], - "purpose": "Send a registered anonymous event. No-op if disabled or not initialized." - }, - "trackAppStarted": { - "line": 74, - "purpose": "Anonymous event marking this launch." - }, - "setEnabled": { - "line": 82, - "params": [ - "enabled" - ], - "purpose": "Toggle telemetry from Settings. Persists the new state. Aptabase is" - }, - "enforceFailClosed": { - "line": 100, - "purpose": "Make a failed settings load's fail-closed state stick. Called right after" - }, - "isEnabled": { - "line": 111, - "purpose": "Effective enabled state. Default ON when the setting has never been" - } - }, - "sizeBytes": 4335, - "extraction": { - "status": "parsed" - }, - "provenance": { - "description": "e87c57f595420aa9", - "purposes": { - "enforceFailClosed": "ecf7a07c2aba26cb", - "init": "42ba849be1952f08", - "isEnabled": "c193c02a32aa295b", - "setEnabled": "4a841b01bfb46099", - "track": "2e86b64f1eda064b", - "trackAppStarted": "9c37f94c9015aeb9" - } - } - }, - "main/telemetryEvents": { - "file": "src/main/telemetryEvents.js", - "description": "Telemetry events — pure policy module", - "exports": [ - "EVENTS", - "normalizeTool", - "validateEvent", - "effectiveEnabled", - "createRateLimiter", - "DEFAULT_RATE_LIMIT", - "diffSpecLifecycle", - "renameSpecLifecycle" - ], - "depends": [], - "functions": { - "normalizeTool": { - "line": 63, - "params": [ - "id" - ], - "purpose": "Collapse a tool id to the fixed dashboard enum. User-defined custom tool" - }, - "validateEvent": { - "line": 78, - "params": [ - "name", - "props" - ], - "purpose": "Validate an (event, props) pair against the registry." - }, - "effectiveEnabled": { - "line": 102, - "params": [ - "{ value", - "loadFailed }" - ], - "purpose": "Effective telemetry state from the persisted setting plus settings-load" - }, - "createRateLimiter": { - "line": 128, - "params": [ - "options" - ], - "purpose": "A rolling-window limiter. `check(now)` decides one event and returns" - }, - "stampedSince": { - "line": 188, - "params": [ - "stamp", - "since" - ] - }, - "diffSpecLifecycle": { - "line": 207, - "params": [ - "previous", - "specs", - "now" - ], - "purpose": "Compare one push of a project's specs with this run's earlier looks at it." - }, - "renameSpecLifecycle": { - "line": 246, - "params": [ - "state", - "oldSlug", - "newSlug" - ], - "purpose": "Carry a renamed spec's history to its new slug, so the rename does not read" - } - }, - "sizeBytes": 10358, - "extraction": { - "status": "parsed" - }, - "provenance": { - "description": "2fd1c473a30f50f2", - "purposes": { - "createRateLimiter": "f272874802425832", - "diffSpecLifecycle": "3b2433f402a29a53", - "effectiveEnabled": "1f3cf0fb32313e34", - "normalizeTool": "dc8a3cfd5b8b95ee", - "renameSpecLifecycle": "abb27ce2d87a63f8", - "validateEvent": "bf0ab6cb2283257c" - } - } - }, "main/uiZoom": { "file": "src/main/uiZoom.js", "description": "UI zoom — the main-process owner of the interface scale (ui-zoom-steps spec).", @@ -5836,7 +5954,7 @@ "path", "electron", "fsSafe", - "telemetry" + "analytics" ], "functions": { "init": { @@ -6016,7 +6134,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 216296, + "sizeBytes": 217739, "extraction": { "status": "unsupported", "reason": "no-extractor" @@ -6028,7 +6146,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 3747, + "sizeBytes": 3778, "extraction": { "status": "unsupported", "reason": "no-extractor" @@ -6405,107 +6523,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" @@ -6513,14 +6631,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" @@ -6533,12 +6651,13 @@ "TERMINAL_DESTROYED" ], "emits": [ - "TELEMETRY_TRACK", - "TELEMETRY_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", "SPEC_AGENT_ACTIVITY" ] }, - "sizeBytes": 41248, + "sizeBytes": 41538, "extraction": { "status": "parsed" }, @@ -6656,6 +6775,35 @@ } } }, + "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 + } + }, + "sizeBytes": 2518, + "extraction": { + "status": "parsed" + }, + "provenance": { + "description": "e8f5bd6a1459bcb7" + } + }, "renderer/appHeader": { "file": "src/renderer/appHeader.js", "description": "App Header — the one row across the top of the window", @@ -7316,6 +7464,8 @@ "lucideIcon" ], "depends": [ + "electron", + "shared/ipcChannels", "dock/dockState", "dnd/sortable", "tooltip", @@ -7331,7 +7481,7 @@ ], "functions": { "lucideIcon": { - "line": 44, + "line": 46, "params": [ "data", "size = 14" @@ -7339,7 +7489,7 @@ "purpose": "Inline SVG for a lucide icon's data (the shape sectionRail.js uses)." }, "emptyState": { - "line": 53, + "line": 55, "params": [ "title", "message" @@ -7347,7 +7497,7 @@ "purpose": "The dock's inline empty state — the shape decisionsView.renderEmpty uses." }, "panelTab": { - "line": 72, + "line": 74, "params": [ "{ label", "icon", @@ -7357,116 +7507,122 @@ "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." } }, - "sizeBytes": 18489, + "ipc": { + "listens": [], + "emits": [ + "ANALYTICS_TRACK" + ] + }, + "sizeBytes": 18854, "extraction": { "status": "parsed" }, @@ -8049,7 +8205,7 @@ "ipc": { "listens": [], "emits": [ - "TELEMETRY_TRACK" + "ANALYTICS_TRACK" ] }, "sizeBytes": 15853, @@ -8327,19 +8483,19 @@ ], "functions": { "init": { - "line": 45 + "line": 47 }, "initAboutSection": { - "line": 104 + "line": 124 }, "runCheck": { - "line": 189, + "line": 209, "params": [ "userInitiated" ] }, "renderUpdateState": { - "line": 228, + "line": 248, "params": [ "{ checked", "found", @@ -8351,32 +8507,39 @@ ] }, "showUpdateBanner": { - "line": 252, + "line": 272, "params": [ "info" ] }, "hideUpdateBanner": { - "line": 263 + "line": 283 }, "hideSidebarDot": { - "line": 267 + "line": 287 }, "formatRelative": { - "line": 273, + "line": 293, "params": [ "date" ] }, "initZoomSelect": { - "line": 289, + "line": 309, "purpose": "Fill the Interface size select from the shared ladder — one option per" }, "syncZoomFromMain": { - "line": 308 + "line": 332 + }, + "syncErrorReportingAvailability": { + "line": 348, + "params": [ + "analyticsOn" + ], + "purpose": "Error reporting is gated on analytics in main (analytics.captureException" }, "syncToggleFromSettings": { - "line": 318 + "line": 355 } }, "ipc": { @@ -8385,16 +8548,22 @@ "OPEN_SETTINGS", "UPDATE_AVAILABLE" ], - "emits": [] + "emits": [ + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK", + "ANALYTICS_TRACK" + ] }, - "sizeBytes": 11647, + "sizeBytes": 14043, "extraction": { "status": "parsed" }, "provenance": { "description": "1dffdbc6d50a7e19", "purposes": { - "initZoomSelect": "cbe76526d4d65826" + "initZoomSelect": "cbe76526d4d65826", + "syncErrorReportingAvailability": "f99d7b4f4c5c647a" } } }, @@ -9459,30 +9628,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 } = {}" @@ -9490,47 +9659,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", @@ -9538,37 +9707,43 @@ ] }, "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 } }, - "sizeBytes": 16813, + "ipc": { + "listens": [], + "emits": [ + "ANALYTICS_TRACK" + ] + }, + "sizeBytes": 17021, "extraction": { "status": "parsed" }, @@ -10249,7 +10424,7 @@ "doneWindow", "frameSettingsModal", "feedbackPanel", - "telemetryNotice", + "analyticsNotice", "healthNotice", "specDrivenHint", "docsHealthHint", @@ -10295,6 +10470,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": { @@ -10305,16 +10487,18 @@ "UPDATE_AVAILABLE" ], "emits": [ - "REFRESH_GIT_STATUS" + "REFRESH_GIT_STATUS", + "ANALYTICS_EXCEPTION" ] }, - "sizeBytes": 35701, + "sizeBytes": 36509, "extraction": { "status": "parsed" }, "provenance": { "description": "15084cdcaeca3e5f", "purposes": { + "forwardException": "50c359f5f5349218", "init": "963690fd5a00420d", "registerCommands": "a3e9675a98b7208e", "revealSidebarTab": "0ebc0110a1121c81", @@ -11047,7 +11231,7 @@ "ORCH_STATE" ], "emits": [ - "TELEMETRY_TRACK", + "ANALYTICS_TRACK", "ORCH_WORKER_LANE" ] }, @@ -12244,14 +12428,20 @@ "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" } }, - "sizeBytes": 7637, + "ipc": { + "listens": [], + "emits": [ + "ANALYTICS_TRACK" + ] + }, + "sizeBytes": 7860, "extraction": { "status": "parsed" }, @@ -14126,6 +14316,18 @@ "reason": "no-extractor" } }, + "renderer/styles/components/analytics-notice.css": { + "file": "src/renderer/styles/components/analytics-notice.css", + "description": "", + "exports": [], + "depends": [], + "functions": {}, + "sizeBytes": 2580, + "extraction": { + "status": "unsupported", + "reason": "no-extractor" + } + }, "renderer/styles/components/app-header.css": { "file": "src/renderer/styles/components/app-header.css", "description": "", @@ -14372,7 +14574,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 7552, + "sizeBytes": 8092, "extraction": { "status": "unsupported", "reason": "no-extractor" @@ -14426,18 +14628,6 @@ "reason": "no-extractor" } }, - "renderer/styles/components/telemetry-notice.css": { - "file": "src/renderer/styles/components/telemetry-notice.css", - "description": "", - "exports": [], - "depends": [], - "functions": {}, - "sizeBytes": 2580, - "extraction": { - "status": "unsupported", - "reason": "no-extractor" - } - }, "renderer/styles/components/terminal.css": { "file": "src/renderer/styles/components/terminal.css", "description": "", @@ -15259,35 +15449,6 @@ } } }, - "renderer/telemetryNotice": { - "file": "src/renderer/telemetryNotice.js", - "description": "Telemetry Notice Banner", - "exports": [ - "init" - ], - "depends": [ - "electron", - "shared/ipcChannels" - ], - "functions": { - "init": { - "line": 21, - "params": [ - "openSettings" - ] - }, - "dismiss": { - "line": 48 - } - }, - "sizeBytes": 1921, - "extraction": { - "status": "parsed" - }, - "provenance": { - "description": "00ef406cce3b4f98" - } - }, "renderer/terminal": { "file": "src/renderer/terminal.js", "description": "Terminal UI Module", @@ -17015,7 +17176,7 @@ "exports": [], "depends": [], "functions": {}, - "sizeBytes": 4013, + "sizeBytes": 5125, "extraction": { "status": "unsupported", "reason": "no-extractor" @@ -21106,7 +21267,7 @@ ], "depends": [], "functions": {}, - "sizeBytes": 11799, + "sizeBytes": 11930, "extraction": { "status": "parsed" }, @@ -21971,6 +22132,33 @@ "description": "a806f9f813564656" } }, + "test/analytics.test": { + "file": "test/analytics.test.js", + "description": "Analytics policy tests — fail-closed opt-out decision.", + "exports": [], + "depends": [ + "node:test", + "node:assert/strict", + "src/main/analyticsEvents", + "fs", + "path" + ], + "functions": { + "seed": { + "line": 418, + "params": [ + "specs" + ] + } + }, + "sizeBytes": 21440, + "extraction": { + "status": "parsed" + }, + "provenance": { + "description": "9d61943b8b213ee4" + } + }, "test/branchPickerModel.test": { "file": "test/branchPickerModel.test.js", "description": "The status bar branch picker's list model (status-bar-branch-picker spec,", @@ -24972,33 +25160,6 @@ "description": "494edc14d60ce825" } }, - "test/telemetry.test": { - "file": "test/telemetry.test.js", - "description": "Telemetry policy tests — fail-closed opt-out decision.", - "exports": [], - "depends": [ - "node:test", - "node:assert/strict", - "src/main/telemetryEvents", - "fs", - "path" - ], - "functions": { - "seed": { - "line": 186, - "params": [ - "specs" - ] - } - }, - "sizeBytes": 11255, - "extraction": { - "status": "parsed" - }, - "provenance": { - "description": "02f0cdc3a91d93c8" - } - }, "test/themes.test": { "file": "test/themes.test.js", "description": "The theme registry (src/renderer/themes.js).", @@ -25941,6 +26102,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": [], @@ -25988,6 +26171,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", @@ -26113,9 +26308,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", @@ -26722,18 +26917,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/.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/outcome.md b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md new file mode 100644 index 00000000..fe5a31e7 --- /dev/null +++ b/.frame/specs/posthog-analytics-identity-funnels-and-errors/outcome.md @@ -0,0 +1,84 @@ +# 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)_ + +--- + +## 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)_ + +--- + +## 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)_ + +--- + +## 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)_ + +--- + +## 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 `(?/…`, `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..6d32be92 --- /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": "done", + "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:53:09Z", + "last_phase_at": "2026-09-21T13:53:09Z", + "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..283ff22c 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)", @@ -705,17 +705,17 @@ }, { "id": "task-aptabase-key-env", - "title": "Move the Aptabase app key out of source into an env var", - "description": "src/main/telemetry.js:21 hardcodes APTABASE_APP_KEY = 'A-EU-5590504973'. Read it from process.env (e.g. FRAME_APTABASE_KEY) with the value injected at build time — electron-builder can bake it via extraMetadata or a generated config module — instead of living in the tracked source. telemetry.init() must keep working when the var is absent: no key means telemetry stays off rather than crashing or sending to a wrong endpoint. Touches src/main/telemetry.js, package.json's build config, and RELEASE.local.md (the release steps gain one more exported var).", - "userRequest": "User said: \"aptabase key'i env var'a taşımak için task yaratalım onu sonra yapalım\"", - "acceptanceCriteria": "No Aptabase key literal remains in tracked source; A signed build produced by the documented release flow still reports telemetry; A build with the env var unset starts normally with telemetry disabled — no crash, no request to a wrong app; RELEASE.local.md documents the new variable alongside the Apple ones", - "notes": "Not a leak: an Aptabase app key is a client-side ingestion ID, meant to ship inside the binary, and it is already in git history and every released 2.x build. Rotating it is not the point — this is config hygiene, so the key stops being a source-code constant and can differ per environment (dev vs release). Because it is already public, this is not urgent and does not need a coordinated rotation. Prior decisions in .frame/specs/audit-q3-product-analytics/ (the 10-event registry, opt-out fails CLOSED) stay as they are; only where the key comes from changes.", + "title": "Move the Aptabase and PostHog keys into build-time env vars", + "description": "src/main/analytics.js hardcodes both ingestion keys: APTABASE_APP_KEY = 'A-EU-5590504973' and POSTHOG_API_KEY = 'phc_w4KG…'. Read each from process.env (e.g. FRAME_APTABASE_KEY, FRAME_POSTHOG_KEY) with the values injected at build time — electron-builder can bake them via extraMetadata or a generated config module — instead of living in the tracked source. A desktop app has no runtime env on the user's machine, so the values must be baked into the packaged build. analytics.init() must keep working when either var is absent: a missing key turns that provider off (the other keeps sending) rather than crashing or sending to a wrong endpoint. Touches src/main/analytics.js, package.json's build config, and RELEASE.local.md (the release steps gain the exported vars).", + "userRequest": "User said: \"aptabase key'i env var'a taşımak için task yaratalım onu sonra yapalım\" — extended 2026-10-05 after restoring Aptabase alongside PostHog: \"şimdilik kodda dursun ama task yaratıp atabiliriz pendinge\"", + "acceptanceCriteria": "No Aptabase or PostHog key literal remains in tracked source; A signed build produced by the documented release flow still reports to both Aptabase and PostHog; A build (or npm start) with the vars unset starts normally with analytics sending nothing — no crash, no request to a wrong project; With only one var set, only that provider sends; RELEASE.local.md documents the new variables alongside the Apple ones", + "notes": "Not a leak: both are client-side, write-only ingestion keys meant to ship inside the binary (PostHog phc_ token cannot read the dashboard or delete data), and both are already in git history and released builds. Moving them does not hide them — app.asar is extractable. The real wins: (1) forks and anyone running the public repo from source stop sending events into Frame's dashboards; (2) dev runs can use separate projects or send nothing, so dev usage stops polluting prod data. Not urgent, no rotation needed. PostHog personal/secret API keys must never enter source either way. Prior decisions in .frame/specs/audit-q3-product-analytics/ (event registry allowlist, opt-out fails CLOSED) stay as they are; only where the keys come from changes.", "status": "pending", "priority": "low", "category": "refactor", - "context": "Spotted during the v2.6.0 release audit, while checking that no key ships in the package", + "context": "Spotted during the v2.6.0 release audit, while checking that no key ships in the package; extended 2026-10-05 when Aptabase was restored next to PostHog (ea097e7) and the user decided to keep both keys in source for now", "createdAt": "2026-07-28T18:29:31.226Z", - "updatedAt": "2026-07-28T18:29:31.226Z", + "updatedAt": "2026-10-05T12:00:00.000Z", "completedAt": null }, { @@ -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": "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:36:43Z", + "completedAt": "2026-09-21T13:36:43Z" + }, + { + "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": "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:39:07Z", + "completedAt": "2026-09-21T13:39:07Z" + }, + { + "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": "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:40:40Z", + "completedAt": "2026-09-21T13:40:40Z" + }, + { + "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": "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:41:45Z", + "completedAt": "2026-09-21T13:41:45Z" + }, + { + "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": "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:43:24Z", + "completedAt": "2026-09-21T13:43:24Z" + }, + { + "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": "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:44:39Z", + "completedAt": "2026-09-21T13:44:39Z" + }, + { + "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": "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:45:51Z", + "completedAt": "2026-09-21T13:45:51Z" + }, + { + "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": "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:47:32Z", + "completedAt": "2026-09-21T13:47:32Z" + }, + { + "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": "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:50:43Z", + "completedAt": "2026-09-21T13:50:43Z" + }, + { + "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": "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:52:51Z", + "completedAt": "2026-09-21T13:52:51Z" } ], "metadata": { - "totalCreated": 609, + "totalCreated": 619, "totalCompleted": 29 }, "categories": { @@ -8375,4 +8505,4 @@ "test": "Testing", "research": "Research and exploration" } -} \ No newline at end of file +} diff --git a/PRIVACY.md b/PRIVACY.md index e9ca8ed9..6ffeb100 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,7 +4,11 @@ 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 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 that is either a value from a **fixed list** or a **plain number** (one exists: how long a session was active). Never free text, never anything typed or generated by you — a number cannot hold a file path or a prompt, and a value outside its list is dropped before sending. + +Each event also carries a **session ID** — a random value generated fresh at every launch and never stored, which lets events from one sitting be read together and means nothing at all once the app closes. + +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: @@ -19,6 +23,13 @@ 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) | +| `app_session_ended` | `active_seconds`: a number, capped at one day | how long Frame was actually on screen this session — only time a window was visible counts, so leaving it open in the background adds nothing | +| `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 | @@ -26,6 +37,24 @@ 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 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 + +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 analytics off stops this like everything else — no event, no location. + ## What we do **not** collect - ❌ File paths @@ -33,24 +62,39 @@ 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 +- ❌ 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 -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, 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. + +## Error reports (opt-in, off by default) -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. +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. -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. +When it is on, an exception's message and stack are sent after two passes: + +- **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. + +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. +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. + +The same usage events are also sent to [Aptabase](https://aptabase.com), an open-source, privacy-focused analytics service on their EU region. Aptabase receives only the event name, its fixed-list property, the app version and the operating system — **not** the install ID, and never error reports. Aptabase does not retain IP addresses and does not share data with third parties. ## 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. @@ -60,7 +104,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. @@ -73,7 +117,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 diff --git a/index.html b/index.html index 5b79df75..448ca6c8 100644 --- a/index.html +++ b/index.html @@ -18,25 +18,27 @@ - -
-
-