feat(analytics): in-repo publishable token — source builds get the same consent-gated analytics (#1193)

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>
This commit is contained in:
debpalash
2026-07-20 11:51:40 +05:30
co-authored by Claude Fable 5
parent 367ed1e8e0
commit 23f1767e3c
14 changed files with 277 additions and 101 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175)
- `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175)
- First-run downloads race the direct GitHub path against the mirror and use whichever answers fastest (#1179)
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no; source builds never ask)
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no)
- Source builds carry the publishable analytics token and get the same first-run consent ask as installers; opt-in events now note the install channel (installer / docker / source) — thanks @agudmund! (#1193)
- Official Google Colab notebook (`notebooks/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
- ROCm Docker image `ghcr.io/debpalash/omnivoice-studio:rocm` (+ `:stable-rocm`, `:X.Y.Z-rocm`) (#1165)
- `OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170)
+2 -2
View File
@@ -15,7 +15,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
- **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline.
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); source builds have no token and never even ask. No required cloud calls, accounts, or API keys.
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys.
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
<!-- GSD:project-end -->
@@ -24,7 +24,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
- **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture.
- **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture. Its publishable write-only project token is committed in-repo (owner reversal 2026-07-20, #1193 — source builds get the same consent-gated analytics as installers; env/baked token overrides), allowed by `tests/test_no_committed_analytics_token.py` in exactly `backend/core/analytics.py` + `frontend/src/utils/analytics.ts`.
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
+1 -1
View File
@@ -586,7 +586,7 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
<br/>
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, OmniVoice sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Source builds have no analytics destination at all, so they never even ask. Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
</details>
<details>
+3 -2
View File
@@ -1020,8 +1020,9 @@ def get_analytics():
return {
"enabled": analytics.enabled(),
"opted_in": analytics.user_opted_in(),
# False for source builds / any build with no token: analytics can never
# run, so the UI can say so instead of offering a toggle that does nothing.
# True for source builds too since #1193 (in-repo default token; env/baked
# overrides). False only for a destination-less build, where the UI can
# say so instead of offering a toggle that does nothing.
"available": analytics.token_configured(),
# Whether the user has ever been explicitly asked (first-run consent step
# or the one-time banner). The UI uses this to ask exactly once — it never
+60 -13
View File
@@ -4,10 +4,12 @@ OmniVoice is local-first, so analytics here is held to a higher bar than the
usual SDK drop-in. Three rules, each enforced in code below and pinned by tests:
1. **Off unless the user says yes.** Two independent gates must BOTH be true:
a build-provided ``POSTHOG_PROJECT_TOKEN`` *and* the user's explicit
``analytics_enabled`` preference, which defaults to **False**. A default
install transmits nothing, so the product's promise holds out of the box.
``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill switch that outranks both.
a configured destination token (the in-repo publishable default, overridden
by ``POSTHOG_PROJECT_TOKEN`` when set see ``_PUBLIC_PROJECT_TOKEN``) *and*
the user's explicit ``analytics_enabled`` preference, which defaults to
**False**. A default install transmits nothing, so the product's promise
holds out of the box. ``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill
switch that outranks both.
2. **No exception autocapture, ever.** The obvious SDK default
(``enable_exception_autocapture=True``) ships raw tracebacks which carry
@@ -42,6 +44,19 @@ _client_key: Optional[str] = None # the (token, host) the live client was built
_KILL_SWITCH = "OMNIVOICE_ANALYTICS_DISABLED"
_OFF_VALUES = {"1", "true", "yes", "on"}
#: In-repo default analytics destination (owner-sanctioned reversal, #1193):
#: source builds get the SAME consent-gated analytics as installers. This is a
#: PostHog *publishable* client key — write-only event ingestion, no data
#: access; PostHog's own FAQ says these are designed to ship in client code —
#: NOT a secret. It only names a destination: not one event leaves the machine
#: without the user's explicit opt-in (see `enabled()`).
#: A `POSTHOG_PROJECT_TOKEN` env var (release builds bake one in via the
#: desktop shell; developers can point at their own project) always wins.
#: Committed-token guard: tests/test_no_committed_analytics_token.py allows a
#: `phc_` literal in exactly this file and frontend/src/utils/analytics.ts.
_PUBLIC_PROJECT_TOKEN = "phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9"
_DEFAULT_HOST = "https://eu.i.posthog.com"
#: The ONLY property keys that may leave this machine. Anything else is dropped.
#: Deliberately conservative: no free text, no paths, no names, no ids of user
#: content. Add here only after asking "could this ever hold something the user
@@ -70,6 +85,7 @@ _ALLOWED_PROPS: frozenset[str] = frozenset({
"uptime_bucket", # app_crashed: BUCKETED prior-run uptime, never raw seconds
"error_class", # error_occurred/app_crashed: locked taxonomy key (GPU_OOM, …)
"stage", # error_occurred: coarse pipeline stage / route head only
"install_channel", # installer | docker | source — closed set, never a path
})
#: A string property longer than this is refused outright — a belt-and-braces
@@ -130,11 +146,23 @@ def user_prompted() -> bool:
return False
def _resolved_token() -> str:
"""The destination token: env (baked builds / developer override) wins,
the committed publishable default (#1193) is the fallback. Empty only when
both are blank a destination-less build can never run analytics."""
return (os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip() or _PUBLIC_PROJECT_TOKEN
def _resolved_host() -> str:
return (os.environ.get("POSTHOG_HOST") or _DEFAULT_HOST).strip()
def token_configured() -> bool:
"""Whether this BUILD ships an analytics destination at all. When false,
analytics can never run no matter what the user chooses which is the case
for anyone building from source."""
return bool((os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip())
"""Whether this build has an analytics destination at all. Since #1193 the
in-repo default means source builds have one too so they get the same
first-run consent ask as installers. False only when both the env var and
the committed default are blank; consent stays the real gate regardless."""
return bool(_resolved_token())
def enabled() -> bool:
@@ -152,8 +180,8 @@ def _get_client():
shutdown()
return None
token = os.environ["POSTHOG_PROJECT_TOKEN"].strip()
host = (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip()
token = _resolved_token()
host = _resolved_host()
key = f"{token}@{host}"
if _client is not None and _client_key == key:
return _client
@@ -274,8 +302,27 @@ def _platform() -> str:
return {"darwin": "macos"}.get(_pl.system().lower(), _pl.system().lower() or "unknown")
def install_channel() -> str:
"""How this backend was distributed — a closed set, never derived from
paths or hostnames. "installer": the desktop shell sets
``OMNIVOICE_INSTALL_CHANNEL=installer`` (backend.rs analytics_env()).
"docker": the image sets ``OMNIVOICE_SERVER_MODE=1`` (see
api/dependencies.py the pre-existing Docker marker). Else "source"."""
ch = (os.environ.get("OMNIVOICE_INSTALL_CHANNEL", "") or "").strip().lower()
if ch in {"installer", "docker", "source"}:
return ch
# _OFF_VALUES doubles as the repo's canonical truthy-string set.
if (os.environ.get("OMNIVOICE_SERVER_MODE", "") or "").strip().lower() in _OFF_VALUES:
return "docker"
return "source"
def _common_props() -> dict:
return {"app_version": _app_version(), "platform": _platform()}
return {
"app_version": _app_version(),
"platform": _platform(),
"install_channel": install_channel(),
}
def uptime_bucket(seconds: Optional[float]) -> str:
@@ -414,8 +461,8 @@ def sync_uninstall_ping_info() -> None:
pass
return
payload = {
"token": os.environ["POSTHOG_PROJECT_TOKEN"].strip(),
"host": (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip(),
"token": _resolved_token(),
"host": _resolved_host(),
"distinct_id": installation_id(),
"app_version": _app_version(),
"platform": _platform(),
+46 -25
View File
@@ -281,27 +281,33 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
/// Env the spawned backend needs in order to have an analytics destination at all.
/// Analytics env for the spawned backend: destination override + install channel.
///
/// `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.
/// Without this, `token_configured()` is false forever and every backend event is
/// dead code in every shipped build, no matter what secret CI holds.
///
/// The token is really a *build* input. release.yml passes the
/// `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as `VITE_POSTHOG_KEY`,
/// and that step compiles this binary as well as the frontend bundle — so
/// `option_env!` bakes it in on exactly the builds that ship it, and we hand it to
/// the child process here.
/// `core/analytics.py` ships an in-repo publishable default token (#1193), and
/// reads `POSTHOG_PROJECT_TOKEN` from its environment as the OVERRIDE. release.yml
/// passes the `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as
/// `VITE_POSTHOG_KEY`, and that step compiles this binary as well as the frontend
/// bundle — so `option_env!` bakes it in on the builds that ship it, and we hand
/// it to the child process here so a baked release token wins over the in-repo
/// default.
///
/// Two properties this preserves, both load-bearing:
/// * **No token baked in (every source build) => nothing is passed** => the
/// backend has no destination and analytics can never run. Correct default.
/// * **A real process env var wins**, so a developer can point a local run at
/// their own PostHog project without recompiling.
/// * **Since #1193 there is always a destination**: `core/analytics.py` now
/// carries the in-repo publishable default token, so even when nothing is
/// baked in here (a source-built shell) the backend can run its
/// consent-gated analytics. What this function adds on top is *override*
/// precedence — a baked release token replaces the in-repo default.
/// * **A real process env var wins over both**, so a developer can point a
/// local run at their own PostHog project without recompiling.
///
/// This only supplies a *destination*. Consent is a separate gate the backend
/// checks in prefs (default off) — a token alone never causes a single event.
/// It also stamps `OMNIVOICE_INSTALL_CHANNEL=installer` (#1193): anyone running
/// through this desktop shell is on the "installer" channel — the backend's
/// `install_channel()` reads it (docker is detected via its own marker; bare
/// `uvicorn` runs report "source").
///
/// This only supplies a *destination* and a channel label. Consent is a separate
/// gate the backend checks in prefs (default off) — a token alone never causes a
/// single event.
fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut pass = |name: &str, baked: Option<&str>| {
@@ -314,6 +320,7 @@ fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(St
};
pass("POSTHOG_PROJECT_TOKEN", baked_token);
pass("POSTHOG_HOST", baked_host);
pass("OMNIVOICE_INSTALL_CHANNEL", Some("installer"));
out
}
@@ -500,8 +507,10 @@ mod tests {
// #1123 shipped backend analytics that could never run: core/analytics.py reads
// POSTHOG_PROJECT_TOKEN from the runtime environment, and nothing on the user's
// machine ever set it. These pin the wiring that fixes it — and, just as
// importantly, pin that a build with no token stays silent.
// machine ever set it. These pin the wiring that fixes it. Since #1193 the
// backend also carries an in-repo default token, so what's pinned here is the
// OVERRIDE precedence (baked > in-repo default, process env > baked) plus the
// "installer" channel marker this shell stamps on the child.
/// The env-var tests below mutate process-global state; keep them off each
/// other's toes (cargo runs tests in threads by default).
@@ -512,35 +521,47 @@ mod tests {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
let env = analytics_env(Some("phc_baked"), Some("https://eu.i.posthog.com"));
// Without this the backend has no destination and every event is dropped.
// The baked release token must reach the child, where it overrides the
// backend's in-repo default destination (#1193).
assert!(env.contains(&("POSTHOG_PROJECT_TOKEN".into(), "phc_baked".into())));
assert!(env
.contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into())));
}
#[test]
fn a_source_build_passes_no_destination_at_all() {
fn a_source_build_passes_no_destination_but_still_marks_the_channel() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("POSTHOG_HOST");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
// No secret at compile time (anyone building from source), and the empty
// string CI hands over when the secret is simply absent.
assert!(analytics_env(None, None).is_empty());
assert!(analytics_env(Some(""), Some(" ")).is_empty());
// No secret at compile time (anyone building the shell from source), and
// the empty string CI hands over when the secret is simply absent: no
// POSTHOG_* is passed (the backend falls back to its in-repo default,
// #1193) — but running under this shell is still the "installer" channel.
for env in [analytics_env(None, None), analytics_env(Some(""), Some(" "))] {
assert_eq!(
env,
vec![("OMNIVOICE_INSTALL_CHANNEL".to_string(), "installer".to_string())]
);
}
}
#[test]
fn the_process_environment_beats_the_baked_token() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("POSTHOG_PROJECT_TOKEN", "phc_developers_own_project");
std::env::set_var("OMNIVOICE_INSTALL_CHANNEL", "source");
let env = analytics_env(Some("phc_baked"), None);
// Don't override what the caller deliberately set — the child inherits it.
assert!(env.iter().all(|(k, _)| k != "POSTHOG_PROJECT_TOKEN"));
assert!(env.iter().all(|(k, _)| k != "OMNIVOICE_INSTALL_CHANNEL"));
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
}
#[test]
+2 -2
View File
@@ -1374,8 +1374,8 @@ function App() {
<BackendCrashNotice />
{/* One-time analytics consent ask for installs that predate the
first-run consent step. Renders nothing once any choice was made
(or in source builds, which have no analytics destination). */}
first-run consent step. Renders nothing once any choice was made.
Source builds get it too since #1193 (in-repo default token). */}
<AnalyticsConsentBanner />
{/* #567's visible half: while the shell auto-restarts a dead backend
@@ -12,8 +12,9 @@
* - Dismiss (X) treated as No (analytics stays/goes off, `prompted` set);
* never shown again. Dismissal is a choice, not a snooze nagging users
* into consent would be its own kind of dark pattern.
* - Backend unreachable / source build (no token) / already prompted /
* already opted in renders nothing.
* - Backend unreachable / destination-less build / already prompted /
* already opted in renders nothing. (Source builds have a destination
* since #1193, so they get this same one-time ask.)
*/
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -10,8 +10,9 @@
* voice names, and any identity),
* - and that it can be turned off again at any time.
*
* When the build ships no analytics destination (any source build), the toggle
* is not offered at all an inert switch would be a lie. See
* When the build ships no analytics destination (rare since #1193 the in-repo
* default token covers source builds too), the toggle is not offered at all
* an inert switch would be a lie. See
* backend/core/analytics.py for the enforcement (allowlist + no exception
* autocapture) that makes the promises above true rather than aspirational.
*/
+3 -2
View File
@@ -228,8 +228,9 @@ export default function SetupWizard({ onReady }) {
// Whether to insert the analytics consent step. Resolved once at mount
// (the user is on step 0 when this lands, so indices never shift underfoot):
// only when the build CAN send (token baked in) and the user was never
// asked. Source builds have no destination asking would be dishonest.
// only when the build CAN send and the user was never asked. Since #1193
// every build has a destination (in-repo default token), so source builds
// get this same ask; skipping the wizard still means analytics stays off.
const [askConsent, setAskConsent] = useState(false);
useEffect(() => {
let cancelled = false;
+16 -9
View File
@@ -30,15 +30,20 @@
import type { PostHog } from 'posthog-js';
/**
* Supplied at BUILD time, not committed. PostHog client tokens are publishable
* (write-only event ingestion), but a token-shaped literal in the repo trips the
* secret scanner and is a bad habit regardless so the release build injects it,
* exactly as the backend takes POSTHOG_PROJECT_TOKEN from the environment.
*
* No token => no destination => the Privacy toggle isn't even offered and nothing
* can ever be sent. That is the correct default for a source build.
* In-repo default destination (owner-sanctioned reversal, #1193): source builds
* get the SAME consent-gated analytics as installers. This is a PostHog
* *publishable* client key write-only event ingestion, no data access;
* PostHog's own FAQ says these are designed to ship in client code NOT a
* secret. It only names a destination: nothing is ever sent without the user's
* explicit opt-in (init happens only after consent, see enableAnalytics()).
* A build-time VITE_POSTHOG_KEY (release builds; developers pointing at their
* own project) always wins over it mirrors backend/core/analytics.py.
* tests/test_no_committed_analytics_token.py pins that a `phc_` literal may
* live in exactly this file and backend/core/analytics.py.
*/
const POSTHOG_TOKEN: string = (import.meta.env?.VITE_POSTHOG_KEY as string) || '';
const PUBLIC_PROJECT_TOKEN = 'phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9';
const POSTHOG_TOKEN: string =
(import.meta.env?.VITE_POSTHOG_KEY as string) || PUBLIC_PROJECT_TOKEN;
const POSTHOG_HOST: string =
(import.meta.env?.VITE_POSTHOG_HOST as string) || 'https://eu.i.posthog.com';
@@ -73,6 +78,7 @@ const ALLOWED_PROPS = new Set([
'uptime_bucket',
'error_class',
'stage',
'install_channel', // installer | docker | source — closed set, never a path
]);
/** A string longer than this is refused outright, so free text can't ride in on
@@ -151,7 +157,8 @@ export function capture(event: string, props?: Record<string, unknown>): void {
}
/** On app start: turn analytics on ONLY if the backend says the user opted in.
* Anything else backend down, no consent, source build leaves it off. */
* Anything else backend down, no consent, destination-less build leaves it
* off. */
export async function initAnalyticsFromConsent(
fetchState: () => Promise<{ opted_in?: boolean; available?: boolean }>,
): Promise<boolean> {
+24 -5
View File
@@ -38,6 +38,8 @@ def sent(monkeypatch, tmp_path):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False)
monkeypatch.delenv("OMNIVOICE_INSTALL_CHANNEL", raising=False)
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
analytics.shutdown()
analytics._reset_error_events_for_tests()
@@ -88,7 +90,8 @@ def test_installed_fires_once_and_only_once(sent, monkeypatch):
analytics.record_startup_lifecycle(None)
assert _names(sent).count("app_installed") == 1
props = dict(sent[0][1])
assert set(props) <= {"app_version", "platform"}
assert set(props) <= {"app_version", "platform", "install_channel"}
assert props["install_channel"] == "source" # no shell/docker marker in tests
def test_installed_never_fires_without_consent_but_is_not_swallowed(sent, monkeypatch):
@@ -105,8 +108,10 @@ def test_installed_never_fires_without_consent_but_is_not_swallowed(sent, monkey
assert _names(sent).count("app_installed") == 1
def test_installed_never_fires_without_a_token(sent, monkeypatch):
analytics.set_opted_in(True) # source build: consent but no destination
def test_installed_never_fires_without_any_token(sent, monkeypatch):
# No destination at all: env absent AND the in-repo default (#1193) blanked.
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "")
analytics.set_opted_in(True) # consent but no destination
analytics.record_startup_lifecycle(None)
assert sent == []
@@ -334,6 +339,20 @@ def test_uninstall_ping_info_written_when_enabled_and_removed_when_not(
assert not info.exists()
def test_uninstall_ping_info_never_written_for_a_source_build(sent, monkeypatch, tmp_path):
analytics.set_opted_in(True) # no token
def test_uninstall_ping_info_written_for_a_source_build_with_the_default_token(
sent, monkeypatch, tmp_path
):
"""#1193: a consented source build has a destination (the in-repo default),
so the uninstall ping must work there too."""
analytics.set_opted_in(True) # no env token → in-repo default
info = tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME
assert info.exists()
payload = json.loads(info.read_text())
assert payload["token"] == analytics._PUBLIC_PROJECT_TOKEN
assert payload["host"] == "https://eu.i.posthog.com"
def test_uninstall_ping_info_never_written_without_any_token(sent, monkeypatch, tmp_path):
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "") # destination-less build
analytics.set_opted_in(True)
assert not (tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME).exists()
+48 -2
View File
@@ -40,6 +40,8 @@ def _isolate(monkeypatch, tmp_path):
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", raising=False)
monkeypatch.delenv("OMNIVOICE_INSTALL_CHANNEL", raising=False)
monkeypatch.delenv("OMNIVOICE_SERVER_MODE", raising=False)
class _InertPosthog:
def __init__(self, *a, **k):
@@ -70,8 +72,29 @@ def test_off_by_default_even_when_the_build_ships_a_token(monkeypatch):
assert analytics.enabled() is False
def test_opting_in_without_a_token_still_cannot_transmit(monkeypatch):
"""A source build has no destination — the toggle must not pretend otherwise."""
def test_source_builds_have_a_destination_via_the_in_repo_default(monkeypatch):
"""#1193: with no env/baked token, the committed publishable key is the
fallback so source builds get the SAME consent flow as installers. Consent
is still the gate: available enabled."""
assert analytics.token_configured() is True # no env token set by _isolate
assert analytics.enabled() is False # …but silence is still not consent
analytics.set_opted_in(True)
assert analytics.enabled() is True
def test_the_env_token_beats_the_in_repo_default(monkeypatch):
"""Release builds bake a token through the shell env; developers set their
own. Either must override the committed default."""
monkeypatch.setenv("POSTHOG_PROJECT_TOKEN", " phc_env_wins ")
assert analytics._resolved_token() == "phc_env_wins"
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN")
assert analytics._resolved_token() == analytics._PUBLIC_PROJECT_TOKEN
def test_opting_in_without_any_token_still_cannot_transmit(monkeypatch):
"""A build with no destination at all (env absent AND in-repo default
blanked) the toggle must not pretend otherwise."""
monkeypatch.setattr(analytics, "_PUBLIC_PROJECT_TOKEN", "")
analytics.set_opted_in(True)
assert analytics.user_opted_in() is True
assert analytics.token_configured() is False
@@ -281,3 +304,26 @@ def test_installation_id_is_random_not_derived_from_the_machine():
assert socket.gethostname() not in iid
assert os.environ.get("USER", "nope") not in iid
# ── install_channel: closed set, driven by env markers (#1193) ───────────────
def test_install_channel_resolves_installer_docker_then_source(monkeypatch):
assert analytics.install_channel() == "source" # bare source run
monkeypatch.setenv("OMNIVOICE_SERVER_MODE", "1") # the Docker image's marker
assert analytics.install_channel() == "docker"
monkeypatch.setenv("OMNIVOICE_INSTALL_CHANNEL", "installer") # desktop shell marker
assert analytics.install_channel() == "installer"
# A value outside the closed set falls through to the other markers.
monkeypatch.setenv("OMNIVOICE_INSTALL_CHANNEL", "franken-build")
monkeypatch.delenv("OMNIVOICE_SERVER_MODE")
assert analytics.install_channel() == "source"
def test_install_channel_rides_wherever_app_version_does(monkeypatch):
"""Attached via _common_props (the same place app_version is), and
allowlisted so the sanitizer doesn't strip it."""
props = analytics._common_props()
assert props["install_channel"] == "source"
assert analytics.sanitize_properties(props)["install_channel"] == "source"
+64 -33
View File
@@ -1,14 +1,20 @@
"""No analytics token may be committed to the repo.
"""The analytics token lives in exactly two canonical files — nowhere else.
gitleaks caught a hardcoded PostHog key in frontend/src/utils/analytics.ts once.
PostHog client tokens are *publishable* (write-only event ingestion, not data
access), so it was never a credential leak but a token-shaped literal in the
source is a bad habit, and this project already bans them. The destination is
supplied at BUILD time instead (VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN).
History: gitleaks once caught a hardcoded PostHog key in analytics.ts, and the
repo banned token-shaped literals outright (build-time injection only). Owner
reversal #1193: source builds now ship the same consent-gated analytics as
installers, so the *publishable* project token (write-only event ingestion, not
data access PostHog's client keys are designed to ship in client code) is
committed as an in-repo default in the two files that implement analytics:
This guard makes the rule ours rather than relying on the scanner to catch it,
and it fails BEFORE a push rather than after. Same file-scanning idiom as
test_no_hardcoded_cjk / test_no_literal_borders.
backend/core/analytics.py (_PUBLIC_PROJECT_TOKEN)
frontend/src/utils/analytics.ts (PUBLIC_PROJECT_TOKEN)
This guard pins the new contract: a `phc_` literal may exist ONLY there, both
files must actually carry one, and the two must be the SAME token (one PostHog
project a drifted pair would silently split the event stream). The build-time
override chain (env / baked secret beats the in-repo default) stays pinned
below. Same file-scanning idiom as test_no_hardcoded_cjk / test_no_literal_borders.
"""
from __future__ import annotations
@@ -19,9 +25,15 @@ from pathlib import Path
_REPO = Path(__file__).resolve().parents[1]
# A PostHog project key. Deliberately matched by SHAPE, not by the specific value,
# so a *different* key can't slip through.
# so a key can't slip into a *different* file unnoticed.
_POSTHOG_KEY_RE = re.compile(r"phc_[A-Za-z0-9]{20,}")
# The ONLY tracked files allowed to contain a `phc_` literal (#1193).
_CANONICAL_TOKEN_FILES = (
"backend/core/analytics.py",
"frontend/src/utils/analytics.ts",
)
_SKIP_DIRS = {"node_modules", ".git", "target", "dist", "build", ".venv", "zig-out"}
_SCAN_EXT = {".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".json", ".yml", ".yaml", ".md", ".env"}
@@ -38,12 +50,14 @@ def _tracked_files():
yield name, _REPO / p
def test_no_posthog_token_is_committed():
def test_posthog_token_literals_only_in_the_two_canonical_files():
offenders = []
for name, path in _tracked_files():
# This guard describes the pattern it forbids, so exempt itself.
if name == "tests/test_no_committed_analytics_token.py":
continue
if name in _CANONICAL_TOKEN_FILES:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
@@ -52,43 +66,60 @@ def test_no_posthog_token_is_committed():
offenders.append(name)
assert not offenders, (
"A PostHog token literal is committed in: "
"A PostHog token literal is committed outside the canonical files ("
+ ", ".join(_CANONICAL_TOKEN_FILES)
+ "): "
+ ", ".join(offenders)
+ ". Supply it at build time instead (VITE_POSTHOG_KEY for the frontend, "
"POSTHOG_PROJECT_TOKEN for the backend) — see frontend/src/utils/analytics.ts."
+ ". The in-repo default lives ONLY there (#1193); everything else takes "
"the token via VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN at build/run time."
)
def test_the_frontend_reads_its_token_from_the_build_env():
"""The mechanism that replaces the literal must stay in place."""
def test_both_canonical_files_carry_the_same_default_token():
"""#1193's whole point: source builds have a destination. Both halves must
ship the in-repo default, and it must be ONE token a mismatched pair would
split installs across PostHog projects with no error anywhere."""
tokens = {}
for name in _CANONICAL_TOKEN_FILES:
found = _POSTHOG_KEY_RE.findall((_REPO / name).read_text(encoding="utf-8"))
assert found, f"{name} no longer carries the in-repo default token (#1193)"
assert len(set(found)) == 1, f"{name} contains multiple distinct phc_ literals"
tokens[name] = found[0]
assert len(set(tokens.values())) == 1, f"canonical token files disagree: {tokens}"
# ── the OVERRIDE chain must stay wired ───────────────────────────────────────
#
# The in-repo default is the fallback; release builds override it with the repo
# secret, and a developer's process env overrides everything:
#
# repo secret -> release.yml -> tauri-action -> option_env! in backend.rs
# -> spawned backend process env -> analytics._resolved_token()
#
# Every link is invisible when it breaks (events just land in the default
# project instead of the release one). These pin the links that live in files a
# future change could quietly drop.
def test_the_frontend_reads_its_token_override_from_the_build_env():
"""The build-time override mechanism must stay in place."""
src = (_REPO / "frontend/src/utils/analytics.ts").read_text(encoding="utf-8")
assert "VITE_POSTHOG_KEY" in src
# ── the token has to actually REACH both halves ──────────────────────────────
#
# The backend reads POSTHOG_PROJECT_TOKEN from its own environment at runtime,
# on the user's machine, where nothing sets it. So the chain
#
# repo secret -> release.yml -> tauri-action -> option_env! in backend.rs
# -> spawned backend process env -> analytics.token_configured()
#
# has to hold end to end, and every link is invisible when it breaks: analytics
# just silently never fires. These pin the two links that live in files a future
# change could quietly drop.
def test_release_workflow_still_passes_the_secret_to_the_build():
wf = (_REPO / ".github/workflows/release.yml").read_text(encoding="utf-8")
assert "VITE_POSTHOG_KEY" in wf, "the build no longer receives the analytics token"
assert "secrets.POSTHOG_PROJECT_TOKEN" in wf, "the token must come from the repo secret"
assert "VITE_POSTHOG_KEY" in wf, "the build no longer receives the analytics token override"
assert "secrets.POSTHOG_PROJECT_TOKEN" in wf, "the override must come from the repo secret"
def test_the_shell_hands_the_token_to_the_backend_it_spawns():
"""Without this the backend's analytics is dead code in every shipped build."""
"""Without this a baked release token could never beat the in-repo default."""
src = (_REPO / "frontend/src-tauri/src/backend.rs").read_text(encoding="utf-8")
assert 'option_env!("VITE_POSTHOG_KEY")' in src, "the shell no longer bakes in the token"
assert "POSTHOG_PROJECT_TOKEN" in src, "the backend process is no longer given a destination"
assert "POSTHOG_PROJECT_TOKEN" in src, "the backend process is no longer given the override"
# #1193: the shell marks everything it spawns as the "installer" channel.
assert "OMNIVOICE_INSTALL_CHANNEL" in src, "the shell no longer stamps the install channel"
# option_env! is resolved at COMPILE time, so cargo must rebuild when the
# secret changes — otherwise a cached build keeps the token it first saw.