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:
co-authored by
Claude Fable 5
parent
367ed1e8e0
commit
23f1767e3c
+2
-1
@@ -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)
|
- 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)
|
- `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 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
|
- 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)
|
- 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)
|
- `OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170)
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 -->
|
<!-- 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:
|
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.
|
- **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 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.
|
- **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.
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
|
|||||||
<br/>
|
<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.
|
<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>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -1020,8 +1020,9 @@ def get_analytics():
|
|||||||
return {
|
return {
|
||||||
"enabled": analytics.enabled(),
|
"enabled": analytics.enabled(),
|
||||||
"opted_in": analytics.user_opted_in(),
|
"opted_in": analytics.user_opted_in(),
|
||||||
# False for source builds / any build with no token: analytics can never
|
# True for source builds too since #1193 (in-repo default token; env/baked
|
||||||
# run, so the UI can say so instead of offering a toggle that does nothing.
|
# 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(),
|
"available": analytics.token_configured(),
|
||||||
# Whether the user has ever been explicitly asked (first-run consent step
|
# 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
|
# or the one-time banner). The UI uses this to ask exactly once — it never
|
||||||
|
|||||||
+60
-13
@@ -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:
|
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:
|
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
|
a configured destination token (the in-repo publishable default, overridden
|
||||||
``analytics_enabled`` preference, which defaults to **False**. A default
|
by ``POSTHOG_PROJECT_TOKEN`` when set — see ``_PUBLIC_PROJECT_TOKEN``) *and*
|
||||||
install transmits nothing, so the product's promise holds out of the box.
|
the user's explicit ``analytics_enabled`` preference, which defaults to
|
||||||
``OMNIVOICE_ANALYTICS_DISABLED=1`` is a hard kill switch that outranks both.
|
**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
|
2. **No exception autocapture, ever.** The obvious SDK default
|
||||||
(``enable_exception_autocapture=True``) ships raw tracebacks — which carry
|
(``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"
|
_KILL_SWITCH = "OMNIVOICE_ANALYTICS_DISABLED"
|
||||||
_OFF_VALUES = {"1", "true", "yes", "on"}
|
_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.
|
#: 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
|
#: 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
|
#: 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
|
"uptime_bucket", # app_crashed: BUCKETED prior-run uptime, never raw seconds
|
||||||
"error_class", # error_occurred/app_crashed: locked taxonomy key (GPU_OOM, …)
|
"error_class", # error_occurred/app_crashed: locked taxonomy key (GPU_OOM, …)
|
||||||
"stage", # error_occurred: coarse pipeline stage / route head only
|
"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
|
#: A string property longer than this is refused outright — a belt-and-braces
|
||||||
@@ -130,11 +146,23 @@ def user_prompted() -> bool:
|
|||||||
return False
|
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:
|
def token_configured() -> bool:
|
||||||
"""Whether this BUILD ships an analytics destination at all. When false,
|
"""Whether this build has an analytics destination at all. Since #1193 the
|
||||||
analytics can never run no matter what the user chooses — which is the case
|
in-repo default means source builds have one too — so they get the same
|
||||||
for anyone building from source."""
|
first-run consent ask as installers. False only when both the env var and
|
||||||
return bool((os.environ.get("POSTHOG_PROJECT_TOKEN", "") or "").strip())
|
the committed default are blank; consent stays the real gate regardless."""
|
||||||
|
return bool(_resolved_token())
|
||||||
|
|
||||||
|
|
||||||
def enabled() -> bool:
|
def enabled() -> bool:
|
||||||
@@ -152,8 +180,8 @@ def _get_client():
|
|||||||
shutdown()
|
shutdown()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
token = os.environ["POSTHOG_PROJECT_TOKEN"].strip()
|
token = _resolved_token()
|
||||||
host = (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip()
|
host = _resolved_host()
|
||||||
key = f"{token}@{host}"
|
key = f"{token}@{host}"
|
||||||
if _client is not None and _client_key == key:
|
if _client is not None and _client_key == key:
|
||||||
return _client
|
return _client
|
||||||
@@ -274,8 +302,27 @@ def _platform() -> str:
|
|||||||
return {"darwin": "macos"}.get(_pl.system().lower(), _pl.system().lower() or "unknown")
|
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:
|
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:
|
def uptime_bucket(seconds: Optional[float]) -> str:
|
||||||
@@ -414,8 +461,8 @@ def sync_uninstall_ping_info() -> None:
|
|||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
payload = {
|
payload = {
|
||||||
"token": os.environ["POSTHOG_PROJECT_TOKEN"].strip(),
|
"token": _resolved_token(),
|
||||||
"host": (os.environ.get("POSTHOG_HOST") or "https://eu.i.posthog.com").strip(),
|
"host": _resolved_host(),
|
||||||
"distinct_id": installation_id(),
|
"distinct_id": installation_id(),
|
||||||
"app_version": _app_version(),
|
"app_version": _app_version(),
|
||||||
"platform": _platform(),
|
"platform": _platform(),
|
||||||
|
|||||||
@@ -281,27 +281,33 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
|
|||||||
|
|
||||||
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
|
// ── 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
|
/// `core/analytics.py` ships an in-repo publishable default token (#1193), and
|
||||||
/// RUNTIME — but the backend runs on the *user's* machine, where nothing sets it.
|
/// reads `POSTHOG_PROJECT_TOKEN` from its environment as the OVERRIDE. release.yml
|
||||||
/// Without this, `token_configured()` is false forever and every backend event is
|
/// passes the `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as
|
||||||
/// dead code in every shipped build, no matter what secret CI holds.
|
/// `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
|
||||||
/// The token is really a *build* input. release.yml passes the
|
/// it to the child process here so a baked release token wins over the in-repo
|
||||||
/// `POSTHOG_PROJECT_TOKEN` secret to the tauri-action step as `VITE_POSTHOG_KEY`,
|
/// default.
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// Two properties this preserves, both load-bearing:
|
/// Two properties this preserves, both load-bearing:
|
||||||
/// * **No token baked in (every source build) => nothing is passed** => the
|
/// * **Since #1193 there is always a destination**: `core/analytics.py` now
|
||||||
/// backend has no destination and analytics can never run. Correct default.
|
/// carries the in-repo publishable default token, so even when nothing is
|
||||||
/// * **A real process env var wins**, so a developer can point a local run at
|
/// baked in here (a source-built shell) the backend can run its
|
||||||
/// their own PostHog project without recompiling.
|
/// 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
|
/// It also stamps `OMNIVOICE_INSTALL_CHANNEL=installer` (#1193): anyone running
|
||||||
/// checks in prefs (default off) — a token alone never causes a single event.
|
/// 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)> {
|
fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(String, String)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut pass = |name: &str, baked: Option<&str>| {
|
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_PROJECT_TOKEN", baked_token);
|
||||||
pass("POSTHOG_HOST", baked_host);
|
pass("POSTHOG_HOST", baked_host);
|
||||||
|
pass("OMNIVOICE_INSTALL_CHANNEL", Some("installer"));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,8 +507,10 @@ mod tests {
|
|||||||
|
|
||||||
// #1123 shipped backend analytics that could never run: core/analytics.py reads
|
// #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
|
// 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
|
// machine ever set it. These pin the wiring that fixes it. Since #1193 the
|
||||||
// importantly, pin that a build with no token stays silent.
|
// 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
|
/// The env-var tests below mutate process-global state; keep them off each
|
||||||
/// other's toes (cargo runs tests in threads by default).
|
/// 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());
|
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
||||||
std::env::remove_var("POSTHOG_HOST");
|
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"));
|
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_PROJECT_TOKEN".into(), "phc_baked".into())));
|
||||||
assert!(env
|
assert!(env
|
||||||
.contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into())));
|
.contains(&("POSTHOG_HOST".into(), "https://eu.i.posthog.com".into())));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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());
|
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
||||||
std::env::remove_var("POSTHOG_HOST");
|
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
|
// No secret at compile time (anyone building the shell from source), and
|
||||||
// string CI hands over when the secret is simply absent.
|
// the empty string CI hands over when the secret is simply absent: no
|
||||||
assert!(analytics_env(None, None).is_empty());
|
// POSTHOG_* is passed (the backend falls back to its in-repo default,
|
||||||
assert!(analytics_env(Some(""), Some(" ")).is_empty());
|
// #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]
|
#[test]
|
||||||
fn the_process_environment_beats_the_baked_token() {
|
fn the_process_environment_beats_the_baked_token() {
|
||||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
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("POSTHOG_PROJECT_TOKEN", "phc_developers_own_project");
|
||||||
|
std::env::set_var("OMNIVOICE_INSTALL_CHANNEL", "source");
|
||||||
let env = analytics_env(Some("phc_baked"), None);
|
let env = analytics_env(Some("phc_baked"), None);
|
||||||
// Don't override what the caller deliberately set — the child inherits it.
|
// 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 != "POSTHOG_PROJECT_TOKEN"));
|
||||||
|
assert!(env.iter().all(|(k, _)| k != "OMNIVOICE_INSTALL_CHANNEL"));
|
||||||
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
std::env::remove_var("POSTHOG_PROJECT_TOKEN");
|
||||||
|
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1374,8 +1374,8 @@ function App() {
|
|||||||
<BackendCrashNotice />
|
<BackendCrashNotice />
|
||||||
|
|
||||||
{/* One-time analytics consent ask for installs that predate the
|
{/* One-time analytics consent ask for installs that predate the
|
||||||
first-run consent step. Renders nothing once any choice was made
|
first-run consent step. Renders nothing once any choice was made.
|
||||||
(or in source builds, which have no analytics destination). */}
|
Source builds get it too since #1193 (in-repo default token). */}
|
||||||
<AnalyticsConsentBanner />
|
<AnalyticsConsentBanner />
|
||||||
|
|
||||||
{/* #567's visible half: while the shell auto-restarts a dead backend
|
{/* #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);
|
* - Dismiss (X) → treated as No (analytics stays/goes off, `prompted` set);
|
||||||
* never shown again. Dismissal is a choice, not a snooze — nagging users
|
* never shown again. Dismissal is a choice, not a snooze — nagging users
|
||||||
* into consent would be its own kind of dark pattern.
|
* into consent would be its own kind of dark pattern.
|
||||||
* - Backend unreachable / source build (no token) / already prompted /
|
* - Backend unreachable / destination-less build / already prompted /
|
||||||
* already opted in → renders nothing.
|
* 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 { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
* voice names, and any identity),
|
* voice names, and any identity),
|
||||||
* - and that it can be turned off again at any time.
|
* - and that it can be turned off again at any time.
|
||||||
*
|
*
|
||||||
* When the build ships no analytics destination (any source build), the toggle
|
* When the build ships no analytics destination (rare since #1193 — the in-repo
|
||||||
* is not offered at all — an inert switch would be a lie. See
|
* 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
|
* backend/core/analytics.py for the enforcement (allowlist + no exception
|
||||||
* autocapture) that makes the promises above true rather than aspirational.
|
* autocapture) that makes the promises above true rather than aspirational.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -228,8 +228,9 @@ export default function SetupWizard({ onReady }) {
|
|||||||
|
|
||||||
// Whether to insert the analytics consent step. Resolved once at mount
|
// 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):
|
// (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
|
// only when the build CAN send and the user was never asked. Since #1193
|
||||||
// asked. Source builds have no destination — asking would be dishonest.
|
// 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);
|
const [askConsent, setAskConsent] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -30,15 +30,20 @@
|
|||||||
import type { PostHog } from 'posthog-js';
|
import type { PostHog } from 'posthog-js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supplied at BUILD time, not committed. PostHog client tokens are publishable
|
* In-repo default destination (owner-sanctioned reversal, #1193): source builds
|
||||||
* (write-only event ingestion), but a token-shaped literal in the repo trips the
|
* get the SAME consent-gated analytics as installers. This is a PostHog
|
||||||
* secret scanner and is a bad habit regardless — so the release build injects it,
|
* *publishable* client key — write-only event ingestion, no data access;
|
||||||
* exactly as the backend takes POSTHOG_PROJECT_TOKEN from the environment.
|
* 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
|
||||||
* No token => no destination => the Privacy toggle isn't even offered and nothing
|
* explicit opt-in (init happens only after consent, see enableAnalytics()).
|
||||||
* can ever be sent. That is the correct default for a source build.
|
* 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 =
|
const POSTHOG_HOST: string =
|
||||||
(import.meta.env?.VITE_POSTHOG_HOST as string) || 'https://eu.i.posthog.com';
|
(import.meta.env?.VITE_POSTHOG_HOST as string) || 'https://eu.i.posthog.com';
|
||||||
|
|
||||||
@@ -73,6 +78,7 @@ const ALLOWED_PROPS = new Set([
|
|||||||
'uptime_bucket',
|
'uptime_bucket',
|
||||||
'error_class',
|
'error_class',
|
||||||
'stage',
|
'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
|
/** 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.
|
/** 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(
|
export async function initAnalyticsFromConsent(
|
||||||
fetchState: () => Promise<{ opted_in?: boolean; available?: boolean }>,
|
fetchState: () => Promise<{ opted_in?: boolean; available?: boolean }>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def sent(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
|
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
|
||||||
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
|
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
|
||||||
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", 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.shutdown()
|
||||||
analytics._reset_error_events_for_tests()
|
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)
|
analytics.record_startup_lifecycle(None)
|
||||||
assert _names(sent).count("app_installed") == 1
|
assert _names(sent).count("app_installed") == 1
|
||||||
props = dict(sent[0][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):
|
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
|
assert _names(sent).count("app_installed") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_installed_never_fires_without_a_token(sent, monkeypatch):
|
def test_installed_never_fires_without_any_token(sent, monkeypatch):
|
||||||
analytics.set_opted_in(True) # source build: consent but no destination
|
# 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)
|
analytics.record_startup_lifecycle(None)
|
||||||
assert sent == []
|
assert sent == []
|
||||||
|
|
||||||
@@ -334,6 +339,20 @@ def test_uninstall_ping_info_written_when_enabled_and_removed_when_not(
|
|||||||
assert not info.exists()
|
assert not info.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_uninstall_ping_info_never_written_for_a_source_build(sent, monkeypatch, tmp_path):
|
def test_uninstall_ping_info_written_for_a_source_build_with_the_default_token(
|
||||||
analytics.set_opted_in(True) # no 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()
|
assert not (tmp_path / analytics.UNINSTALL_PING_INFO_BASENAME).exists()
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ def _isolate(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
|
monkeypatch.setattr(config, "DATA_DIR", str(tmp_path))
|
||||||
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
|
monkeypatch.delenv("POSTHOG_PROJECT_TOKEN", raising=False)
|
||||||
monkeypatch.delenv("OMNIVOICE_ANALYTICS_DISABLED", 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:
|
class _InertPosthog:
|
||||||
def __init__(self, *a, **k):
|
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
|
assert analytics.enabled() is False
|
||||||
|
|
||||||
|
|
||||||
def test_opting_in_without_a_token_still_cannot_transmit(monkeypatch):
|
def test_source_builds_have_a_destination_via_the_in_repo_default(monkeypatch):
|
||||||
"""A source build has no destination — the toggle must not pretend otherwise."""
|
"""#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)
|
analytics.set_opted_in(True)
|
||||||
assert analytics.user_opted_in() is True
|
assert analytics.user_opted_in() is True
|
||||||
assert analytics.token_configured() is False
|
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 socket.gethostname() not in iid
|
||||||
assert os.environ.get("USER", "nope") 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"
|
||||||
|
|||||||
@@ -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.
|
History: gitleaks once caught a hardcoded PostHog key in analytics.ts, and the
|
||||||
PostHog client tokens are *publishable* (write-only event ingestion, not data
|
repo banned token-shaped literals outright (build-time injection only). Owner
|
||||||
access), so it was never a credential leak — but a token-shaped literal in the
|
reversal #1193: source builds now ship the same consent-gated analytics as
|
||||||
source is a bad habit, and this project already bans them. The destination is
|
installers, so the *publishable* project token (write-only event ingestion, not
|
||||||
supplied at BUILD time instead (VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN).
|
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,
|
backend/core/analytics.py (_PUBLIC_PROJECT_TOKEN)
|
||||||
and it fails BEFORE a push rather than after. Same file-scanning idiom as
|
frontend/src/utils/analytics.ts (PUBLIC_PROJECT_TOKEN)
|
||||||
test_no_hardcoded_cjk / test_no_literal_borders.
|
|
||||||
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,9 +25,15 @@ from pathlib import Path
|
|||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
# A PostHog project key. Deliberately matched by SHAPE, not by the specific value,
|
# 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,}")
|
_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"}
|
_SKIP_DIRS = {"node_modules", ".git", "target", "dist", "build", ".venv", "zig-out"}
|
||||||
_SCAN_EXT = {".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".json", ".yml", ".yaml", ".md", ".env"}
|
_SCAN_EXT = {".ts", ".tsx", ".js", ".jsx", ".py", ".rs", ".json", ".yml", ".yaml", ".md", ".env"}
|
||||||
|
|
||||||
@@ -38,12 +50,14 @@ def _tracked_files():
|
|||||||
yield name, _REPO / p
|
yield name, _REPO / p
|
||||||
|
|
||||||
|
|
||||||
def test_no_posthog_token_is_committed():
|
def test_posthog_token_literals_only_in_the_two_canonical_files():
|
||||||
offenders = []
|
offenders = []
|
||||||
for name, path in _tracked_files():
|
for name, path in _tracked_files():
|
||||||
# This guard describes the pattern it forbids, so exempt itself.
|
# This guard describes the pattern it forbids, so exempt itself.
|
||||||
if name == "tests/test_no_committed_analytics_token.py":
|
if name == "tests/test_no_committed_analytics_token.py":
|
||||||
continue
|
continue
|
||||||
|
if name in _CANONICAL_TOKEN_FILES:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -52,43 +66,60 @@ def test_no_posthog_token_is_committed():
|
|||||||
offenders.append(name)
|
offenders.append(name)
|
||||||
|
|
||||||
assert not offenders, (
|
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)
|
+ ", ".join(offenders)
|
||||||
+ ". Supply it at build time instead (VITE_POSTHOG_KEY for the frontend, "
|
+ ". The in-repo default lives ONLY there (#1193); everything else takes "
|
||||||
"POSTHOG_PROJECT_TOKEN for the backend) — see frontend/src/utils/analytics.ts."
|
"the token via VITE_POSTHOG_KEY / POSTHOG_PROJECT_TOKEN at build/run time."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_frontend_reads_its_token_from_the_build_env():
|
def test_both_canonical_files_carry_the_same_default_token():
|
||||||
"""The mechanism that replaces the literal must stay in place."""
|
"""#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")
|
src = (_REPO / "frontend/src/utils/analytics.ts").read_text(encoding="utf-8")
|
||||||
assert "VITE_POSTHOG_KEY" in src
|
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():
|
def test_release_workflow_still_passes_the_secret_to_the_build():
|
||||||
wf = (_REPO / ".github/workflows/release.yml").read_text(encoding="utf-8")
|
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 "VITE_POSTHOG_KEY" in wf, "the build no longer receives the analytics token override"
|
||||||
assert "secrets.POSTHOG_PROJECT_TOKEN" in wf, "the token must come from the repo secret"
|
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():
|
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")
|
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 '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
|
# 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.
|
# secret changes — otherwise a cached build keeps the token it first saw.
|
||||||
|
|||||||
Reference in New Issue
Block a user