Skip to content

Add PostHog analytics alongside Aptabase - #165

Merged
kaanozhan merged 25 commits into
kaanozhan:mainfrom
denizmrtoglu:main
Oct 8, 2026
Merged

kaanozhan merged 25 commits into
kaanozhan:mainfrom
denizmrtoglu:main

Conversation

@denizmrtoglu

@denizmrtoglu denizmrtoglu commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Aptabase stays as it was: same app key, initialized before app.whenReady(), and it receives the validated events without any identifier.
  • PostHog (EU cloud, posthog-node) receives the same events plus an anonymous install id, a per-launch session id and OS / app version as person properties.
  • Each backend initializes on its own, so a failure in one does not stop the other. Both use the same allowlist, rate limiter and opt-out.

Identity and sessions (PostHog only)

  • A random UUID install id is stored in user settings. Turning analytics off deletes it, and turning it back on creates a new one.
  • Each launch gets a session id. app_session_ended reports active seconds, counted only while a window is visible.
  • Country and region come from the request IP (disableGeoip: false). The IP itself is not stored.

Events

  • Six new behaviour events, each wired at a single chokepoint rather than on individual buttons: panel_opened, implement_mode_selected, tour_finished, session_resumed, task_completed, settings_changed.
  • Every event and property is still declared in the registry in analyticsEvents.js. Unregistered events are dropped, and unknown properties or out-of-enum values are stripped.

Opt-in error reporting (PostHog only)

  • A separate "Send error reports" setting, off by default, which also requires analytics to be on.
  • Exceptions from the main process and the renderer are sanitized before sending: secrets are redacted and absolute paths are reduced to file names.

Other

  • "Telemetry" is renamed to "analytics" (modules, IPC channels, settings keys, CSS and tests). Existing settings are migrated forward, and a corrupt settings file still fails closed.
  • The disclosure notice is now versioned, so it is shown once more when what is collected changes.
  • The PostHog batcher is flushed on quit, with a 2 s timeout.
  • PRIVACY.md documents 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

denizmrtoglu and others added 22 commits September 21, 2026 16:34
…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>
@denizmrtoglu denizmrtoglu changed the title Add PostHog analytics integration Add PostHog analytics alongside Aptabase Oct 5, 2026
denizmrtoglu and others added 3 commits October 5, 2026 14:07
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
@kaanozhan
kaanozhan merged commit 4a89824 into kaanozhan:main Oct 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants