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) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-25 16:22:51 +01:00
parent 91854f8bae
commit 3cb55fe752
4 changed files with 146 additions and 0 deletions

View File

@@ -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.

View File

@@ -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`

View File

@@ -270,6 +270,7 @@ export const mockHandlers: Record<string, (args: any) => 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 {

View File

@@ -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 })
})
})