Owner-sanctioned reversal: the publishable write-only PostHog client key is
committed as the in-repo default in backend/core/analytics.py and
frontend/src/utils/analytics.ts (env / baked release token still wins), so
source builds show the same first-run consent ask as installers — skip = off,
nothing is ever sent without an explicit yes. Adds an install_channel property
(installer / docker / source) to lifecycle events, stamped by the desktop shell
via OMNIVOICE_INSTALL_CHANNEL and by the Docker image's existing
OMNIVOICE_SERVER_MODE marker. Guard tests now pin the two-canonical-files
allowlist + same-token invariant, and the uninstall-ping info file works on the
default token.
Fixes#1193
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
core/analytics.py reads POSTHOG_PROJECT_TOKEN from its own environment at RUNTIME,
but the backend runs on the *user's* machine, where nothing sets it. So in a shipped
build token_configured() was false forever: every backend event — including the
speech_generated capture — was silently dropped, no matter what secret CI held. Only
the frontend half ever worked, and nothing would have told us.
The token is really a build input. release.yml already passes the POSTHOG_PROJECT_TOKEN
secret to the tauri-action step as VITE_POSTHOG_KEY, and that step compiles the Rust
shell as well as the frontend bundle — so option_env! bakes it into the shell on exactly
the builds that ship it, and spawn_backend() hands it to the child process.
The guarantees are unchanged and now pinned by tests:
- no token baked in (every source build) => nothing passed => no destination => the
backend cannot transmit, and the toggle isn't offered;
- a real process env var still wins, so a dev can point a local run at their own project;
- consent remains a separate gate (prefs, default off) — a destination alone sends nothing.
Hardened against silent recurrence, since this failure mode is invisible: build.rs gets
rerun-if-env-changed (option_env! is compile-time, so a cached build would otherwise keep
the token it first saw), and tests assert the whole chain — release.yml still passes the
secret, backend.rs still bakes it, build.rs still busts the cache.
Also fixes a CHANGELOG contradiction that would have shipped in the release notes: the
Usage-panel entry still claimed PostHog "was proposed and rejected ... there is no
analytics service, no token", directly under two entries announcing it.
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(analytics): wire posthog-js — consent-gated, autocapture OFF
The owner supplied the standard snippet:
posthog.init(TOKEN, { api_host, defaults: '2026-05-30' })
Shipping that verbatim would have broken the guarantee we just made, twice:
1. It initialises AT MODULE LOAD — it starts tracking every user before they
have consented to anything. The README now says "OmniVoice sends nothing out
of the box"; this would have made that false on the very next release.
Analytics is therefore started ONLY after the user opts in (Settings →
Privacy), and the stored consent is what restores it at launch.
2. posthog-js AUTOCAPTURES by default, and `defaults: '2026-05-30'` turns that
on. Autocapture sends the text content of the DOM elements a user interacts
with. In THIS app the DOM holds the script they are about to synthesise,
their voice names and their file names — exactly the content we promise never
leaves the machine. It is explicitly disabled, along with session recording
(which records the screen) and pageview capture.
utils/analytics.ts: hardenedConfig() — autocapture false, disable_session_recording
true, capture_pageview/pageleave false, mask_all_text + mask_all_element_attributes
as defence in depth, and opt_out_capturing_by_default so init alone can never
capture. Events pass sanitizeProps(), mirroring the backend allowlist: a key not
on it is DROPPED and long strings refused, so a future caller cannot leak content
by adding a field. Backend down / no consent / no destination → stays off.
The token is taken from VITE_POSTHOG_KEY at BUILD time and is never committed —
a token-shaped literal trips the secret scanner and is a bad habit regardless.
release.yml injects it from a repo secret; the backend already reads
POSTHOG_PROJECT_TOKEN the same way. No token => no destination => the Privacy
toggle isn't offered and nothing can be sent, which is the right default for a
source build. A test fails if a phc_ literal is ever committed to that file.
posthog-js added to frontend/package.json; root bun.lock regenerated and
`bun install --frozen-lockfile` verified (the Docker gate).
11 tests: autocapture/session-recording/pageview off, starts opted-out, allowlist
drops text+paths+names, long strings refused, consent honoured in all three
failure directions, and no token literal in source. Frontend suite 1229 passed.
* test(analytics): guard the committed-token rule in the suite, not just in the scanner
The frontend typecheck failed on the guard I added: it reached for `node:fs`,
which has no type definitions in the frontend tsconfig (and would have been
cwd-dependent at runtime anyway). Wrong layer.
Source-scanning guards in this repo are Python tests (test_no_hardcoded_cjk,
test_no_literal_borders), so this one moves there — and gets strictly stronger
in the process: it scans every tracked file rather than analytics.ts alone, and
matches a PostHog key by SHAPE (phc_[A-Za-z0-9]{20,}), so a *different* key
can't slip through where the old test only knew about the one gitleaks caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>