From 3cb55fe752691df5f66960d2f984969112eae1df Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 25 Mar 2026 16:22:51 +0100 Subject: [PATCH] docs: add telemetry section to ARCHITECTURE.md and ABSTRACTIONS.md Also adds Playwright smoke test for consent dialog and mock handler for reinit_telemetry command. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ABSTRACTIONS.md | 16 +++++ docs/ARCHITECTURE.md | 41 +++++++++++++ src/mock-tauri/mock-handlers.ts | 1 + tests/smoke/telemetry-consent.spec.ts | 88 +++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 tests/smoke/telemetry-consent.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index f3ff3fe4..ed0a49c8 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -589,3 +589,19 @@ interface Settings { ``` Managed by `useSettings` hook and `SettingsPanel` component. + +## Telemetry + +### Components +- **`TelemetryConsentDialog`** — First-launch dialog asking user to opt in to anonymous crash reporting. Two buttons: accept (sets `telemetry_consent: true`, generates `anonymous_id`) or decline. +- **`TelemetryToggle`** — Checkbox component in `SettingsPanel` for crash reporting and analytics toggles. + +### Hooks +- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`. + +### Libraries +- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars. +- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle. + +### Tauri Commands +- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ee48d19c..30374c0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -808,3 +808,44 @@ App startup (3s delay) → ready → "Restart to apply" + Restart Now → network error → fail silently ``` + +### Telemetry (Opt-in) + +Anonymous crash reporting (Sentry) and usage analytics (PostHog), both **opt-in only**. + +```mermaid +sequenceDiagram + participant User + participant App + participant Settings + participant Sentry + participant PostHog + + Note over App: First launch or upgrade + App->>User: TelemetryConsentDialog + alt Accept + User->>Settings: telemetry_consent=true, anonymous_id=UUID + Settings->>Sentry: init(DSN, anonymous_id) + Settings->>PostHog: init(key, anonymous_id) + else Decline + User->>Settings: telemetry_consent=false + Note over Sentry,PostHog: Zero network requests + end + + Note over App: Settings panel toggle change + User->>Settings: crash_reporting_enabled=false + Settings->>Sentry: teardown() + Settings->>App: reinit_telemetry (Tauri cmd) +``` + +**Privacy guarantees:** +- No vault content, note titles, or file paths in payloads (regex scrubber in `beforeSend`) +- `anonymous_id` is a locally-generated UUID, never tied to identity +- `send_default_pii: false` on both SDKs +- PostHog: `autocapture: false`, `persistence: 'memory'`, no cookies + +**Architecture:** +- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()` +- **JS:** `@sentry/browser` + `posthog-js` initialized lazily by `useTelemetry` hook +- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct +- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null` diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index ea360365..61098d1d 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -270,6 +270,7 @@ export const mockHandlers: Record any> = { register_mcp_tools: () => 'registered', check_mcp_status: () => 'installed', repair_vault: (): string => 'Vault repaired', + reinit_telemetry: (): null => null, } export function addMockEntry(_entry: VaultEntry, content: string): void { diff --git a/tests/smoke/telemetry-consent.spec.ts b/tests/smoke/telemetry-consent.spec.ts new file mode 100644 index 00000000..c1a58871 --- /dev/null +++ b/tests/smoke/telemetry-consent.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from '@playwright/test' + +test.describe('Telemetry consent dialog', () => { + test('shows consent dialog when telemetry_consent is null', async ({ page }) => { + // Override get_settings to return null telemetry_consent + await page.goto('/') + await page.evaluate(() => { + window.__mockHandlers!.get_settings = () => ({ + anthropic_key: null, + openai_key: null, + google_key: null, + github_token: null, + github_username: null, + auto_pull_interval_minutes: null, + telemetry_consent: null, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + }) + }) + await page.reload() + await page.waitForLoadState('networkidle') + + // Consent dialog should be visible + await expect(page.getByText('Help improve Laputa')).toBeVisible({ timeout: 10000 }) + await expect(page.getByTestId('telemetry-accept')).toBeVisible() + await expect(page.getByTestId('telemetry-decline')).toBeVisible() + }) + + test('clicking No thanks dismisses dialog and sets consent to false', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + window.__mockHandlers!.get_settings = () => ({ + anthropic_key: null, + openai_key: null, + google_key: null, + github_token: null, + github_username: null, + auto_pull_interval_minutes: null, + telemetry_consent: null, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + }) + }) + await page.reload() + await page.waitForLoadState('networkidle') + + await page.getByTestId('telemetry-decline').click() + + // Dialog should disappear and main app shell should be visible + await expect(page.getByText('Help improve Laputa')).not.toBeVisible({ timeout: 5000 }) + }) + + test('clicking Allow dismisses dialog and sets consent to true', async ({ page }) => { + await page.goto('/') + await page.evaluate(() => { + window.__mockHandlers!.get_settings = () => ({ + anthropic_key: null, + openai_key: null, + google_key: null, + github_token: null, + github_username: null, + auto_pull_interval_minutes: null, + telemetry_consent: null, + crash_reporting_enabled: null, + analytics_enabled: null, + anonymous_id: null, + }) + }) + await page.reload() + await page.waitForLoadState('networkidle') + + await page.getByTestId('telemetry-accept').click() + + // Dialog should disappear and main app shell should be visible + await expect(page.getByText('Help improve Laputa')).not.toBeVisible({ timeout: 5000 }) + }) + + test('dialog does not appear when consent was already given', async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + + // Default mock settings already have telemetry_consent: true from App.test setup + // The consent dialog should NOT be visible + await expect(page.getByText('Help improve Laputa')).not.toBeVisible({ timeout: 3000 }) + }) +})