Repository navigation
Add PostHog analytics alongside Aptabase - #165
Merged
Merged
Conversation
…and tasks 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
"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) <noreply@anthropic.com>
The previous commit removed `disableGeoip: true` and claimed geo was on. It was not. posthog-node is a server-side SDK, where the request IP is usually the server's, so @posthog/core defaults the flag to TRUE (`options.disableGeoip ?? true`) and kept stamping $geoip_disable on every event. Frame is the exception that default does not expect: it runs on the user's machine, so the request IP is the user's. The flag has to say false out loud, and the comment now says why it must not be tidied away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
posthog-node is a server-side SDK with no notion of a session: it forwards
a $session_id but never invents one, so PostHog's session views were empty.
A launch is now the session.
Active time is counted by ticks of a visibility-gated interval, not by
clock arithmetic — Frame sits open all day, so wall-clock launch-to-quit
would report a working day for someone who glanced at it twice, and a
timestamp delta across a hidden pause would bill those hours as active.
active_seconds is the registry's first number. validateEvent now takes a
declared {type:'number', max} spec beside the enum arrays; the type check
is the whole guarantee, since a number cannot carry a path or a prompt.
PRIVACY.md stops saying every property comes from a fixed list, because
that is no longer true, and says what is true instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Restores the Aptabase integration removed in 57b49a0 so both services receive usage events. Aptabase gets the same validated, rate-limited events without the install id, session id or person properties; exception reports stay PostHog-only. Each SDK initializes independently so one failing leaves the other sending, and init() again runs before app.whenReady() as Aptabase requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the .frame/STRUCTURE.json conflict by taking upstream's map and regenerating it from the merged index with update-structure --staged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI runs the suite without node_modules, so every test that loads analytics.js transitively failed with MODULE_NOT_FOUND once posthog-node became a top-level require. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # .frame/STRUCTURE.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds PostHog next to the existing Aptabase integration. Both now receive the same allowlisted usage events. On top of that, PostHog gets an anonymous install id, which makes unique users, funnels and retention measurable instead of just event counts.
What changes
Two analytics backends, side by side
app.whenReady(), and it receives the validated events without any identifier.posthog-node) receives the same events plus an anonymous install id, a per-launch session id and OS / app version as person properties.Identity and sessions (PostHog only)
app_session_endedreports active seconds, counted only while a window is visible.disableGeoip: false). The IP itself is not stored.Events
panel_opened,implement_mode_selected,tour_finished,session_resumed,task_completed,settings_changed.analyticsEvents.js. Unregistered events are dropped, and unknown properties or out-of-enum values are stripped.Opt-in error reporting (PostHog only)
Other
PRIVACY.mddocuments both backends, the install id, location and the error opt-in.Keys
The Aptabase app key and the PostHog project token are both write-only ingestion keys and are hardcoded in
src/main/analytics.js. They can send events but cannot read or delete data. Moving them to build-time env vars, so that forks and dev runs stop reporting to these dashboards, is tracked as a follow-up (task-aptabase-key-env).Testing
npm test: 856 passing.🤖 Generated with Claude Code