From 19ec41296f710de7d7321d547b80501acc54cd89 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 18 Aug 2026 20:47:50 +1000 Subject: [PATCH 1/7] feat(telemetry): add opt-in OpenTelemetry pipeline and core Berd events Adds an opt-in telemetry stack that reports a small, hand-authored catalog of product events over OTLP logs, gated behind a consent setting that is off until the user turns it on. - Renderer client batches and exports events, enforces consent at emit time, aborts in-flight exports when consent is revoked, counts events suppressed while consent is unsettled, and flushes when a window hides or closes. - Native Tauri commands own consent persistence and enforcement, gateway token exchange (clamping the reported TTL, keeping a sibling's fresh token on 401 invalidation), and OTLP endpoint validation that rejects userinfo and explicit ports. - Wire schema `berd-otlp-logs-v1` tags events with distribution.channel and carries no user_id, agent_id, project_id, or item_id. An inert distribution-sink seam sits on the emit path. - Events cover the app, chat, agent, project, and home surfaces, with telemetry anchors contained so a throw never breaks the caller. - Staging builds point at the live staging gateway and production builds at otel.berd.xyz; the settings consent row hides without the telemetry capability. - `just dev` prints every fired event to the terminal and the dev event viewer renders those lines grey. Enforced-telemetry builds need two switches to move together: `VITE_TELEMETRY_ENFORCED=1` for the renderer and `--features block-telemetry-enforced` for the native side. All five build paths now set both halves, so none can produce a build that hides the consent toggle and then rejects every export as "Telemetry is disabled for this installation". `_bundle-unix` and `_bundle-debug-unix` call `scripts/block-feature-gates.sh` instead of hand-rolling their Cargo feature list, `Get-BerdAppFeatures` carries the gate for every Windows lane, and `build_linux_docker.sh` forwards the flag across the container boundary. Windows cannot call the bash mapper (no guaranteed bash in the release image), so the release-script tests derive the canonical gate list from that mapper and pin the PowerShell table, `build-macos.sh`, and the Docker forwarding list against it. Also pins the OTel packages in lockstep at 0.221.0/2.10.0 and sherpa-onnx at 1.12.40, and runs the app crate's telemetry tests in the tauri-test gate. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- .github/workflows/release.yml | 1 + justfile | 35 +- package.json | 7 + pnpm-lock.yaml | 79 +- scripts/block-feature-gates.sh | 5 +- scripts/build_linux_docker.sh | 1 + scripts/release/build-macos.sh | 15 +- .../release/tests/release-scripts.test.mjs | 101 +- scripts/windows/Test-WindowsDev.ps1 | 6 +- scripts/windows/WindowsDev.psm1 | 6 +- src-tauri/Cargo.lock | 9 +- src-tauri/Cargo.toml | 21 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/renderer.rs | 135 +- src-tauri/src/commands/telemetry.rs | 1795 +++++++++++++++++ src-tauri/src/lib.rs | 13 +- src-tauri/src/services/distro_bundle.rs | 118 +- src-tauri/tauri.conf.json | 2 +- src/app/AppShell.tsx | 13 +- src/env.d.ts | 1 + .../__tests__/AgentBuilderCapability.test.tsx | 173 ++ .../hooks/__tests__/usePersonaSource.test.tsx | 132 ++ src/features/agents/hooks/usePersonaSource.ts | 28 +- src/features/agents/lib/agentTelemetry.ts | 58 + src/features/agents/ui/AgentBuilderRail.tsx | 31 + src/features/agents/ui/AgentsView.tsx | 34 +- .../ui/__tests__/AgentBuilderRail.test.tsx | 155 ++ .../ui/__tests__/AgentsView.entry.test.tsx | 261 +++ .../berdctl/commands/impl/createAgent.ts | 4 + .../berdctl/commands/impl/createProject.ts | 5 + .../commands/impl/setProjectStartupMode.ts | 7 + ...seChatSessionController.compaction.test.ts | 28 +- .../useChatSessionController.test.ts | 539 ++++- .../chat/hooks/useChatSessionController.ts | 180 +- .../chat/lib/__tests__/steerCore.test.ts | 88 + .../chat/lib/chatFirstMessage.test.ts | 138 ++ src/features/chat/lib/chatFirstMessage.ts | 78 + src/features/chat/lib/chatTelemetry.ts | 96 + .../chat/lib/queuedSessionSend.test.ts | 370 ++++ src/features/chat/lib/queuedSessionSend.ts | 58 +- src/features/chat/lib/steerCore.ts | 48 +- src/features/chat/types.ts | 10 + src/features/feedback/FeedbackDialog.test.tsx | 21 - .../feedback/submitFeedbackReport.test.ts | 8 +- src/features/feedback/submitFeedbackReport.ts | 5 +- .../home/hooks/usePinToHomeWidget.test.tsx | 331 +++ src/features/home/hooks/usePinToHomeWidget.ts | 71 +- src/features/home/lib/chatPinIdentity.test.ts | 83 + src/features/home/lib/chatPinIdentity.ts | 68 + src/features/home/lib/homePinTargets.ts | 101 + .../home/lib/homePinTelemetry.test.ts | 419 ++++ src/features/home/lib/homePinTelemetry.ts | 238 +++ src/features/home/lib/homeTelemetry.ts | 58 + src/features/home/stores/homeWidgetStore.ts | 4 + src/features/home/ui/HomeView.test.tsx | 186 +- src/features/home/ui/HomeView.tsx | 62 +- .../onboarding/ui/OnboardingFlow.test.tsx | 162 ++ src/features/onboarding/ui/OnboardingFlow.tsx | 7 + src/features/projects/lib/projectTelemetry.ts | 84 + .../projects/ui/CreateProjectDialog.tsx | 8 + src/features/projects/ui/ProjectsView.tsx | 4 + src/features/search/ui/SearchView.tsx | 15 +- .../search/ui/__tests__/SearchView.test.tsx | 71 + .../settings/ui/ArchivedProjectsSection.tsx | 12 +- src/features/settings/ui/SystemSettings.tsx | 6 + .../settings/ui/TelemetryConsentRow.tsx | 82 + .../ui/__tests__/TelemetryConsentRow.test.tsx | 169 ++ .../settings/ui/settingsSearchItems.ts | 7 + src/main.test.tsx | 64 +- src/main.tsx | 9 + src/shared/api/invokeWithStartupRetry.ts | 41 + src/shared/api/rendererTelemetry.ts | 11 +- src/shared/api/runtimeConfig.ts | 42 +- src/shared/api/telemetrySettings.test.ts | 73 + src/shared/api/telemetrySettings.ts | 26 + src/shared/i18n/locales/en/settings.json | 8 + src/shared/i18n/locales/es/settings.json | 8 + src/shared/profile/buildProfile.test.ts | 13 + src/shared/profile/buildProfile.ts | 7 + src/shared/profile/capabilities.test.ts | 1 + src/shared/telemetry/client.inert.test.ts | 33 +- src/shared/telemetry/client.test.ts | 1342 ++++++++++++ src/shared/telemetry/client.ts | 776 ++++++- src/shared/telemetry/consent.test.ts | 104 + src/shared/telemetry/consent.ts | 99 + src/shared/telemetry/devLog.test.ts | 169 ++ src/shared/telemetry/devLog.ts | 95 + src/shared/telemetry/distributionSink.test.ts | 43 + src/shared/telemetry/distributionSink.ts | 45 + src/shared/telemetry/events/berd_agent.ts | 87 + src/shared/telemetry/events/berd_app.ts | 59 + src/shared/telemetry/events/berd_chat.ts | 98 + src/shared/telemetry/events/berd_home.ts | 59 + src/shared/telemetry/events/berd_project.ts | 87 + src/shared/telemetry/events/event.ts | 18 + src/shared/telemetry/events/events.test.ts | 165 ++ src/shared/telemetry/events/index.ts | 35 + src/shared/telemetry/exporter.test.ts | 479 +++++ src/shared/telemetry/exporter.ts | 109 + vite.config.ts | 41 + 100 files changed, 11074 insertions(+), 339 deletions(-) create mode 100644 src-tauri/src/commands/telemetry.rs create mode 100644 src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx create mode 100644 src/features/agents/lib/agentTelemetry.ts create mode 100644 src/features/chat/lib/chatFirstMessage.test.ts create mode 100644 src/features/chat/lib/chatFirstMessage.ts create mode 100644 src/features/chat/lib/chatTelemetry.ts create mode 100644 src/features/chat/lib/queuedSessionSend.test.ts create mode 100644 src/features/home/lib/chatPinIdentity.test.ts create mode 100644 src/features/home/lib/chatPinIdentity.ts create mode 100644 src/features/home/lib/homePinTargets.ts create mode 100644 src/features/home/lib/homePinTelemetry.test.ts create mode 100644 src/features/home/lib/homePinTelemetry.ts create mode 100644 src/features/home/lib/homeTelemetry.ts create mode 100644 src/features/onboarding/ui/OnboardingFlow.test.tsx create mode 100644 src/features/projects/lib/projectTelemetry.ts create mode 100644 src/features/settings/ui/TelemetryConsentRow.tsx create mode 100644 src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx create mode 100644 src/shared/api/invokeWithStartupRetry.ts create mode 100644 src/shared/api/telemetrySettings.test.ts create mode 100644 src/shared/api/telemetrySettings.ts create mode 100644 src/shared/telemetry/client.test.ts create mode 100644 src/shared/telemetry/consent.test.ts create mode 100644 src/shared/telemetry/consent.ts create mode 100644 src/shared/telemetry/devLog.test.ts create mode 100644 src/shared/telemetry/devLog.ts create mode 100644 src/shared/telemetry/distributionSink.test.ts create mode 100644 src/shared/telemetry/distributionSink.ts create mode 100644 src/shared/telemetry/events/berd_agent.ts create mode 100644 src/shared/telemetry/events/berd_app.ts create mode 100644 src/shared/telemetry/events/berd_chat.ts create mode 100644 src/shared/telemetry/events/berd_home.ts create mode 100644 src/shared/telemetry/events/berd_project.ts create mode 100644 src/shared/telemetry/events/event.ts create mode 100644 src/shared/telemetry/events/events.test.ts create mode 100644 src/shared/telemetry/events/index.ts create mode 100644 src/shared/telemetry/exporter.test.ts create mode 100644 src/shared/telemetry/exporter.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 605d2b063..90a82bbea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,7 @@ env: VITE_BUILDERBOT: "0" VITE_FEEDBACK: "0" VITE_MANAGED_CONNECTIONS: "0" + VITE_TELEMETRY_ENFORCED: "0" VITE_VOICE_DICTATION: "0" VITE_BYO_KEY_PROVIDERS: "1" VITE_SECURITY_ML: "0" diff --git a/justfile b/justfile index e57e5ed49..4d3d6d99d 100644 --- a/justfile +++ b/justfile @@ -213,7 +213,10 @@ _tauri-check-unix: _tauri-check-windows: just tauri-check-windows -# Run the Rust plugin tests with external sidecars disabled. +# Run the Rust plugin and app-crate telemetry tests with external sidecars +# disabled. The telemetry lanes filter the app lib's tests by name — the +# `commands::telemetry` module path matches wholesale — and run twice because +# the `block-telemetry-enforced` feature swaps in the enforced-consent tests. tauri-test: just _tauri-test-{{ os_family() }} @@ -221,11 +224,15 @@ tauri-test: _tauri-test-unix: just _tauri-cargo-unix test -p tauri-plugin-berdctl --features server just _tauri-cargo-unix test -p berdctl + just _tauri-cargo-unix test --lib telemetry + just _tauri-cargo-unix test --lib --features block-telemetry-enforced telemetry [windows] _tauri-test-windows: just _tauri-cargo-windows test -p tauri-plugin-berdctl --features server just _tauri-cargo-windows test -p berdctl + just _tauri-cargo-windows test --lib telemetry + just _tauri-cargo-windows test --lib --features block-telemetry-enforced telemetry # Run the local CI gate. ci: release-version-check check tauri-fmt-check tauri-check tauri-test clippy test release-scripts-test build @@ -334,21 +341,10 @@ _bundle-unix: VITE_FEEDBACK="${VITE_FEEDBACK:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh ./scripts/prepare-catch-sidecar.sh - CARGO_FEATURES=(berdctl) - [[ "${VITE_AGENT_TOOLS:-0}" == "1" ]] && CARGO_FEATURES+=(block-agent-tools) - [[ "${VITE_AUTOMATIONS:-0}" == "1" ]] && CARGO_FEATURES+=(block-automations) - [[ "${VITE_BUILDERBOT:-0}" == "1" ]] && CARGO_FEATURES+=(block-builderbot) - [[ "${VITE_FEEDBACK:-0}" == "1" ]] && CARGO_FEATURES+=(block-feedback) - [[ "${VITE_MANAGED_CONNECTIONS:-0}" == "1" ]] && CARGO_FEATURES+=(block-managed-connections) - if [[ "${VITE_VOICE_DICTATION:-0}" == "1" ]]; then - CARGO_FEATURES+=(block-voice-dictation) - else - CARGO_FEATURES+=(no-voice-dictation) - fi + CARGO_FEATURES_CSV="$(./scripts/block-feature-gates.sh berdctl)" if [[ "${VITE_AGENT_TOOLS:-0}" == "1" ]]; then ./scripts/prepare-bb-cli-resource.sh fi - CARGO_FEATURES_CSV="$(IFS=,; echo "${CARGO_FEATURES[*]}")" # Derive a git-based version so non-release bundles don't ship the 0.1.0 # placeholder. Injected via a temp --config overlay to keep the tree clean. @@ -424,21 +420,10 @@ _bundle-debug-unix: VITE_FEEDBACK="${VITE_FEEDBACK:-0}" CARGO_TARGET_DIR="$TAURI_CARGO_TARGET_DIR" ./scripts/prepare-berdctl-sidecar.sh ./scripts/prepare-catch-sidecar.sh - CARGO_FEATURES=(berdctl devtools) - [[ "${VITE_AGENT_TOOLS:-0}" == "1" ]] && CARGO_FEATURES+=(block-agent-tools) - [[ "${VITE_AUTOMATIONS:-0}" == "1" ]] && CARGO_FEATURES+=(block-automations) - [[ "${VITE_BUILDERBOT:-0}" == "1" ]] && CARGO_FEATURES+=(block-builderbot) - [[ "${VITE_FEEDBACK:-0}" == "1" ]] && CARGO_FEATURES+=(block-feedback) - [[ "${VITE_MANAGED_CONNECTIONS:-0}" == "1" ]] && CARGO_FEATURES+=(block-managed-connections) - if [[ "${VITE_VOICE_DICTATION:-0}" == "1" ]]; then - CARGO_FEATURES+=(block-voice-dictation) - else - CARGO_FEATURES+=(no-voice-dictation) - fi + CARGO_FEATURES_CSV="$(./scripts/block-feature-gates.sh berdctl,devtools)" if [[ "${VITE_AGENT_TOOLS:-0}" == "1" ]]; then ./scripts/prepare-bb-cli-resource.sh fi - CARGO_FEATURES_CSV="$(IFS=,; echo "${CARGO_FEATURES[*]}")" # Use a temporary config overlay so normal release bundles keep devtools # disabled, and fold in the git-derived version so the bundle doesn't ship diff --git a/package.json b/package.json index 67e399ab3..fb2fa5468 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,13 @@ "@agentclientprotocol/sdk": "^0.19.0", "@daypicker/react": "^10.0.1", "@mcp-ui/client": "7.1.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "0.221.0", + "@opentelemetry/core": "2.10.0", + "@opentelemetry/otlp-transformer": "0.221.0", + "@opentelemetry/resources": "2.10.0", + "@opentelemetry/sdk-logs": "0.221.0", + "@opentelemetry/semantic-conventions": "^1.41.0", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-aspect-ratio": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2d0230f..63d9cd67b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,27 @@ importers: '@mcp-ui/client': specifier: 7.1.1 version: 7.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: 0.221.0 + version: 0.221.0 + '@opentelemetry/core': + specifier: 2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': + specifier: 0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: 2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: 0.221.0 + version: 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.41.0 + version: 1.43.0 '@radix-ui/react-accordion': specifier: ^1.2.20 version: 1.2.20(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1005,6 +1026,10 @@ packages: resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} + '@opentelemetry/api-logs@0.221.0': + resolution: {integrity: sha512-OlanaW1vv7ufTqQ3/fPLI4arGt5ZoM+P8abOMki6uEYnpRazepSWDwDnnw+la7kE26SHVC18//SMccrDvLKOXQ==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1021,12 +1046,30 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.221.0': + resolution: {integrity: sha512-lg6lkOU08Az23jVcn/0Els9HP+V8PnR4Km6p0KgpTggS0n/WuhnmY64rSh83Of9iR9nD+dpWr6adlcX8KzAwjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/resources@2.10.0': resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-logs@0.221.0': + resolution: {integrity: sha512-FaDcazjyMp7TZZZAsqbo4IkovP0UegoCu0EBkiNt+qCqvUf7FPAsfcrZ3+ZEkKgXZ/jHafop+JoGPDk3A0SmLg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.10.0': + resolution: {integrity: sha512-t6r1VSvXNtSDnPXU1FbZeetJb7yyovHmgu0wRSoftxtE0g2rSNhQZQUy69sRUCL+iioJpX8SN/S6wq6ZtvLySQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.10.0': resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -1152,6 +1195,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.142.0': resolution: {integrity: sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==} @@ -1193,6 +1237,7 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.142.0': resolution: {integrity: sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==} @@ -1280,11 +1325,13 @@ packages: resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} @@ -1302,6 +1349,7 @@ packages: resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.24.2': resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} @@ -2170,7 +2218,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.2.3': resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} @@ -2388,12 +2435,14 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.3': resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} @@ -6251,6 +6300,10 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs@0.221.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': @@ -6267,12 +6320,36 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/otlp-transformer@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.221.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.221.0 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/scripts/block-feature-gates.sh b/scripts/block-feature-gates.sh index d1c921ac5..d00831f01 100755 --- a/scripts/block-feature-gates.sh +++ b/scripts/block-feature-gates.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# Resolve the six renderer build gates to their matching Tauri Cargo features. +# Resolve the seven renderer build gates to their matching Tauri Cargo features. set -euo pipefail base_features="${1:-}" features=() [[ -n "$base_features" ]] && IFS=',' read -r -a features <<< "$base_features" -for name in VITE_AGENT_TOOLS VITE_AUTOMATIONS VITE_BUILDERBOT VITE_FEEDBACK VITE_MANAGED_CONNECTIONS VITE_VOICE_DICTATION; do +for name in VITE_AGENT_TOOLS VITE_AUTOMATIONS VITE_BUILDERBOT VITE_FEEDBACK VITE_MANAGED_CONNECTIONS VITE_TELEMETRY_ENFORCED VITE_VOICE_DICTATION; do value="${!name:-0}" if [[ "$value" != "0" && "$value" != "1" ]]; then echo "$name must be 0 or 1 (got: $value)" >&2 @@ -19,6 +19,7 @@ done [[ "${VITE_BUILDERBOT:-0}" == "1" ]] && features+=(block-builderbot) [[ "${VITE_FEEDBACK:-0}" == "1" ]] && features+=(block-feedback) [[ "${VITE_MANAGED_CONNECTIONS:-0}" == "1" ]] && features+=(block-managed-connections) +[[ "${VITE_TELEMETRY_ENFORCED:-0}" == "1" ]] && features+=(block-telemetry-enforced) if [[ "${VITE_VOICE_DICTATION:-0}" == "1" ]]; then features+=(block-voice-dictation) else diff --git a/scripts/build_linux_docker.sh b/scripts/build_linux_docker.sh index c4674244e..f572d0bd5 100755 --- a/scripts/build_linux_docker.sh +++ b/scripts/build_linux_docker.sh @@ -73,6 +73,7 @@ vite_env_names=( VITE_BUILDERBOT VITE_FEEDBACK VITE_MANAGED_CONNECTIONS + VITE_TELEMETRY_ENFORCED VITE_VOICE_DICTATION VITE_BYO_KEY_PROVIDERS VITE_SECURITY_ML diff --git a/scripts/release/build-macos.sh b/scripts/release/build-macos.sh index b0eca5b60..27941a0c9 100755 --- a/scripts/release/build-macos.sh +++ b/scripts/release/build-macos.sh @@ -102,6 +102,9 @@ VITE_AUTOMATIONS_VALUE="${VITE_AUTOMATIONS:-0}" VITE_BUILDERBOT_VALUE="${VITE_BUILDERBOT:-0}" VITE_FEEDBACK_VALUE="${VITE_FEEDBACK:-0}" VITE_MANAGED_CONNECTIONS_VALUE="${VITE_MANAGED_CONNECTIONS:-0}" +# Managed internal distributions force telemetry consent ON and hide the +# settings toggle; public builds leave consent to the user (default OFF). +VITE_TELEMETRY_ENFORCED_VALUE="${VITE_TELEMETRY_ENFORCED:-0}" VITE_VOICE_DICTATION_VALUE="${VITE_VOICE_DICTATION:-0}" VITE_BYO_KEY_PROVIDERS_VALUE="${VITE_BYO_KEY_PROVIDERS:-1}" # Public builds have no external security classifier. Internal distributions may @@ -140,6 +143,9 @@ set_vite_env() { VITE_MANAGED_CONNECTIONS) VITE_MANAGED_CONNECTIONS_VALUE="$value" ;; + VITE_TELEMETRY_ENFORCED) + VITE_TELEMETRY_ENFORCED_VALUE="$value" + ;; VITE_VOICE_DICTATION) VITE_VOICE_DICTATION_VALUE="$value" ;; @@ -395,10 +401,10 @@ if [[ "$BUILD_KIND" == "custom" ]]; then fi -# Resolve the six independent Block-service product gates into matching +# Resolve the seven independent Block-service product gates into matching # renderer and backend/package gates. Values are positive opt-ins: absent is # public-off and no runtime config can revive a build-disabled family. -for value in "$VITE_AGENT_TOOLS_VALUE" "$VITE_AUTOMATIONS_VALUE" "$VITE_BUILDERBOT_VALUE" "$VITE_FEEDBACK_VALUE" "$VITE_MANAGED_CONNECTIONS_VALUE" "$VITE_VOICE_DICTATION_VALUE"; do +for value in "$VITE_AGENT_TOOLS_VALUE" "$VITE_AUTOMATIONS_VALUE" "$VITE_BUILDERBOT_VALUE" "$VITE_FEEDBACK_VALUE" "$VITE_MANAGED_CONNECTIONS_VALUE" "$VITE_TELEMETRY_ENFORCED_VALUE" "$VITE_VOICE_DICTATION_VALUE"; do [[ "$value" == "0" || "$value" == "1" ]] || { echo "Block-service feature gates must be 0 or 1" >&2; exit 1; } done [[ "$VITE_AGENT_TOOLS_VALUE" == "1" ]] && CARGO_FEATURES="$CARGO_FEATURES,block-agent-tools" @@ -406,6 +412,10 @@ done [[ "$VITE_BUILDERBOT_VALUE" == "1" ]] && CARGO_FEATURES="$CARGO_FEATURES,block-builderbot" [[ "$VITE_FEEDBACK_VALUE" == "1" ]] && CARGO_FEATURES="$CARGO_FEATURES,block-feedback" [[ "$VITE_MANAGED_CONNECTIONS_VALUE" == "1" ]] && CARGO_FEATURES="$CARGO_FEATURES,block-managed-connections" +# The renderer flag and the Cargo feature must move together: the flag skips +# the user setting in Gate A and hides the toggle, the feature does the same +# for the native Gate B in export_otel_logs. +[[ "$VITE_TELEMETRY_ENFORCED_VALUE" == "1" ]] && CARGO_FEATURES="$CARGO_FEATURES,block-telemetry-enforced" if [[ "$VITE_VOICE_DICTATION_VALUE" == "1" ]]; then CARGO_FEATURES="$CARGO_FEATURES,block-voice-dictation" else @@ -478,6 +488,7 @@ env \ VITE_BUILDERBOT="$VITE_BUILDERBOT_VALUE" \ VITE_FEEDBACK="$VITE_FEEDBACK_VALUE" \ VITE_MANAGED_CONNECTIONS="$VITE_MANAGED_CONNECTIONS_VALUE" \ + VITE_TELEMETRY_ENFORCED="$VITE_TELEMETRY_ENFORCED_VALUE" \ VITE_VOICE_DICTATION="$VITE_VOICE_DICTATION_VALUE" \ VITE_BYO_KEY_PROVIDERS="$VITE_BYO_KEY_PROVIDERS_VALUE" \ VITE_SECURITY_ML="$VITE_SECURITY_ML_VALUE" \ diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index df608d428..2f1253c76 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -448,16 +448,7 @@ describe("local macOS bundle version propagation", () => { }); describe("local macOS bundle feature-gate propagation", () => { - const gates = [ - ["VITE_AGENT_TOOLS", "block-agent-tools"], - ["VITE_AUTOMATIONS", "block-automations"], - ["VITE_BUILDERBOT", "block-builderbot"], - ["VITE_FEEDBACK", "block-feedback"], - ["VITE_MANAGED_CONNECTIONS", "block-managed-connections"], - ["VITE_VOICE_DICTATION", "block-voice-dictation"], - ]; - - it("maps all six positive opt-ins in both release and debug recipes", async () => { + it("resolves gates through the shared mapper in both release and debug recipes", async () => { const justfile = await readFile(join(repo, "justfile"), "utf8"); const releaseRecipe = justfile.slice( justfile.indexOf("_bundle-unix:"), @@ -468,16 +459,22 @@ describe("local macOS bundle feature-gate propagation", () => { justfile.indexOf("# ── Test"), ); + // Both recipes delegate the whole gate table so a new gate reaches the + // bundle without a second edit; only the posture bases differ. + expect(releaseRecipe).toContain( + `CARGO_FEATURES_CSV="$(./scripts/block-feature-gates.sh berdctl)"`, + ); + expect(debugRecipe).toContain( + `CARGO_FEATURES_CSV="$(./scripts/block-feature-gates.sh berdctl,devtools)"`, + ); + for (const recipe of [releaseRecipe, debugRecipe]) { - for (const [viteGate, cargoFeature] of gates) { - expect(recipe).toContain(`\${${viteGate}:-0}`); - expect(recipe).toContain(cargoFeature); - } expect(recipe).toContain(`VITE_AUTH_GATE="\${VITE_AUTH_GATE:-0}"`); expect(recipe).toContain( `VITE_BYO_KEY_PROVIDERS="\${VITE_BYO_KEY_PROVIDERS:-1}"`, ); - expect(recipe).toContain("no-voice-dictation"); + // Resource staging is not gate mapping, so it stays in the recipe. + expect(recipe).toContain(`\${VITE_AGENT_TOOLS:-0}`); expect(recipe).toContain("prepare-bb-cli-resource.sh"); expect(recipe).toContain('"../resources/bb"'); expect(recipe).toContain(`VITE_FEEDBACK="\${VITE_FEEDBACK:-0}"`); @@ -2136,6 +2133,24 @@ fi }); }); +// The renderer build gates and their Cargo features, read out of the mapper +// that dev and the bash bundle paths already share, so the drift guards below +// pick up a new gate without being edited. +async function canonicalGates() { + const source = await readFile( + join(repo, "scripts/block-feature-gates.sh"), + "utf8", + ); + const envNames = source.match(/^for name in (.+); do$/m)?.[1].split(/\s+/); + expect(envNames).toBeDefined(); + return envNames.map((env) => ({ + env, + feature: source.match( + new RegExp(`\\$\\{${env}:-0\\}" == "1" \\]\\];?[^(]*\\(?(block-[a-z-]+)`), + )?.[1], + })); +} + describe("Block feature gate propagation", () => { it("maps every updater-off default to the fail-closed Cargo posture", () => { const result = run("bash", ["scripts/block-feature-gates.sh", "berdctl"]); @@ -2143,13 +2158,14 @@ describe("Block feature gate propagation", () => { expect(result.stdout.trim()).toBe("berdctl,no-voice-dictation"); }); - it("maps the six independent renderer gates to matching Cargo features", () => { + it("maps the seven independent renderer gates to matching Cargo features", () => { const env = { VITE_AGENT_TOOLS: "1", VITE_AUTOMATIONS: "1", VITE_BUILDERBOT: "1", VITE_FEEDBACK: "1", VITE_MANAGED_CONNECTIONS: "1", + VITE_TELEMETRY_ENFORCED: "1", VITE_VOICE_DICTATION: "1", }; const result = run( @@ -2166,6 +2182,7 @@ describe("Block feature gate propagation", () => { "block-builderbot", "block-feedback", "block-managed-connections", + "block-telemetry-enforced", "block-voice-dictation", ]); }); @@ -2177,4 +2194,56 @@ describe("Block feature gate propagation", () => { expect(result.status).toBe(2); expect(result.stderr).toContain("VITE_AUTOMATIONS must be 0 or 1"); }); + + // A renderer gate that reaches vite but not the Cargo feature set builds an + // app whose UI hides the feature while the backend rejects it (or the other + // way round). Only bash callers can share the mapper, so the resolvers that + // re-implement it are pinned against it here. + it("resolves every canonical gate in the resolvers that cannot call the mapper", async () => { + const gates = await canonicalGates(); + expect(gates.map((gate) => gate.env)).toContain("VITE_TELEMETRY_ENFORCED"); + for (const gate of gates) { + expect(gate.feature).toMatch(/^block-/); + } + + const [windowsDev, macosBuild, dockerBuild] = await Promise.all([ + readFile(join(repo, "scripts/windows/WindowsDev.psm1"), "utf8"), + readFile(join(repo, "scripts/release/build-macos.sh"), "utf8"), + readFile(join(repo, "scripts/build_linux_docker.sh"), "utf8"), + ]); + + // Windows has no guaranteed bash in the release image, so Get-BerdAppFeatures + // re-implements the table for every Windows lane including bundle-windows. + const windowsGates = windowsDev.match( + /\$gates = @\(([\s\S]*?)\n\s*\)/, + )?.[1]; + expect(windowsGates).toBeDefined(); + for (const gate of gates) { + expect(windowsGates).toContain( + `@{ Env = "${gate.env}"; Feature = "${gate.feature}" }`, + ); + } + + // build-macos.sh maps inline so it can reject release-owned overrides; the + // resolved value has to reach both the Cargo features and the vite env. + for (const gate of gates) { + expect(macosBuild).toContain(`${gate.env}_VALUE="\${${gate.env}:-0}"`); + expect(macosBuild).toContain(gate.feature); + expect(macosBuild).toContain(`${gate.env}="$${gate.env}_VALUE"`); + } + + // Docker bundles only see the gates the wrapper forwards into the container. + const forwarded = dockerBuild + .match(/vite_env_names=\(([\s\S]*?)\n\)/)?.[1] + ?.split(/\s+/); + expect(forwarded).toBeDefined(); + for (const gate of gates) { + expect(forwarded).toContain(gate.env); + } + }); + + it("keeps recipes from re-forking gate policy into a hand-built feature list", async () => { + const justfile = await readFile(join(repo, "justfile"), "utf8"); + expect(justfile).not.toContain("CARGO_FEATURES+=(block-"); + }); }); diff --git a/scripts/windows/Test-WindowsDev.ps1 b/scripts/windows/Test-WindowsDev.ps1 index f3de776bb..5f6c0982b 100644 --- a/scripts/windows/Test-WindowsDev.ps1 +++ b/scripts/windows/Test-WindowsDev.ps1 @@ -78,16 +78,16 @@ try { Assert-Equal "process args: embedded quote escaped" (Join-WindowsProcessArguments -Arguments @('say "hi"')) '"say \"hi\""' Assert-Equal "public app feature defaults fail closed" (Get-BerdAppFeatures) "berdctl,app-test-driver,no-voice-dictation" - $featureGateNames = @("VITE_AGENT_TOOLS", "VITE_AUTOMATIONS", "VITE_BUILDERBOT", "VITE_FEEDBACK", "VITE_MANAGED_CONNECTIONS", "VITE_VOICE_DICTATION") + $featureGateNames = @("VITE_AGENT_TOOLS", "VITE_AUTOMATIONS", "VITE_BUILDERBOT", "VITE_FEEDBACK", "VITE_MANAGED_CONNECTIONS", "VITE_TELEMETRY_ENFORCED", "VITE_VOICE_DICTATION") $savedFeatureGates = @{} foreach ($name in $featureGateNames) { $savedFeatureGates[$name] = [Environment]::GetEnvironmentVariable($name, "Process") [Environment]::SetEnvironmentVariable($name, "1", "Process") } try { - Assert-Equal "all six renderer gates map to app Cargo features" ` + Assert-Equal "all seven renderer gates map to app Cargo features" ` (Get-BerdAppFeatures -BaseFeatures @("berdctl")) ` - "berdctl,block-agent-tools,block-automations,block-builderbot,block-feedback,block-managed-connections,block-voice-dictation" + "berdctl,block-agent-tools,block-automations,block-builderbot,block-feedback,block-managed-connections,block-telemetry-enforced,block-voice-dictation" } finally { foreach ($name in $featureGateNames) { [Environment]::SetEnvironmentVariable($name, $savedFeatureGates[$name], "Process") diff --git a/scripts/windows/WindowsDev.psm1 b/scripts/windows/WindowsDev.psm1 index e8e65b40a..aebb9a0a9 100644 --- a/scripts/windows/WindowsDev.psm1 +++ b/scripts/windows/WindowsDev.psm1 @@ -480,9 +480,12 @@ function Join-WindowsProcessArguments { return ($quoted -join " ") } -# Single source of truth for mapping the six renderer build gates onto the +# Single source of truth for mapping the seven renderer build gates onto the # matching Tauri Cargo feature set. Callers may add posture features (for # example berdctl/app-test-driver/devtools) without duplicating gate policy. +# Windows cannot call scripts/block-feature-gates.sh (no guaranteed bash in the +# release image), so this table is pinned equal to that mapper by +# scripts/release/tests/release-scripts.test.mjs. function Get-BerdAppFeatures { param([string[]]$BaseFeatures = @("berdctl", "app-test-driver")) @@ -498,6 +501,7 @@ function Get-BerdAppFeatures { @{ Env = "VITE_BUILDERBOT"; Feature = "block-builderbot" }, @{ Env = "VITE_FEEDBACK"; Feature = "block-feedback" }, @{ Env = "VITE_MANAGED_CONNECTIONS"; Feature = "block-managed-connections" }, + @{ Env = "VITE_TELEMETRY_ENFORCED"; Feature = "block-telemetry-enforced" }, @{ Env = "VITE_VOICE_DICTATION"; Feature = "block-voice-dictation" } ) foreach ($gate in $gates) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 69a5ee8da..fe5d90c6d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "dunce", "earshot", "etcetera 0.11.0", + "fern", "flate2", "futures-util", "hex", @@ -5798,9 +5799,9 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "sherpa-onnx" -version = "1.13.5" +version = "1.12.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352a5dbbd9623e800be27e77de514e87fcfe2120256acc4434e6ea87a56bc885" +checksum = "bc1492cdcbd945259ea6871f1ed8e1f5b48ef78cd54c1c057f63eb11c862406f" dependencies = [ "serde", "serde_json", @@ -5809,9 +5810,9 @@ dependencies = [ [[package]] name = "sherpa-onnx-sys" -version = "1.13.5" +version = "1.12.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1541926ecf70b3d806467a2f100d3d879eef7fd34f8b307028bb074871d7493" +checksum = "c6f669fe60877fb48f151f44d9e3423c90b6b49e6fc7cf503da29224a893b099" dependencies = [ "bzip2 0.4.4", "tar", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0dcfd6e0e..040d61aa6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ etcetera = "0.11.0" flate2 = "1" hex = "0.4" ignore = "0.4.25" +fern = "0.7" infer = "0.19.0" libc = "0.2" log = "0.4.29" @@ -51,7 +52,20 @@ audioadapter-buffers = "3.0" earshot = "1.0" rubato = "3.0" tar = "0.4" -sherpa-onnx = "1.12" +# Held below 1.13: sherpa-onnx-sys downloads prebuilt native libs into a +# version-stamped dir under the target dir, and rust-cache strips the files out +# of it when saving. The restored empty skeleton defeats the build script's +# `lib_dir.is_dir()` early return, so it never re-downloads and linking fails +# with "could not find native static library `sherpa-onnx-c-api`". CI's Linux +# cache is poisoned for v1.13.5; this pin resolves to a path the cache has +# never gutted. Tilde, not caret — `^1.12` floats back to 1.13.5. +# +# NOTE: sherpa-onnx-sys is the crate whose version actually names that path, +# and it declares `^1.12.40`, so nothing in this manifest constrains it — it is +# held at 1.12.40 by Cargo.lock alone. A bare `cargo update` floats it back to +# 1.13.5 and reintroduces the CI failure; re-pin it with +# `cargo update -p sherpa-onnx-sys --precise 1.12.40`. +sherpa-onnx = "~1.12.40" ssstretch = "0.1.0" semver = "1" serde = { version = "1", features = ["derive"] } @@ -133,6 +147,11 @@ block-automations = [] block-builderbot = [] block-feedback = [] block-managed-connections = [] +# Forces telemetry consent ON, skipping the user's telemetry-settings.json +# (the renderer hides the toggle via the paired VITE_TELEMETRY_ENFORCED flag). +# For managed internal distributions where consent is an employment-policy +# fact, not a per-user choice. +block-telemetry-enforced = [] block-voice-dictation = [] # Admin runtime-config endpoint fetch. Default-OFF: a normal build never # compiles the kgoose-backed fetch/cache path and instead loads the bundled diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e61d7cd1a..24f21ac40 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -44,6 +44,7 @@ pub mod runtime_config; pub mod security_threshold; pub mod skill_marketplace; pub mod system; +pub mod telemetry; pub mod terminal; pub mod updates; pub mod voice_capture; diff --git a/src-tauri/src/commands/renderer.rs b/src-tauri/src/commands/renderer.rs index 1fda09fb8..a406a5000 100644 --- a/src-tauri/src/commands/renderer.rs +++ b/src-tauri/src/commands/renderer.rs @@ -3,13 +3,140 @@ //! Lets the web UI forward lifecycle signals it can observe (e.g. an //! unexpected page reload after a renderer reap) into `berd.log`, alongside //! the backend's `renderer_monitor` memory samples. +//! +//! This module also owns the Stdout log formatter: dev-time telemetry-viewer +//! lines arrive over this command tagged with [`TELEMETRY_VIEWER_LOG_TARGET`], +//! and the formatter renders exactly those records grey in the terminal. Both +//! ends of that seam — the command that stamps the target and the formatter +//! that keys off it — live here so they cannot drift apart. The LogDir target +//! deliberately has no formatter: the grey is ANSI escapes, which belong on a +//! terminal and would be pollution in `berd.log`. + +use std::fmt::Arguments; + +/// Log target for dev-time telemetry-viewer lines (`src/shared/telemetry/ +/// devLog.ts`). Carrying the tag as the record's target rather than a message +/// prefix keeps it structured: the default format prints it as `[telemetry]` +/// in both the terminal and `berd.log`, and the Stdout formatter can key off +/// it without sniffing message content. +pub const TELEMETRY_VIEWER_LOG_TARGET: &str = "telemetry"; + +/// Bright-black (SGR 90): grey on every common terminal theme without +/// assuming a palette, unlike faint (SGR 2), which some terminals ignore. +const TELEMETRY_VIEWER_STYLE: &str = "\x1b[90m"; +const ANSI_RESET: &str = "\x1b[0m"; /// Append a renderer lifecycle event from the frontend to the app log. +/// +/// `target` is validated to a closed set; anything unrecognized falls back to +/// this module's own target, so the renderer cannot ride an arbitrary value +/// into another target's level filters (e.g. `perf`'s debug override). #[tauri::command] -pub fn log_renderer_event(level: String, message: String) { +pub fn log_renderer_event(level: String, message: String, target: Option) { + let target = renderer_log_target(target.as_deref()); match level.as_str() { - "error" => log::error!("[renderer] {message}"), - "warn" => log::warn!("[renderer] {message}"), - _ => log::info!("[renderer] {message}"), + "error" => log::error!(target: target, "[renderer] {message}"), + "warn" => log::warn!(target: target, "[renderer] {message}"), + _ => log::info!(target: target, "[renderer] {message}"), + } +} + +fn renderer_log_target(requested: Option<&str>) -> &'static str { + match requested { + Some(TELEMETRY_VIEWER_LOG_TARGET) => TELEMETRY_VIEWER_LOG_TARGET, + _ => module_path!(), + } +} + +/// Per-target formatter for the Stdout log target: telemetry-viewer records +/// are wrapped in grey so they read apart from ordinary log output; every +/// other record passes through byte-identical. +/// +/// This runs after the plugin's root formatter, so `message` is the finished +/// `[date][time][target][level] …` line and the whole line takes the color. +/// Styling here — on the one target that is a terminal — is what keeps the +/// escapes out of the LogDir target's `berd.log`. +pub fn stdout_log_format( + out: fern::FormatCallback<'_>, + message: &Arguments<'_>, + record: &log::Record<'_>, +) { + if record.target() == TELEMETRY_VIEWER_LOG_TARGET { + out.finish(format_args!( + "{TELEMETRY_VIEWER_STYLE}{message}{ANSI_RESET}" + )); + } else { + out.finish(format_args!("{message}")); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + /// Runs one record through a dispatch wired like the Stdout target in + /// `lib.rs` (the production formatter, then the sink) and returns the + /// exact line the terminal would receive. + fn stdout_line(target: &str, message: &str) -> String { + let lines: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&lines); + let (_, logger) = fern::Dispatch::new() + .format(stdout_log_format) + .chain(fern::Output::call(move |record| { + sink.lock().unwrap().push(record.args().to_string()); + })) + .into_log(); + logger.log( + &log::Record::builder() + .args(format_args!("{message}")) + .level(log::Level::Info) + .target(target) + .build(), + ); + let lines = lines.lock().unwrap(); + assert_eq!(lines.len(), 1, "expected exactly one formatted line"); + lines[0].clone() + } + + #[test] + fn stdout_wraps_telemetry_viewer_records_in_grey() { + // The exact bytes: bright-black SGR 90 opens, the full already- + // formatted line rides inside, and the reset closes — nothing is + // left styled after the record. + assert_eq!( + stdout_line(TELEMETRY_VIEWER_LOG_TARGET, "[renderer] main berd_x {}"), + "\u{1b}[90m[renderer] main berd_x {}\u{1b}[0m" + ); + } + + #[test] + fn stdout_passes_other_records_through_byte_identical() { + let line = "[2026-08-17][10:00:00][berd_lib::foo][INFO] plain line"; + assert_eq!(stdout_line("berd_lib::foo", line), line); + } + + #[test] + fn stdout_does_not_grey_on_message_content() { + // Only the record's target selects the styling; a message that merely + // mentions telemetry stays plain. + let line = "[renderer] [telemetry] lookalike"; + assert_eq!(stdout_line("berd_lib::commands::renderer", line), line); + } + + #[test] + fn renderer_log_target_accepts_only_the_telemetry_tag() { + assert_eq!( + renderer_log_target(Some("telemetry")), + TELEMETRY_VIEWER_LOG_TARGET + ); + // Absent and unrecognized values both fall back to this module — in + // particular "perf", whose debug level override a renderer request + // must not be able to opt into. + let fallback = "berd_lib::commands::renderer"; + assert_eq!(renderer_log_target(None), fallback); + assert_eq!(renderer_log_target(Some("perf")), fallback); + assert_eq!(renderer_log_target(Some("")), fallback); } } diff --git a/src-tauri/src/commands/telemetry.rs b/src-tauri/src/commands/telemetry.rs new file mode 100644 index 000000000..79aa813bb --- /dev/null +++ b/src-tauri/src/commands/telemetry.rs @@ -0,0 +1,1795 @@ +use crate::services::distro_bundle::{DistroBundleState, TelemetryChannel}; +use reqwest::{ + header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, + redirect::Policy, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + OnceLock, + }, + time::{Duration, Instant}, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +const OTEL_LOGS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const OTEL_LOGS_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +// Telemetry-gateway path convention: the build-injected endpoint is the full +// `https:///v1/logs` URL, and the anonymous `/v1/bootstrap` URL is +// derived from it by swapping the path suffix. Swapping in a real gateway host +// is therefore pure configuration — build env, this file's host allowlist, the +// CSP, and the pinned test values — with no code-path changes. +const OTEL_LOGS_PATH: &str = "/v1/logs"; +const TELEMETRY_BOOTSTRAP_PATH: &str = "/v1/bootstrap"; + +// Wire-contract version the gateway validates every upload against. It names +// the *body* contract the renderer serializes — the closed resource-attribute +// set, the strict log-record shape, the event catalog and its per-event +// parameters — so any change to what goes on the wire has to move in lockstep +// with a version the gateway has registered. Sent on `/v1/logs` exactly once; +// missing, empty, unregistered, or comma-joined (i.e. sent twice) is a +// terminal 400 `schema_validation_failed`, and a rejected batch is permanently +// lost because the renderer's `BatchLogRecordProcessor` drops it. +// `/v1/bootstrap` does not read it. +const TELEMETRY_SCHEMA_VERSION_HEADER: &str = "x-berd-schema-version"; +const TELEMETRY_SCHEMA_VERSION: &str = "berd-otlp-logs-v1"; + +// Telemetry-gateway host allowlist. The renderer's OTLP endpoint is +// build-injected from VITE_OTLP_LOGS_ENDPOINT (see vite.config.ts) and must +// resolve to one of these hosts: +// +// - otel.berd.xyz — the production gateway (squareup/berd-monitoring's +// production deployment), injected for VITE_ENVIRONMENT=production builds. +// - otel.test.blockstaging.build — the staging gateway +// (squareup/berd-monitoring's staging deployment), injected for +// VITE_ENVIRONMENT=staging builds. +// - otlp.invalid.goose-internal.example — DUMMY placeholder injected in +// development; the prod/staging gate plus this fake host keep the path +// inert in dev and external clones. +// +// Keep this in sync with vite.config.ts's endpoint constants and +// tauri.conf.json's CSP connect-src. +const ALLOWED_OTEL_LOGS_HOSTS: [&str; 3] = [ + "otel.berd.xyz", + "otel.test.blockstaging.build", + "otlp.invalid.goose-internal.example", +]; + +// File under the app-data dir holding the persistent anonymous installation +// id. It keys bootstrap-token issuance (and rate limiting) on the gateway and +// is stamped by the renderer as the `installation.id` resource attribute. +const INSTALLATION_ID_FILE_NAME: &str = "telemetry-installation-id"; + +// File under the app-data dir holding the user's telemetry consent. It lives +// here — not in the renderer's localStorage — precisely so this module can +// enforce it natively: "disabled" must mean no bytes to the gateway at all, +// including `/v1/bootstrap` (which carries the installation id), and only the +// Rust host can guarantee that regardless of renderer timing. A missing file +// is DISABLED — telemetry is opt-in, and every failure mode reads as "no +// consent". +const TELEMETRY_SETTINGS_FILE_NAME: &str = "telemetry-settings.json"; +const TELEMETRY_SETTINGS_SCHEMA_VERSION: u32 = 1; + +// Refresh the cached upload token this long before its reported expiry, so an +// export near the boundary does not race server-side expiry. +const TOKEN_REFRESH_MARGIN: Duration = Duration::from_secs(60); + +// Cap on the gateway-reported token TTL. `Instant + Duration` panics on +// overflow and the TTL is network input, so an unbounded `expiresInSeconds` +// from a buggy or hostile gateway must not reach the addition verbatim. A day +// is far beyond any TTL the gateway actually issues (~15 minutes); clamping +// costs nothing but an earlier proactive refresh. +const MAX_TOKEN_TTL: Duration = Duration::from_secs(24 * 60 * 60); + +/// Native half of the telemetry bootstrap-token flow. The upload token never +/// enters the renderer: it is bootstrapped anonymously from the gateway keyed +/// on the persistent installation id, cached here in process memory, and +/// attached to `/v1/logs` uploads as a `Bearer` header. +/// +/// Also owns the persisted telemetry consent (see +/// `TELEMETRY_SETTINGS_FILE_NAME`), read from disk when the state is +/// constructed in `setup()` — synchronously, before any webview exists — so +/// there is no startup window in which an export can outrun the consent check. +pub struct TelemetryAuthState { + app_data_dir: PathBuf, + installation_id: OnceLock, + token: Mutex>, + consent_enabled: AtomicBool, + /// Monotonic count of consent revocations this process has seen. An export + /// snapshots it at entry and aborts at its next network boundary if it has + /// moved, so revocation cancels work already past the entry gate (see + /// `ensure_consent_unrevoked`). + revocation_epoch: AtomicU64, +} + +struct CachedUploadToken { + token: String, + /// Proactive-refresh deadline: issuance time plus the reported TTL minus + /// `TOKEN_REFRESH_MARGIN`. + refresh_after: Instant, +} + +impl TelemetryAuthState { + pub fn new(app_data_dir: PathBuf) -> Self { + let consent_enabled = load_telemetry_consent(&app_data_dir); + Self { + app_data_dir, + installation_id: OnceLock::new(), + token: Mutex::new(None), + consent_enabled: AtomicBool::new(consent_enabled), + revocation_epoch: AtomicU64::new(0), + } + } + + /// The effective telemetry consent: forced ON in enforced builds, + /// otherwise the persisted user setting. Fail-closed — a missing, + /// unreadable, or unrecognized settings file means disabled. + fn consent_granted(&self) -> bool { + telemetry_enforced_by_build() || self.consent_enabled.load(Ordering::Relaxed) + } + + /// Snapshot of the revocation counter, taken as an export begins and + /// carried to every network boundary it later crosses (see + /// `ensure_consent_unrevoked`). + fn revocation_epoch(&self) -> u64 { + self.revocation_epoch.load(Ordering::Relaxed) + } + + /// The mid-flight consent check: an export may continue only while consent + /// is still granted *and* no revocation has landed since its snapshot. + /// + /// The epoch — rather than a plain `consent_granted()` re-check — is what + /// makes revocation supersede a re-grant: a batch in flight when the user + /// opted out never ships, even if they opt back in before it reaches the + /// next boundary, and only batches queued after the re-grant flow again. + /// `Relaxed` throughout, matching the consent load: there is no + /// cross-variable invariant (either signal alone aborts) and the check is + /// inherently racy against bytes already on the wire. + /// + /// The error is deliberately distinct from the entry gate's, so logs + /// separate a mid-flight abort from a settled-off refusal. + fn ensure_consent_unrevoked(&self, epoch: u64) -> Result<(), String> { + if !self.consent_granted() || self.revocation_epoch() != epoch { + return Err("Telemetry consent was revoked during export".to_string()); + } + Ok(()) + } + + fn settings(&self) -> TelemetrySettings { + TelemetrySettings { + enabled: self.consent_granted(), + } + } + + /// Persists and applies consent, and on revocation bumps the epoch so + /// exports already past the entry gate abort at their next network + /// boundary. A grant deliberately does not bump: there is no reason for + /// opting in to kill work in progress. + fn set_consent(&self, enabled: bool) -> Result { + if telemetry_enforced_by_build() { + // The settings toggle is never rendered in enforced builds, so a + // write reaching here is a bug — refuse loudly rather than + // persisting a value the build would ignore. + return Err("Telemetry is always enabled in this build".to_string()); + } + persist_telemetry_consent(&self.app_data_dir, enabled)?; + self.consent_enabled.store(enabled, Ordering::Relaxed); + if !enabled { + self.revocation_epoch.fetch_add(1, Ordering::Relaxed); + } + Ok(self.settings()) + } + + /// The persistent anonymous installation id, loaded (or created) on first + /// use. Falls back to a session-only id when the app-data dir is + /// unwritable, keeping telemetry best-effort rather than fallible. + fn installation_id(&self) -> &str { + self.installation_id.get_or_init(|| { + load_or_create_installation_id(&self.app_data_dir).unwrap_or_else(|error| { + log::warn!("Failed to persist telemetry installation id: {error}"); + Uuid::new_v4().to_string() + }) + }) + } + + /// Returns a valid upload token, bootstrapping a fresh one when none is + /// cached or the cached one is within the proactive-refresh margin of + /// expiry. The lock is held across the bootstrap call so concurrent + /// exports cannot stampede the gateway's per-install rate limit. + /// + /// Takes the caller's revocation-epoch snapshot because the consent + /// re-check for the bootstrap request has to happen *here*, not at the call + /// site: waiting on the lock behind a sibling's bootstrap can be the + /// longest stall in an export, and the request below is the one that + /// carries the installation id. A cache hit needs no check — it touches no + /// network, and the caller re-checks before the upload it is fetching the + /// token for. + async fn upload_token( + &self, + client: &reqwest::Client, + bootstrap_url: &reqwest::Url, + epoch: u64, + ) -> Result { + let mut cached = self.token.lock().await; + if let Some(token) = cached.as_ref() { + if Instant::now() < token.refresh_after { + return Ok(token.token.clone()); + } + } + self.ensure_consent_unrevoked(epoch)?; + let fresh = bootstrap_upload_token(client, bootstrap_url, self.installation_id()).await?; + let value = fresh.token.clone(); + *cached = Some(fresh); + Ok(value) + } + + /// Drops the cached token, but only if it is still the one the gateway + /// just rejected. Concurrent exports each 401 on the same stale token + /// (every window runs its own `BatchLogRecordProcessor`), and an + /// unconditional clear would let each of them discard the fresh token a + /// sibling just cached and re-bootstrap — a stampede against the + /// per-install rate-limited endpoint, where a tripped limiter permanently + /// drops the batch. Compare-and-clear means only the first invalidation + /// clears; the rest reuse the sibling's fresh token on retry. + async fn invalidate_upload_token(&self, rejected_token: &str) { + let mut cached = self.token.lock().await; + if cached + .as_ref() + .is_some_and(|token| token.token == rejected_token) + { + *cached = None; + } + } +} + +/// Whether this build enforces telemetry ON, skipping the user setting (the +/// renderer also hides the toggle). Mapped from `VITE_TELEMETRY_ENFORCED=1` by +/// every gate resolver — `scripts/block-feature-gates.sh` (dev and Unix +/// bundles), `Get-BerdAppFeatures` in `scripts/windows/WindowsDev.psm1`, and +/// `scripts/release/build-macos.sh` — so the renderer's build flag and this +/// Cargo feature always move together. The resolvers are pinned equal by +/// `scripts/release/tests/release-scripts.test.mjs`; a build that set only the +/// renderer flag would hide the consent toggle and then reject every export. +fn telemetry_enforced_by_build() -> bool { + cfg!(feature = "block-telemetry-enforced") +} + +/// On-disk shape of the consent file. The schema version is pinned exactly: +/// a file from a future schema fails closed (reads as disabled) rather than +/// guessing at consent semantics this build does not know. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PersistedTelemetrySettings { + schema_version: u32, + enabled: bool, +} + +/// Wire shape of `get_telemetry_settings` / `set_telemetry_enabled`: the +/// *effective* consent, i.e. what the export gate will actually apply. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TelemetrySettings { + pub enabled: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OtelLogsExportResponse { + pub status: u16, + pub status_text: String, + pub body: String, +} + +/// Wire shape of `get_telemetry_resource`: the native half of the OTel +/// `Resource` the renderer stamps on every upload. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TelemetryResource { + pub installation_id: String, + pub channel: TelemetryChannel, +} + +/// Returns the native half of the renderer's OTel `Resource`: the persistent +/// anonymous installation id (stamped as `installation.id`) and the +/// distribution channel the staged distro config declares (stamped as +/// `distribution.channel` — `"public"` when no distro bundle or no `telemetry` +/// section is present). +#[tauri::command] +pub fn get_telemetry_resource( + state: tauri::State<'_, TelemetryAuthState>, + distro: tauri::State<'_, DistroBundleState>, +) -> TelemetryResource { + TelemetryResource { + installation_id: state.installation_id().to_string(), + channel: distro.telemetry_channel(), + } +} + +/// Returns the effective telemetry consent for this installation, read from +/// the Rust-owned settings file (missing file = disabled) or forced ON by an +/// enforced build. +#[tauri::command] +pub fn get_telemetry_settings(state: tauri::State<'_, TelemetryAuthState>) -> TelemetrySettings { + state.settings() +} + +/// Persists the user's telemetry consent atomically (write-then-rename) and +/// applies it immediately — the next export sees the new value with no restart, +/// and a revocation also aborts exports already in flight at their next network +/// boundary. Refused in enforced builds, where consent is not user-settable. +#[tauri::command] +pub fn set_telemetry_enabled( + state: tauri::State<'_, TelemetryAuthState>, + enabled: bool, +) -> Result { + state.set_consent(enabled) +} + +/// Exports a batch of OTel log records (already serialized to OTLP/HTTP JSON) +/// to the approved telemetry-gateway `/v1/logs` endpoint through native +/// networking, so WebView CORS cannot block the renderer's OTLP exporter. The +/// endpoint host must be allowlisted; auth is a short-lived upload token +/// bootstrapped here (see `export_with_bootstrap_auth` for the single 401 +/// retry), and every upload declares the body contract it was serialized +/// against (see `TELEMETRY_SCHEMA_VERSION`). Other HTTP 4xx/5xx responses are +/// returned to the renderer, whose `BatchLogRecordProcessor` drops the failed +/// batch — there is no renderer-side retry. Bodies are not size-checked here: +/// the renderer caps batch size and attribute-value length (see +/// `MAX_LOG_EXPORT_BATCH_SIZE` in `src/shared/telemetry/client.ts`) so a full +/// batch stays under the gateway's request-body limit, which it would otherwise +/// answer with a 413 that costs the whole batch. The body is sent as plain +/// uncompressed JSON: the gateway parses the raw bytes, so any +/// `content-encoding` would come back a terminal 400. +#[tauri::command] +pub async fn export_otel_logs( + state: tauri::State<'_, TelemetryAuthState>, + endpoint: String, + body: String, +) -> Result { + export_otel_logs_for_state(state.inner(), &endpoint, body).await +} + +/// The native enforcement gate plus the export itself. Consent is checked +/// before anything else — ahead of even endpoint validation — so a disabled +/// installation sends no bytes to the gateway at all, including the bootstrap +/// request that carries the installation id. This also catches records the +/// renderer's batch processor had already queued when the user flipped +/// telemetry off: they reach this command, and stop here. +/// +/// One check at entry would not be enough, because the awaits below can run for +/// tens of seconds — the token mutex behind a sibling's bootstrap, the bootstrap +/// response itself, a 401 re-auth — while the settings toggle confirms "off" +/// synchronously. So the revocation epoch is snapshotted here, *before* the gate +/// so a revocation racing entry supersedes it too, and re-checked at every +/// network boundary the export goes on to cross (see +/// `ensure_consent_unrevoked`). +/// +/// Residual, and the best a check-before-send design can do: a request already +/// on the wire when revocation lands cannot be recalled, so post-revocation +/// traffic is bounded to at most that single in-flight HTTP request. +async fn export_otel_logs_for_state( + auth: &TelemetryAuthState, + endpoint: &str, + body: String, +) -> Result { + let epoch = auth.revocation_epoch(); + if !auth.consent_granted() { + return Err("Telemetry is disabled for this installation".to_string()); + } + let logs_url = allowed_otel_logs_endpoint(endpoint)?; + let bootstrap_url = telemetry_bootstrap_url(&logs_url)?; + export_with_bootstrap_auth(client(), auth, epoch, &logs_url, &bootstrap_url, body).await +} + +/// Uploads one OTLP batch with bootstrap-token auth. On a 401 — the gateway's +/// only auth-failure code — the rejected token is invalidated +/// (compare-and-clear, see `invalidate_upload_token`), a fresh one is fetched +/// — from the cache when a concurrent export already re-bootstrapped, from +/// `/v1/bootstrap` otherwise — and the same body is retried exactly once (the +/// gateway has confirmed the single retry is idempotency-safe). This native +/// retry is the only retry anywhere in the pipeline: the renderer's +/// `BatchLogRecordProcessor` drops failed batches, so a batch hitting token +/// expiry would otherwise be permanently lost. +/// +/// `epoch` is the caller's revocation snapshot: consent is re-checked before +/// each of the two possible uploads, and inside `upload_token` before either +/// bootstrap, so a revocation arriving mid-export stops the export's next +/// request rather than only its next batch. +async fn export_with_bootstrap_auth( + client: &reqwest::Client, + auth: &TelemetryAuthState, + epoch: u64, + logs_url: &reqwest::Url, + bootstrap_url: &reqwest::Url, + body: String, +) -> Result { + let token = auth.upload_token(client, bootstrap_url, epoch).await?; + auth.ensure_consent_unrevoked(epoch)?; + let response = post_otel_logs(client, logs_url, &token, body.clone()).await?; + if response.status != 401 { + return Ok(response); + } + + auth.invalidate_upload_token(&token).await; + let token = auth.upload_token(client, bootstrap_url, epoch).await?; + auth.ensure_consent_unrevoked(epoch)?; + post_otel_logs(client, logs_url, &token, body).await +} + +async fn post_otel_logs( + client: &reqwest::Client, + logs_url: &reqwest::Url, + token: &str, + body: String, +) -> Result { + let response = client + .post(logs_url.clone()) + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/json") + .header(AUTHORIZATION, format!("Bearer {token}")) + .header(TELEMETRY_SCHEMA_VERSION_HEADER, TELEMETRY_SCHEMA_VERSION) + .timeout(OTEL_LOGS_REQUEST_TIMEOUT) + .body(body) + .send() + .await + .map_err(|error| { + format!( + "Failed to export OTel logs to {}: {error}", + logs_url.as_str() + ) + })?; + + let status = response.status(); + let status_text = status.canonical_reason().unwrap_or("").to_string(); + let body = response.text().await.map_err(|error| { + format!( + "Failed to read OTel logs response from {}: {error}", + logs_url + ) + })?; + + Ok(OtelLogsExportResponse { + status: status.as_u16(), + status_text, + body, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BootstrapResponse { + token: String, + expires_in_seconds: u64, +} + +/// Anonymously bootstraps a short-lived upload-only token from the gateway, +/// keyed on the installation id. +async fn bootstrap_upload_token( + client: &reqwest::Client, + bootstrap_url: &reqwest::Url, + installation_id: &str, +) -> Result { + let response = client + .post(bootstrap_url.clone()) + .header(ACCEPT, "application/json") + .timeout(OTEL_LOGS_REQUEST_TIMEOUT) + .json(&serde_json::json!({ "installationId": installation_id })) + .send() + .await + .map_err(|error| { + format!("Telemetry bootstrap request to {bootstrap_url} failed: {error}") + })?; + + let status = response.status(); + if !status.is_success() { + return Err(format!( + "Telemetry bootstrap to {bootstrap_url} failed: {status}" + )); + } + + let response: BootstrapResponse = response.json().await.map_err(|error| { + format!("Invalid telemetry bootstrap response from {bootstrap_url}: {error}") + })?; + + // A TTL at or below the margin yields an already-stale cache entry, so + // every export re-bootstraps — correct, if wasteful, for a gateway that + // issues very short tokens. + let ttl = Duration::from_secs(response.expires_in_seconds).min(MAX_TOKEN_TTL); + Ok(CachedUploadToken { + token: response.token, + refresh_after: Instant::now() + ttl.saturating_sub(TOKEN_REFRESH_MARGIN), + }) +} + +/// Loads the persisted installation id, generating and persisting a fresh UUID +/// when the file is missing or holds a value the gateway would reject. +fn load_or_create_installation_id(app_data_dir: &Path) -> Result { + let path = app_data_dir.join(INSTALLATION_ID_FILE_NAME); + if let Ok(existing) = fs::read_to_string(&path) { + let existing = existing.trim(); + if is_valid_installation_id(existing) { + return Ok(existing.to_string()); + } + } + + let id = Uuid::new_v4().to_string(); + fs::create_dir_all(app_data_dir).map_err(|error| { + format!( + "Failed to create app data dir {}: {error}", + app_data_dir.display() + ) + })?; + // Write-then-rename so a crash can never leave a torn id on disk. + let tmp = path.with_extension("tmp"); + fs::write(&tmp, &id).map_err(|error| format!("Failed to write {}: {error}", tmp.display()))?; + fs::rename(&tmp, &path) + .map_err(|error| format!("Failed to persist {}: {error}", path.display()))?; + Ok(id) +} + +/// Loads the persisted telemetry consent. Fail-closed: a missing file (the +/// normal first-run state — telemetry is opt-in), an unreadable file, invalid +/// JSON, or an unknown schema version all read as disabled. +fn load_telemetry_consent(app_data_dir: &Path) -> bool { + let path = app_data_dir.join(TELEMETRY_SETTINGS_FILE_NAME); + let Ok(raw) = fs::read_to_string(&path) else { + return false; + }; + match serde_json::from_str::(&raw) { + Ok(settings) if settings.schema_version == TELEMETRY_SETTINGS_SCHEMA_VERSION => { + settings.enabled + } + Ok(settings) => { + log::warn!( + "Ignoring telemetry settings with unknown schema version {}", + settings.schema_version + ); + false + } + Err(error) => { + log::warn!("Ignoring unreadable telemetry settings: {error}"); + false + } + } +} + +/// Persists the telemetry consent with a write-then-rename, mirroring the +/// installation-id write, so a crash can never leave a torn settings file — +/// and a torn file would read as disabled anyway. The rename replaces an +/// existing destination on Windows too — `std::fs::rename`'s documented +/// contract, via `MoveFileExW`/`FileRenameInfoEx` with replace-if-exists, +/// unlike C's `rename` — so re-toggling consent needs no remove-first step. +fn persist_telemetry_consent(app_data_dir: &Path, enabled: bool) -> Result<(), String> { + let path = app_data_dir.join(TELEMETRY_SETTINGS_FILE_NAME); + fs::create_dir_all(app_data_dir).map_err(|error| { + format!( + "Failed to create app data dir {}: {error}", + app_data_dir.display() + ) + })?; + let body = serde_json::to_string_pretty(&PersistedTelemetrySettings { + schema_version: TELEMETRY_SETTINGS_SCHEMA_VERSION, + enabled, + }) + .map_err(|error| format!("Failed to serialize telemetry settings: {error}"))?; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, body).map_err(|error| format!("Failed to write {}: {error}", tmp.display()))?; + fs::rename(&tmp, &path) + .map_err(|error| format!("Failed to persist {}: {error}", path.display()))?; + Ok(()) +} + +/// Mirrors the gateway's accepted installation-id shape +/// (`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`), so a corrupted file regenerates +/// instead of bootstrapping with a value the gateway will reject. +fn is_valid_installation_id(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (8..=128).contains(&value.len()) + && first.is_ascii_alphanumeric() + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) +} + +/// Validates that an OTLP logs endpoint is HTTPS, targets an allowlisted +/// gateway host (rejecting look-alike / spoofed-suffix hosts) on its default +/// port with no userinfo, and follows the `/v1/logs` path convention the +/// bootstrap derivation relies on. Userinfo is rejected because reqwest turns +/// URL credentials into a renderer-controlled `Authorization: Basic` header — +/// on the logs upload ahead of the Bearer token, and on the otherwise +/// anonymous bootstrap request, which derives its URL from this one. An +/// explicit port is rejected because the host allowlist compares `host_str()` +/// alone, which would otherwise admit any other service on an allowed host. +fn allowed_otel_logs_endpoint(raw_url: &str) -> Result { + let url = reqwest::Url::parse(raw_url) + .map_err(|error| format!("Invalid OTel logs endpoint {raw_url}: {error}"))?; + + if url.scheme() != "https" { + return Err(format!("OTel logs endpoint must use https: {raw_url}")); + } + + // Deliberately does not echo the URL: it carries the credentials. + if !url.username().is_empty() || url.password().is_some() { + return Err("OTel logs endpoint must not include credentials".to_string()); + } + + match url.host_str() { + Some(host) if ALLOWED_OTEL_LOGS_HOSTS.contains(&host) => {} + _ => return Err(format!("OTel logs endpoint host is not allowed: {raw_url}")), + } + + if url.port().is_some() { + return Err(format!( + "OTel logs endpoint must not include an explicit port: {raw_url}" + )); + } + + if !url.path().ends_with(OTEL_LOGS_PATH) { + return Err(format!( + "OTel logs endpoint path must end with {OTEL_LOGS_PATH}: {raw_url}" + )); + } + + Ok(url) +} + +/// Derives the anonymous bootstrap endpoint from the validated logs endpoint: +/// same scheme/host (and any path prefix), with the trailing `/v1/logs` +/// swapped for `/v1/bootstrap` — so a real-host swap needs no +/// bootstrap-specific configuration. +fn telemetry_bootstrap_url(logs_url: &reqwest::Url) -> Result { + let prefix = logs_url + .path() + .strip_suffix(OTEL_LOGS_PATH) + .ok_or_else(|| { + format!("OTel logs endpoint path must end with {OTEL_LOGS_PATH}: {logs_url}") + })?; + let mut url = logs_url.clone(); + url.set_path(&format!("{prefix}{TELEMETRY_BOOTSTRAP_PATH}")); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +fn client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .connect_timeout(OTEL_LOGS_CONNECT_TIMEOUT) + .redirect(Policy::none()) + .build() + .expect("failed to build OTel logs HTTP client") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex as StdMutex, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + #[derive(Clone, Debug)] + struct RecordedRequest { + path: String, + /// Every header line as received, names lowercased, in wire order — so + /// a test can assert a header was sent *exactly once* rather than + /// merely present. The gateway rejects a comma-joined + /// `x-berd-schema-version` the same way it rejects a missing one. + headers: Vec<(String, String)>, + body: String, + } + + impl RecordedRequest { + fn header_values(&self, name: &str) -> Vec<&str> { + self.headers + .iter() + .filter(|(header, _)| header == name) + .map(|(_, value)| value.as_str()) + .collect() + } + + fn header(&self, name: &str) -> Option<&str> { + self.header_values(name).first().copied() + } + + fn authorization(&self) -> Option<&str> { + self.header("authorization") + } + } + + struct ScriptedResponse { + status: u16, + body: String, + } + + /// Minimal HTTP/1.1 gateway double: records every request and answers with + /// the script's response. `Connection: close` keeps reqwest from reusing + /// sockets, so one connection carries exactly one request. + struct TestGateway { + base_url: String, + requests: Arc>>, + } + + impl TestGateway { + async fn spawn(respond: F) -> Self + where + F: Fn(&RecordedRequest) -> ScriptedResponse + Send + Sync + 'static, + { + Self::spawn_async(move |request| { + let response = respond(&request); + std::future::ready(response) + }) + .await + } + + /// `spawn` with a responder that may await, so a test can hold a + /// response open at a chosen point in the export flow (see the consent + /// module's revocation tests). Requests are recorded *before* the + /// responder runs, so a test can poll `requests_to` to learn the export + /// has reached this step while the response is still pending. + async fn spawn_async(respond: F) -> Self + where + F: Fn(RecordedRequest) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let requests: Arc>> = Arc::default(); + let recorded = requests.clone(); + let respond = Arc::new(respond); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let recorded = recorded.clone(); + let respond = respond.clone(); + tokio::spawn(async move { + let request = read_request(&mut stream).await; + recorded.lock().unwrap().push(request.clone()); + let response = respond(request).await; + let payload = format!( + "HTTP/1.1 {} Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + response.status, + response.body.len(), + response.body + ); + let _ = stream.write_all(payload.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + Self { + base_url: format!("http://{addr}"), + requests, + } + } + + fn url(&self, path: &str) -> reqwest::Url { + reqwest::Url::parse(&format!("{}{path}", self.base_url)).unwrap() + } + + fn requests_to(&self, path: &str) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter(|request| request.path == path) + .cloned() + .collect() + } + } + + async fn read_request(stream: &mut TcpStream) -> RecordedRequest { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + let header_end = loop { + if let Some(pos) = find_subslice(&buf, b"\r\n\r\n") { + break pos + 4; + } + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "connection closed before headers arrived"); + buf.extend_from_slice(&chunk[..n]); + }; + + let head = String::from_utf8_lossy(&buf[..header_end]).to_string(); + let mut lines = head.lines(); + let request_line = lines.next().unwrap_or_default(); + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + let mut content_length = 0usize; + let mut headers = Vec::new(); + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.to_ascii_lowercase(); + let value = value.trim().to_string(); + if name == "content-length" { + content_length = value.parse().unwrap_or(0); + } + headers.push((name, value)); + } + + while buf.len() < header_end + content_length { + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "connection closed before body arrived"); + buf.extend_from_slice(&chunk[..n]); + } + let body = + String::from_utf8_lossy(&buf[header_end..header_end + content_length]).to_string(); + + RecordedRequest { + path, + headers, + body, + } + } + + fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) + } + + fn test_auth_state() -> (tempfile::TempDir, TelemetryAuthState) { + let dir = tempfile::tempdir().unwrap(); + let state = TelemetryAuthState::new(dir.path().to_path_buf()); + (dir, state) + } + + /// Auth state with consent already persisted, which every test that drives + /// an actual upload needs: consent is re-read at each network boundary the + /// export crosses (see `ensure_consent_unrevoked`), not only at the entry + /// gate, so an unconsented state now aborts the transport path as well. + fn consented_auth_state() -> (tempfile::TempDir, TelemetryAuthState) { + let dir = tempfile::tempdir().unwrap(); + persist_telemetry_consent(dir.path(), true).unwrap(); + let state = TelemetryAuthState::new(dir.path().to_path_buf()); + (dir, state) + } + + /// Responds to `/v1/bootstrap` with sequentially numbered tokens + /// (`token-1`, `token-2`, …) of the given TTL; other paths fall through to + /// `logs`. + fn gateway_script( + ttl_seconds: u64, + logs: impl Fn(&RecordedRequest) -> ScriptedResponse + Send + Sync + 'static, + ) -> impl Fn(&RecordedRequest) -> ScriptedResponse + Send + Sync + 'static { + let bootstrap_count = Arc::new(AtomicUsize::new(0)); + move |request| { + if request.path == "/v1/bootstrap" { + let n = bootstrap_count.fetch_add(1, Ordering::SeqCst) + 1; + ScriptedResponse { + status: 200, + body: format!( + r#"{{"token":"token-{n}","tokenType":"Bearer","expiresInSeconds":{ttl_seconds}}}"# + ), + } + } else { + logs(request) + } + } + } + + const EXPORT_BODY: &str = r#"{"resourceLogs":[]}"#; + + #[tokio::test] + async fn bootstraps_once_and_reuses_the_cached_token() { + let gateway = TestGateway::spawn(gateway_script(900, |_| ScriptedResponse { + status: 200, + body: "{}".to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + + for _ in 0..2 { + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + assert_eq!(response.status, 200); + } + + let bootstraps = gateway.requests_to("/v1/bootstrap"); + assert_eq!(bootstraps.len(), 1); + assert_eq!( + bootstraps[0].body, + format!(r#"{{"installationId":"{}"}}"#, auth.installation_id()) + ); + + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs.len(), 2); + for request in &logs { + assert_eq!(request.authorization(), Some("Bearer token-1")); + assert_eq!(request.body, EXPORT_BODY); + } + } + + #[tokio::test] + async fn refreshes_the_token_proactively_before_expiry() { + // A TTL inside the refresh margin makes the cached token immediately + // stale, so the second export must re-bootstrap without ever seeing a + // server-side rejection. + let gateway = TestGateway::spawn(gateway_script(30, |_| ScriptedResponse { + status: 200, + body: "{}".to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + + for _ in 0..2 { + export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + } + + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 2); + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs[0].authorization(), Some("Bearer token-1")); + assert_eq!(logs[1].authorization(), Some("Bearer token-2")); + } + + #[tokio::test] + async fn clamps_an_unbounded_bootstrap_ttl_instead_of_panicking() { + // `expiresInSeconds` deserializes as an unbounded u64, and + // `Instant + Duration` panics on overflow — before the clamp, a + // gateway answering u64::MAX panicked the export task with the + // token-cache lock held. The clamped token must still cache: the + // second export reuses it rather than re-bootstrapping. + let gateway = TestGateway::spawn(gateway_script(u64::MAX, |_| ScriptedResponse { + status: 200, + body: "{}".to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + + for _ in 0..2 { + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + assert_eq!(response.status, 200); + } + + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 1); + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs.len(), 2); + assert_eq!(logs[1].authorization(), Some("Bearer token-1")); + } + + #[tokio::test] + async fn reauths_and_retries_the_same_body_once_on_401() { + let gateway = TestGateway::spawn(gateway_script(900, |request| { + if request.authorization() == Some("Bearer token-1") { + ScriptedResponse { + status: 401, + body: r#"{"error":"invalid_bearer_token"}"#.to_string(), + } + } else { + ScriptedResponse { + status: 200, + body: "{}".to_string(), + } + } + })) + .await; + let (_dir, auth) = consented_auth_state(); + + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + + assert_eq!(response.status, 200); + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 2); + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs.len(), 2); + assert_eq!(logs[0].authorization(), Some("Bearer token-1")); + assert_eq!(logs[1].authorization(), Some("Bearer token-2")); + // The retry re-sends the exact same batch. + assert_eq!(logs[0].body, logs[1].body); + } + + /// Deterministic replay of the concurrent-export race the compare-and-clear + /// exists for: N exports 401 on the same stale token, the first re-auth + /// caches a fresh one, and each straggler then invalidates. An + /// unconditional clear would discard the fresh token every time and + /// re-bootstrap N times against the per-install rate-limited endpoint; + /// with compare-and-clear the straggler's invalidation is a no-op and its + /// retry rides the sibling's token. + #[tokio::test] + async fn a_stragglers_invalidation_keeps_a_siblings_fresh_token() { + let gateway = TestGateway::spawn(gateway_script(900, |request| { + if request.authorization() == Some("Bearer token-1") { + ScriptedResponse { + status: 401, + body: r#"{"error":"invalid_bearer_token"}"#.to_string(), + } + } else { + ScriptedResponse { + status: 200, + body: "{}".to_string(), + } + } + })) + .await; + let (_dir, auth) = consented_auth_state(); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + + // First export: token-1 is rejected, the re-auth caches token-2. + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 2); + + // A straggler that also 401ed on token-1 invalidates after the + // sibling's re-auth: the cache holds token-2, so nothing clears and + // its retry reuses the sibling's token without a third bootstrap. + auth.invalidate_upload_token("token-1").await; + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 2); + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs.last().unwrap().authorization(), Some("Bearer token-2")); + + // Invalidating with the token actually cached still clears, so a + // genuine rejection of the current token re-bootstraps as before. + auth.invalidate_upload_token("token-2").await; + export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 3); + } + + #[tokio::test] + async fn retries_at_most_once_on_repeated_401() { + let gateway = TestGateway::spawn(gateway_script(900, |_| ScriptedResponse { + status: 401, + body: r#"{"error":"invalid_bearer_token"}"#.to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + + let response = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + + // The second 401 is surfaced to the renderer, not retried again. + assert_eq!(response.status, 401); + assert_eq!(gateway.requests_to("/v1/logs").len(), 2); + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 2); + } + + #[tokio::test] + async fn bootstrap_failure_fails_the_export_without_posting_logs() { + let gateway = TestGateway::spawn(|_| ScriptedResponse { + status: 500, + body: r#"{"error":"internal"}"#.to_string(), + }) + .await; + let (_dir, auth) = consented_auth_state(); + + let error = export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap_err(); + + assert!(error.contains("bootstrap")); + assert!(gateway.requests_to("/v1/logs").is_empty()); + } + + /// The gateway validates every upload against a registered wire-contract + /// version and 400s a missing, empty, or comma-joined header — terminal for + /// that batch, which the renderer then drops. The retry has to carry it too, + /// since it is a second upload, not a resend of the first request's headers. + #[tokio::test] + async fn declares_the_schema_version_on_every_upload_including_the_retry() { + let gateway = TestGateway::spawn(gateway_script(900, |request| { + if request.authorization() == Some("Bearer token-1") { + ScriptedResponse { + status: 401, + body: r#"{"error":"invalid_bearer_token"}"#.to_string(), + } + } else { + ScriptedResponse { + status: 200, + body: "{}".to_string(), + } + } + })) + .await; + let (_dir, auth) = consented_auth_state(); + + export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + + let logs = gateway.requests_to("/v1/logs"); + assert_eq!(logs.len(), 2); + for request in &logs { + // Exactly once: a header sent twice reaches the gateway + // comma-joined, which it rejects like a missing one. + assert_eq!( + request.header_values(TELEMETRY_SCHEMA_VERSION_HEADER), + vec!["berd-otlp-logs-v1"] + ); + assert_eq!(request.header("content-type"), Some("application/json")); + // The gateway hands the raw request bytes to its JSON parser, so a + // compressed body is a 400 rather than something it decodes. + assert_eq!(request.header("content-encoding"), None); + } + } + + #[tokio::test] + async fn bootstrap_posts_json_without_the_schema_version_header() { + let gateway = TestGateway::spawn(gateway_script(900, |_| ScriptedResponse { + status: 200, + body: "{}".to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + + export_with_bootstrap_auth( + client(), + &auth, + auth.revocation_epoch(), + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap(); + + let bootstraps = gateway.requests_to("/v1/bootstrap"); + assert_eq!(bootstraps.len(), 1); + // `content-type: application/json` is required — the gateway answers a + // missing or other content type with a 415. + assert_eq!( + bootstraps[0].header("content-type"), + Some("application/json") + ); + // Bootstrap does not read the schema version: it exchanges an + // installation id for a token and never sees a log body. + assert!(bootstraps[0] + .header_values(TELEMETRY_SCHEMA_VERSION_HEADER) + .is_empty()); + // Anonymous: the token being fetched cannot authenticate its own fetch. + assert_eq!(bootstraps[0].authorization(), None); + } + + #[test] + fn creates_and_persists_the_installation_id() { + let dir = tempfile::tempdir().unwrap(); + + let first = load_or_create_installation_id(dir.path()).unwrap(); + let second = load_or_create_installation_id(dir.path()).unwrap(); + + assert_eq!(first, second); + assert!(is_valid_installation_id(&first)); + assert_eq!( + fs::read_to_string(dir.path().join(INSTALLATION_ID_FILE_NAME)).unwrap(), + first + ); + } + + #[test] + fn regenerates_an_invalid_persisted_installation_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(INSTALLATION_ID_FILE_NAME); + fs::write(&path, "bad id!").unwrap(); + + let id = load_or_create_installation_id(dir.path()).unwrap(); + + assert!(is_valid_installation_id(&id)); + assert_eq!(fs::read_to_string(&path).unwrap(), id); + } + + #[test] + fn installation_id_is_stable_across_state_instances() { + let dir = tempfile::tempdir().unwrap(); + let first = TelemetryAuthState::new(dir.path().to_path_buf()); + let second = TelemetryAuthState::new(dir.path().to_path_buf()); + + assert_eq!(first.installation_id(), second.installation_id()); + } + + #[test] + fn telemetry_resource_serializes_the_renderer_wire_shape() { + // The renderer validates this exact shape (camelCase keys, lowercase + // channel literals) before stamping the OTel resource. + let resource = TelemetryResource { + installation_id: "11111111-2222-4333-8444-555555555555".to_string(), + channel: TelemetryChannel::Internal, + }; + + assert_eq!( + serde_json::to_value(&resource).unwrap(), + serde_json::json!({ + "installationId": "11111111-2222-4333-8444-555555555555", + "channel": "internal" + }) + ); + } + + // Consent is opt-in and fail-closed, so the enforced build inverts most of + // these expectations; its behavior is pinned separately below. + #[cfg(not(feature = "block-telemetry-enforced"))] + mod consent { + use super::*; + use tokio::sync::Semaphore; + + #[test] + fn defaults_to_disabled_without_a_settings_file() { + let (_dir, auth) = test_auth_state(); + + assert!(!auth.consent_granted()); + assert!(!auth.settings().enabled); + } + + #[test] + fn set_consent_persists_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let auth = TelemetryAuthState::new(dir.path().to_path_buf()); + + let settings = auth.set_consent(true).unwrap(); + assert!(settings.enabled); + assert!(auth.consent_granted()); + + // The file is schema-versioned JSON a fresh state (a new app + // start) reads back during construction. + let raw = fs::read_to_string(dir.path().join(TELEMETRY_SETTINGS_FILE_NAME)).unwrap(); + let persisted: PersistedTelemetrySettings = serde_json::from_str(&raw).unwrap(); + assert_eq!(persisted.schema_version, TELEMETRY_SETTINGS_SCHEMA_VERSION); + assert!(persisted.enabled); + + let restarted = TelemetryAuthState::new(dir.path().to_path_buf()); + assert!(restarted.consent_granted()); + + restarted.set_consent(false).unwrap(); + assert!(!restarted.consent_granted()); + assert!(!TelemetryAuthState::new(dir.path().to_path_buf()).consent_granted()); + } + + #[test] + fn corrupt_settings_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(TELEMETRY_SETTINGS_FILE_NAME), + "not json at all", + ) + .unwrap(); + + assert!(!load_telemetry_consent(dir.path())); + } + + #[test] + fn future_schema_versions_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join(TELEMETRY_SETTINGS_FILE_NAME), + r#"{"schemaVersion":2,"enabled":true}"#, + ) + .unwrap(); + + assert!(!load_telemetry_consent(dir.path())); + } + + /// The gate has to fire before *anything* else — no endpoint + /// validation, no bootstrap, no upload. The allowlisted host here is + /// unreachable, so this test staying instant (no connect timeout) is + /// itself evidence no network was attempted. + #[tokio::test] + async fn export_refuses_before_any_network_io_when_consent_is_off() { + let (_dir, auth) = test_auth_state(); + + let error = export_otel_logs_for_state( + &auth, + "https://otlp.invalid.goose-internal.example/v1/logs", + EXPORT_BODY.to_string(), + ) + .await + .unwrap_err(); + + assert!(error.contains("disabled")); + } + + /// With consent granted the gate passes and the export proceeds into + /// the existing validation/auth path (whose mechanics the tests above + /// pin): a non-HTTPS endpoint now fails on *validation*, not consent. + #[tokio::test] + async fn export_passes_the_gate_once_consent_is_granted() { + let dir = tempfile::tempdir().unwrap(); + persist_telemetry_consent(dir.path(), true).unwrap(); + let auth = TelemetryAuthState::new(dir.path().to_path_buf()); + + let error = export_otel_logs_for_state( + &auth, + "http://otlp.invalid.goose-internal.example/v1/logs", + EXPORT_BODY.to_string(), + ) + .await + .unwrap_err(); + + assert!(error.contains("https")); + } + + /// Polls until the gateway has recorded a request to `path`, so a test + /// can act — revoke consent — at a known point in an export's flow. The + /// double records a request before running its responder, so this + /// returns while a paused response is still pending. + async fn wait_for_request(gateway: &TestGateway, path: &str) { + for _ in 0..500 { + if !gateway.requests_to(path).is_empty() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("gateway never received a request to {path}"); + } + + fn bootstrap_response(token: &str) -> ScriptedResponse { + ScriptedResponse { + status: 200, + body: format!( + r#"{{"token":"{token}","tokenType":"Bearer","expiresInSeconds":900}}"# + ), + } + } + + /// An export that is awaiting the bootstrap response when consent is + /// revoked must not go on to upload the batch: the entry gate has + /// already passed, so only the pre-upload re-check can stop it. + #[tokio::test] + async fn revoking_consent_while_bootstrap_is_pending_aborts_before_the_logs_post() { + let release = Arc::new(Semaphore::new(0)); + let gate = release.clone(); + let gateway = TestGateway::spawn_async(move |request| { + let gate = gate.clone(); + async move { + if request.path == "/v1/bootstrap" { + // Held open until the test has revoked consent. + let _permit = gate.acquire().await; + bootstrap_response("token-1") + } else { + ScriptedResponse { + status: 200, + body: "{}".to_string(), + } + } + } + }) + .await; + let (_dir, auth) = consented_auth_state(); + let auth = Arc::new(auth); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + let epoch = auth.revocation_epoch(); + + let export = tokio::spawn({ + let auth = auth.clone(); + async move { + export_with_bootstrap_auth( + client(), + &auth, + epoch, + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + } + }); + + wait_for_request(&gateway, "/v1/bootstrap").await; + auth.set_consent(false).unwrap(); + release.add_permits(1); + + let error = export.await.unwrap().unwrap_err(); + assert!(error.contains("revoked"), "unexpected error: {error}"); + // The bootstrap was already on the wire and cannot be recalled; + // the batch itself never leaves. + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 1); + assert!(gateway.requests_to("/v1/logs").is_empty()); + } + + /// The 401 path is the widest post-gate window: it would otherwise + /// re-bootstrap (a second request carrying the installation id) and + /// re-send the batch after the revocation. + #[tokio::test] + async fn revoking_consent_after_a_401_aborts_before_reauth_and_retry() { + let release = Arc::new(Semaphore::new(0)); + let gate = release.clone(); + let gateway = TestGateway::spawn_async(move |request| { + let gate = gate.clone(); + async move { + if request.path == "/v1/bootstrap" { + bootstrap_response("token-1") + } else { + // Held open until the test has revoked consent, then + // answered with the auth failure that drives the retry. + let _permit = gate.acquire().await; + ScriptedResponse { + status: 401, + body: r#"{"error":"invalid_bearer_token"}"#.to_string(), + } + } + } + }) + .await; + let (_dir, auth) = consented_auth_state(); + let auth = Arc::new(auth); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + let epoch = auth.revocation_epoch(); + + let export = tokio::spawn({ + let auth = auth.clone(); + async move { + export_with_bootstrap_auth( + client(), + &auth, + epoch, + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + } + }); + + wait_for_request(&gateway, "/v1/logs").await; + auth.set_consent(false).unwrap(); + release.add_permits(1); + + let error = export.await.unwrap().unwrap_err(); + assert!(error.contains("revoked"), "unexpected error: {error}"); + // The 401 cleared the cached token, so the re-check inside + // `upload_token` fires ahead of the re-auth request itself. + assert_eq!(gateway.requests_to("/v1/bootstrap").len(), 1); + assert_eq!(gateway.requests_to("/v1/logs").len(), 1); + } + + /// Revocation supersedes: opting back in while the batch is still in + /// flight does not resurrect it, which is what the epoch buys over a + /// plain `consent_granted()` re-check (that would pass this batch). + #[tokio::test] + async fn a_revocation_supersedes_a_regrant_for_in_flight_exports() { + let release = Arc::new(Semaphore::new(0)); + let gate = release.clone(); + let gateway = TestGateway::spawn_async(move |request| { + let gate = gate.clone(); + async move { + if request.path == "/v1/bootstrap" { + let _permit = gate.acquire().await; + bootstrap_response("token-1") + } else { + ScriptedResponse { + status: 200, + body: "{}".to_string(), + } + } + } + }) + .await; + let (_dir, auth) = consented_auth_state(); + let auth = Arc::new(auth); + let logs_url = gateway.url("/v1/logs"); + let bootstrap_url = gateway.url("/v1/bootstrap"); + let epoch = auth.revocation_epoch(); + + let export = tokio::spawn({ + let auth = auth.clone(); + async move { + export_with_bootstrap_auth( + client(), + &auth, + epoch, + &logs_url, + &bootstrap_url, + EXPORT_BODY.to_string(), + ) + .await + } + }); + + wait_for_request(&gateway, "/v1/bootstrap").await; + auth.set_consent(false).unwrap(); + auth.set_consent(true).unwrap(); + release.add_permits(1); + + let error = export.await.unwrap().unwrap_err(); + assert!(error.contains("revoked"), "unexpected error: {error}"); + assert!(auth.consent_granted()); + assert!(gateway.requests_to("/v1/logs").is_empty()); + } + + /// The re-check for the bootstrap request lives inside `upload_token`, + /// after the lock and the cache miss — so a revoked epoch reaches no + /// endpoint at all, not even the one that would fetch a token. + #[tokio::test] + async fn a_revoked_epoch_skips_all_network_on_a_cache_miss() { + let gateway = TestGateway::spawn(gateway_script(900, |_| ScriptedResponse { + status: 200, + body: "{}".to_string(), + })) + .await; + let (_dir, auth) = consented_auth_state(); + let epoch = auth.revocation_epoch(); + auth.set_consent(false).unwrap(); + + let error = export_with_bootstrap_auth( + client(), + &auth, + epoch, + &gateway.url("/v1/logs"), + &gateway.url("/v1/bootstrap"), + EXPORT_BODY.to_string(), + ) + .await + .unwrap_err(); + + assert!(error.contains("revoked"), "unexpected error: {error}"); + assert!(gateway.requests_to("/v1/bootstrap").is_empty()); + assert!(gateway.requests_to("/v1/logs").is_empty()); + } + } + + #[cfg(feature = "block-telemetry-enforced")] + mod enforced_consent { + use super::*; + + #[test] + fn consent_is_granted_without_a_settings_file() { + let (_dir, auth) = test_auth_state(); + + assert!(auth.consent_granted()); + assert!(auth.settings().enabled); + } + + #[test] + fn consent_writes_are_refused() { + let (_dir, auth) = test_auth_state(); + + let error = auth.set_consent(false).unwrap_err(); + + assert!(error.contains("always enabled")); + } + } + + #[test] + fn allows_configured_otlp_endpoint_host() { + let url = allowed_otel_logs_endpoint("https://otlp.invalid.goose-internal.example/v1/logs") + .unwrap(); + + assert_eq!( + url.as_str(), + "https://otlp.invalid.goose-internal.example/v1/logs" + ); + } + + /// Pins the exact URL pair a production build uses: the injected + /// `/v1/logs` endpoint validates against the allowlist and derives the + /// production `/v1/bootstrap` URL. + #[test] + fn allows_the_production_gateway_host_and_derives_its_bootstrap_url() { + let logs_url = allowed_otel_logs_endpoint("https://otel.berd.xyz/v1/logs").unwrap(); + + let bootstrap_url = telemetry_bootstrap_url(&logs_url).unwrap(); + + assert_eq!(logs_url.as_str(), "https://otel.berd.xyz/v1/logs"); + assert_eq!(bootstrap_url.as_str(), "https://otel.berd.xyz/v1/bootstrap"); + } + + /// Pins the exact URL pair a staging build uses: the injected + /// `/v1/logs` endpoint validates against the allowlist and derives the + /// staging `/v1/bootstrap` URL. + #[test] + fn allows_the_staging_gateway_host_and_derives_its_bootstrap_url() { + let logs_url = + allowed_otel_logs_endpoint("https://otel.test.blockstaging.build/v1/logs").unwrap(); + + let bootstrap_url = telemetry_bootstrap_url(&logs_url).unwrap(); + + assert_eq!( + logs_url.as_str(), + "https://otel.test.blockstaging.build/v1/logs" + ); + assert_eq!( + bootstrap_url.as_str(), + "https://otel.test.blockstaging.build/v1/bootstrap" + ); + } + + #[test] + fn rejects_non_allowed_host() { + let error = allowed_otel_logs_endpoint("https://otlp.evil.example/v1/logs").unwrap_err(); + + assert!(error.contains("not allowed")); + } + + #[test] + fn rejects_spoofed_host_suffix() { + let error = allowed_otel_logs_endpoint( + "https://otlp.invalid.goose-internal.example.evil.com/v1/logs", + ) + .unwrap_err(); + + assert!(error.contains("not allowed")); + } + + #[test] + fn rejects_non_https_scheme() { + let error = + allowed_otel_logs_endpoint("http://otlp.invalid.goose-internal.example/v1/logs") + .unwrap_err(); + + assert!(error.contains("https")); + } + + /// URL userinfo would reach the wire as a renderer-controlled + /// `Authorization: Basic` header, including on the anonymous bootstrap + /// request; the error must not echo the credential either. + #[test] + fn rejects_endpoint_with_userinfo_credentials() { + let error = + allowed_otel_logs_endpoint("https://user:hunter2@otel.test.blockstaging.build/v1/logs") + .unwrap_err(); + + assert!(error.contains("credentials")); + assert!(!error.contains("hunter2")); + } + + #[test] + fn rejects_endpoint_with_username_only_userinfo() { + let error = allowed_otel_logs_endpoint("https://user@otel.test.blockstaging.build/v1/logs") + .unwrap_err(); + + assert!(error.contains("credentials")); + } + + /// The allowlist compares `host_str()` alone, so without the port check + /// an allowed host on an arbitrary port would receive the upload token + /// and installation id. + #[test] + fn rejects_endpoint_with_explicit_port() { + let error = allowed_otel_logs_endpoint("https://otel.test.blockstaging.build:8443/v1/logs") + .unwrap_err(); + + assert!(error.contains("port")); + } + + /// The scheme-default port normalizes away during URL parsing instead of + /// tripping the explicit-port rejection. + #[test] + fn allows_the_scheme_default_port() { + let url = + allowed_otel_logs_endpoint("https://otel.test.blockstaging.build:443/v1/logs").unwrap(); + + assert_eq!(url.as_str(), "https://otel.test.blockstaging.build/v1/logs"); + } + + #[test] + fn rejects_endpoint_off_the_logs_path_convention() { + let error = + allowed_otel_logs_endpoint("https://otlp.invalid.goose-internal.example/v1/bootstrap") + .unwrap_err(); + + assert!(error.contains("/v1/logs")); + } + + #[test] + fn derives_the_bootstrap_url_from_the_logs_endpoint() { + let logs_url = + allowed_otel_logs_endpoint("https://otlp.invalid.goose-internal.example/v1/logs") + .unwrap(); + + let bootstrap_url = telemetry_bootstrap_url(&logs_url).unwrap(); + + assert_eq!( + bootstrap_url.as_str(), + "https://otlp.invalid.goose-internal.example/v1/bootstrap" + ); + } + + #[test] + fn bootstrap_url_preserves_a_gateway_path_prefix() { + let logs_url = + reqwest::Url::parse("https://otlp.invalid.goose-internal.example/gateway/v1/logs") + .unwrap(); + + let bootstrap_url = telemetry_bootstrap_url(&logs_url).unwrap(); + + assert_eq!( + bootstrap_url.as_str(), + "https://otlp.invalid.goose-internal.example/gateway/v1/bootstrap" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64e7621ce..23dcf66fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -120,7 +120,11 @@ pub fn run() { APP_LOG_ARCHIVES_KEPT, )) .targets([ - tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout), + // The Stdout formatter greys dev-time telemetry-viewer + // records; the LogDir target keeps no formatter so the + // ANSI escapes never reach `berd.log`. + tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout) + .format(commands::renderer::stdout_log_format), tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir { file_name: Some("berd".into()), }), @@ -211,6 +215,9 @@ pub fn run() { app.manage(commands::pocket_voice::PocketVoiceState::default()); app.manage(commands::native_voice::NativeVoiceState::default()); app.manage(commands::voice_capture::VoiceCaptureState::default()); + app.manage(commands::telemetry::TelemetryAuthState::new( + app_data_dir.clone(), + )); let release_channel_state = commands::updates::ReleaseChannelState::load(app.handle())?; app.manage(release_channel_state); @@ -484,6 +491,10 @@ pub fn run() { commands::builderbot::update_builderbot_scheduled_trigger, #[cfg(feature = "block-builderbot")] commands::builderbot::update_builderbot_routing_rule, + commands::telemetry::export_otel_logs, + commands::telemetry::get_telemetry_resource, + commands::telemetry::get_telemetry_settings, + commands::telemetry::set_telemetry_enabled, commands::whoami::whoami, commands::acp::get_goose_serve_url, commands::acp::get_goose_serve_host_info, diff --git a/src-tauri/src/services/distro_bundle.rs b/src-tauri/src/services/distro_bundle.rs index 0e25e84e4..db3c26128 100644 --- a/src-tauri/src/services/distro_bundle.rs +++ b/src-tauri/src/services/distro_bundle.rs @@ -20,6 +20,45 @@ pub struct DistroManifest { pub diagnostics: Option, pub distribution: Option, pub marketplace: Option, + pub telemetry: Option, +} + +/// The build-artifact channel telemetry reports as the `distribution.channel` +/// OTel resource attribute: which distribution this install came from, not who +/// the user is. Closed set — the ingestion gateway allowlists exactly these +/// values — with `Public` as the universal fallback (no distro bundle, no +/// `telemetry` section, or an unrecognized value). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase", from = "String")] +pub enum TelemetryChannel { + #[default] + Public, + Internal, +} + +impl From for TelemetryChannel { + /// Closed-set validation with a fail-back-to-`Public`: an unrecognized + /// channel mislabels the install's traffic as public rather than failing + /// the whole distro manifest (which would also cost the bundle's kgoose + /// and distribution config). + fn from(value: String) -> Self { + match value.as_str() { + "public" => Self::Public, + "internal" => Self::Internal, + other => { + log::warn!( + "Unknown telemetry.channel '{other}' in distro manifest; defaulting to public" + ); + Self::Public + } + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TelemetryDistroConfig { + pub channel: TelemetryChannel, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -125,11 +164,8 @@ impl DistroBundleState { config_path: None, bin_dir: None, manifest: DistroManifest { - app_version: None, kgoose: Some(kgoose), - diagnostics: None, - distribution: None, - marketplace: None, + ..DistroManifest::default() }, }), } @@ -143,11 +179,8 @@ impl DistroBundleState { config_path: None, bin_dir: None, manifest: DistroManifest { - app_version: None, - kgoose: None, diagnostics: Some(DiagnosticsDistroConfig { checks }), - distribution: None, - marketplace: None, + ..DistroManifest::default() }, }), } @@ -196,6 +229,17 @@ impl DistroBundleState { .as_ref() .and_then(|bundle| bundle.manifest.distribution.as_ref()) } + + /// The distribution channel telemetry stamps as `distribution.channel`: + /// `Public` unless a staged distro manifest explicitly declares + /// `"telemetry": { "channel": "internal" }`. + pub fn telemetry_channel(&self) -> TelemetryChannel { + self.bundle + .as_ref() + .and_then(|bundle| bundle.manifest.telemetry.as_ref()) + .map(|telemetry| telemetry.channel) + .unwrap_or_default() + } } fn load_distro_bundle(app_handle: &AppHandle) -> Result, String> { @@ -380,14 +424,11 @@ mod tests { config_path: None, bin_dir: None, manifest: DistroManifest { - app_version: None, kgoose: Some(KgooseDistroConfig { base_url: Some("https://kgoose.example.test/".to_string()), path: None, }), - diagnostics: None, - distribution: None, - marketplace: None, + ..DistroManifest::default() }, }), }; @@ -507,6 +548,59 @@ mod tests { } } + #[test] + fn parses_the_internal_telemetry_channel() { + let manifest = parse_manifest(r#"{"telemetry":{"channel":"internal"}}"#) + .expect("telemetry section should parse"); + + let telemetry = manifest.telemetry.expect("telemetry should be present"); + assert_eq!(telemetry.channel, TelemetryChannel::Internal); + // The wire spelling the renderer and the gateway both key on. + assert_eq!( + serde_json::to_value(telemetry.channel).expect("channel should serialize"), + serde_json::json!("internal") + ); + } + + #[test] + fn unknown_telemetry_channel_fails_back_to_public() { + // Fail-back, not rejection: a typo'd channel must not cost the whole + // manifest (kgoose/distribution config included) — it just mislabels + // this install's traffic as public. + let manifest = parse_manifest(r#"{"telemetry":{"channel":"beta"}}"#) + .expect("unknown channel should not fail the manifest"); + + assert_eq!( + manifest + .telemetry + .expect("telemetry should be present") + .channel, + TelemetryChannel::Public + ); + } + + #[test] + fn rejects_unknown_telemetry_fields() { + assert!( + parse_manifest(r#"{"telemetry":{"channel":"internal","endpoint":"https://x"}}"#) + .is_err() + ); + } + + #[test] + fn telemetry_channel_defaults_to_public() { + // No bundle at all (a public build), and a bundle without the section. + assert_eq!( + DistroBundleState::empty_for_tests().telemetry_channel(), + TelemetryChannel::Public + ); + assert_eq!( + DistroBundleState::with_kgoose_for_tests(KgooseDistroConfig::default()) + .telemetry_channel(), + TelemetryChannel::Public + ); + } + #[test] fn parses_marketplace_skill_url_template() { let manifest = parse_manifest( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c000b675..8629a1067 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,7 +34,7 @@ "security": { "csp": { "default-src": "'self' asset:", - "connect-src": "'self' ipc: asset: http://asset.localhost http://ipc.localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* data: https:", + "connect-src": "'self' ipc: asset: http://asset.localhost http://ipc.localhost http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:* data: https: https://otel.berd.xyz/v1/logs https://otel.berd.xyz/v1/bootstrap https://otel.test.blockstaging.build/v1/logs https://otel.test.blockstaging.build/v1/bootstrap https://otlp.invalid.goose-internal.example/v1/logs https://otlp.invalid.goose-internal.example/v1/bootstrap", "font-src": "'self'", "img-src": "'self' asset: http://asset.localhost blob: data: https://models.dev", "media-src": "'self' asset: http://asset.localhost blob: data:", diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 806861b20..3b1c39db0 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -36,6 +36,7 @@ import { } from "@/features/settings/lib/settingsEvents"; import type { ExtensionEntry } from "@/features/extensions/types"; import { acceptFirstSend } from "@/features/chat/lib/firstWorkspaceSend"; +import { CHAT_SOURCE_SURFACE } from "@/features/chat/lib/chatTelemetry"; import { admitSystemInheritedQueuedMessage, personaIntentFromComposer, @@ -2955,9 +2956,15 @@ export function AppShell({ : {}), persona: personaIntentFromComposer(options?.personaId), attachments: options?.attachments, - ...(options?.sendOptions - ? { sendOptions: options.sendOptions } - : {}), + sendOptions: { + ...options?.sendOptions, + // A deferred first send is dispatched by the background + // queued-send pipeline, which reads this surface for `berd_chat` + // send telemetry. MAIN_CHAT for parity with this composer's + // non-deferred sends, which drain through the ChatView + // controller and report the same surface. + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + }, }, { queueReady: true, onNeedsName: enqueueWorkspaceNameRequest }, ); diff --git a/src/env.d.ts b/src/env.d.ts index dd10705d0..cd0c7be90 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -16,6 +16,7 @@ declare global { readonly VITE_VOICE_DICTATION?: string; readonly VITE_MANAGED_CONNECTIONS?: string; readonly VITE_TELEMETRY_DEBUG?: string; + readonly VITE_OTLP_LOGS_ENDPOINT?: string; readonly VITE_DESIGN_SYSTEM_EXPLORER?: string; readonly VITE_BERD_G2_BASE_URL?: string; /** @deprecated use VITE_BERD_G2_BASE_URL. */ diff --git a/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx b/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx new file mode 100644 index 000000000..dd0085944 --- /dev/null +++ b/src/features/agents/capabilities/__tests__/AgentBuilderCapability.test.tsx @@ -0,0 +1,173 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/render"; + +const telemetryMocks = vi.hoisted(() => ({ + trackAgentCreateCompleted: vi.fn(), + trackAgentEditCompleted: vi.fn(), + trackAgentDeleteCompleted: vi.fn(), +})); + +const apiMocks = vi.hoisted(() => ({ + createPersonaSource: vi.fn(), + deletePersonaSource: vi.fn(), + promotePersonaSource: vi.fn(), + listPersonaSources: vi.fn(), + readAgentSourceFile: vi.fn(), + updatePersonaSource: vi.fn(), + listPersonas: vi.fn(), +})); + +vi.mock("@/shared/api/agents", () => apiMocks); + +vi.mock("@/features/agents/lib/agentTelemetry", () => telemetryMocks); + +vi.mock("@/features/agents/hooks/useAvatarLibrary", () => ({ + useAvatarLibrary: () => ({ + catalog: null, + cachedAvatarMediaById: {}, + loading: false, + cacheChecking: false, + error: false, + errorCode: null, + mediaError: false, + mediaErrorCode: null, + retryCatalog: () => {}, + retryMedia: () => {}, + }), +})); + +vi.mock("@/features/providers/hooks/useAgentProviderStatus", () => ({ + useAgentProviderStatus: () => ({ + readyAgentIds: new Set(["goose"]), + agentReadiness: new Map([["goose", "ready"]]), + agentChecks: new Map(), + loading: false, + refresh: vi.fn().mockResolvedValue(undefined), + }), +})); + +import { AgentBuilderCapability } from "../AgentBuilderCapability"; +import { saveDraftAgentSession } from "@/features/agents/lib/agentBuilderSession"; +import { resetAgentBuilderSourceLifecycleForTests } from "@/features/agents/lib/agentBuilderSourceLifecycle"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { + useChatSessionStore, + type ChatSession, +} from "@/features/chat/stores/chatSessionStore"; +import { setExperimentEnabled } from "@/features/experiments/experimentPreferences"; +import { AVATAR_COLLECTION_PAGE_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import type { AgentSourceEntry } from "@/shared/api/agents"; + +const existingAgentSource: AgentSourceEntry = { + type: "agent", + path: "/Users/x/.agents/agents/code-reviewer.md", + name: "Code Reviewer", + description: "Reviews code", + content: "Review code carefully.", + properties: { provider: "openai", model: "gpt-5" }, + writable: true, +} as AgentSourceEntry; + +const builderSession: ChatSession = { + id: "s1", + title: "Code Reviewer", + createdAt: "2026-06-09T00:00:00.000Z", + updatedAt: "2026-06-09T00:00:00.000Z", + messageCount: 0, + intent: "build-agent", + agentBuilderOpen: true, + targetAgentPath: existingAgentSource.path, + targetAgentSlug: "code-reviewer", + targetAgentDraftState: null, + targetAgentDraftSaved: true, +}; + +// The leave-builder "Keep" choice and closing the builder both funnel into +// saveDraftAgentSession, which runs the save handler the capability registers +// for the session — the rail's saveNow. These tests drive that whole chain +// with only the agents API mocked, pinning that a Keep save of an existing +// (non-draft) agent tracks exactly when it persists something. +describe("AgentBuilderCapability keep-save telemetry", () => { + beforeEach(() => { + telemetryMocks.trackAgentCreateCompleted.mockReset(); + telemetryMocks.trackAgentEditCompleted.mockReset(); + telemetryMocks.trackAgentDeleteCompleted.mockReset(); + apiMocks.createPersonaSource.mockReset(); + apiMocks.deletePersonaSource.mockReset(); + apiMocks.promotePersonaSource.mockReset(); + apiMocks.listPersonaSources.mockReset(); + apiMocks.readAgentSourceFile.mockReset(); + apiMocks.updatePersonaSource.mockReset(); + apiMocks.listPersonas.mockReset(); + apiMocks.listPersonaSources.mockResolvedValue([existingAgentSource]); + apiMocks.readAgentSourceFile.mockImplementation( + async (_path: string, fallback?: AgentSourceEntry) => + fallback ?? existingAgentSource, + ); + // Mirrors the real API: the update response is the persisted entry the + // telemetry is expected to report. + apiMocks.updatePersonaSource.mockImplementation( + async (_path: string, patch: Partial) => ({ + ...existingAgentSource, + ...patch, + properties: { + ...existingAgentSource.properties, + ...(patch.properties ?? {}), + }, + }), + ); + apiMocks.listPersonas.mockResolvedValue([]); + resetAgentBuilderSourceLifecycleForTests(); + setExperimentEnabled(AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, false); + useAgentStore.setState({ + personas: [], + personasLoading: false, + providers: [], + }); + useChatSessionStore.setState({ + sessions: [builderSession], + hasHydratedSessions: true, + hasMoreSessions: false, + }); + }); + + it("emits Edit Completed when a Keep save persists edits to an existing agent", async () => { + renderWithProviders(); + const nameInput = await screen.findByLabelText(/agent name/i); + expect(nameInput).toHaveValue("Code Reviewer"); + + fireEvent.change(nameInput, { target: { value: "Code Reviewer Deluxe" } }); + expect(telemetryMocks.trackAgentEditCompleted).not.toHaveBeenCalled(); + + await act(async () => { + await saveDraftAgentSession("s1"); + }); + + expect(apiMocks.updatePersonaSource).toHaveBeenCalledTimes(1); + expect(apiMocks.updatePersonaSource).toHaveBeenCalledWith( + existingAgentSource.path, + { name: "Code Reviewer Deluxe" }, + ); + expect(telemetryMocks.trackAgentEditCompleted).toHaveBeenCalledTimes(1); + expect(telemetryMocks.trackAgentEditCompleted).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5", + }); + expect(telemetryMocks.trackAgentCreateCompleted).not.toHaveBeenCalled(); + }); + + it("persists nothing and emits nothing for a Keep save with no pending edits", async () => { + renderWithProviders(); + const nameInput = await screen.findByLabelText(/agent name/i); + expect(nameInput).toHaveValue("Code Reviewer"); + + await act(async () => { + await saveDraftAgentSession("s1"); + }); + + expect(apiMocks.updatePersonaSource).not.toHaveBeenCalled(); + expect(telemetryMocks.trackAgentEditCompleted).not.toHaveBeenCalled(); + expect(telemetryMocks.trackAgentCreateCompleted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/agents/hooks/__tests__/usePersonaSource.test.tsx b/src/features/agents/hooks/__tests__/usePersonaSource.test.tsx index 6f4d7bae7..c15755181 100644 --- a/src/features/agents/hooks/__tests__/usePersonaSource.test.tsx +++ b/src/features/agents/hooks/__tests__/usePersonaSource.test.tsx @@ -773,6 +773,138 @@ describe("usePersonaSource", () => { expect(result.current.data?.name).toBe("Snark"); }); + it("notifies onWritePersisted with the persisted source when saveNow flushes edits", async () => { + const existingSource = { + ...sourceV1, + name: "Code Reviewer", + content: "Review code carefully.", + properties: { provider: "openai", model: "gpt-5" }, + }; + const persistedSource = { ...existingSource, name: "Code Reviewer Deluxe" }; + listMock.mockResolvedValue([existingSource]); + updateMock.mockResolvedValue(persistedSource); + const onWritePersisted = vi.fn(); + + const { result } = renderHook(() => + usePersonaSource(path, { builderSessionId: "sess-1", onWritePersisted }), + ); + await flushPromises(); + + act(() => result.current.update({ name: "Code Reviewer Deluxe" })); + expect(onWritePersisted).not.toHaveBeenCalled(); + + let saved = false; + await act(async () => { + saved = await result.current.saveNow(); + }); + + expect(saved).toBe(true); + expect(onWritePersisted).toHaveBeenCalledTimes(1); + expect(onWritePersisted).toHaveBeenCalledWith(persistedSource); + }); + + it("does not notify onWritePersisted when saveNow has nothing to flush", async () => { + const existingSource = { + ...sourceV1, + name: "Code Reviewer", + content: "Review code carefully.", + properties: {}, + }; + listMock.mockResolvedValue([existingSource]); + const onWritePersisted = vi.fn(); + + const { result } = renderHook(() => + usePersonaSource(path, { builderSessionId: "sess-1", onWritePersisted }), + ); + await flushPromises(); + + let saved = false; + await act(async () => { + saved = await result.current.saveNow(); + }); + + expect(saved).toBe(true); + expect(updateMock).not.toHaveBeenCalled(); + expect(onWritePersisted).not.toHaveBeenCalled(); + }); + + it("does not notify onWritePersisted when the flush fails", async () => { + listMock.mockResolvedValue([sourceV1]); + updateMock.mockRejectedValue(new Error("write failed")); + const onWritePersisted = vi.fn(); + + const { result } = renderHook(() => + usePersonaSource(path, { onWritePersisted }), + ); + await flushPromises(); + + act(() => result.current.update({ name: "Snark" })); + await act(async () => { + await result.current.saveNow(); + }); + + expect(onWritePersisted).not.toHaveBeenCalled(); + }); + + it("contains a throwing onWritePersisted observer without re-queuing the persisted write", async () => { + const persistedSource = { ...sourceV1, name: "Snark" }; + listMock.mockResolvedValue([sourceV1]); + updateMock.mockResolvedValue(persistedSource); + const onWritePersisted = vi.fn(() => { + throw new Error("observer exploded"); + }); + + const { result } = renderHook(() => + usePersonaSource(path, { onWritePersisted }), + ); + await flushPromises(); + + act(() => result.current.update({ name: "Snark" })); + + let saved = false; + await act(async () => { + saved = await result.current.saveNow(); + }); + + expect(saved).toBe(true); + expect(onWritePersisted).toHaveBeenCalledTimes(1); + expect(result.current.saveStatus).toBe("saved"); + expect(updateMock).toHaveBeenCalledTimes(1); + + // Nothing was merged back into the pending patch: a follow-up flush + // finds no work, so the durable write is not repeated (and the observer + // does not hear a second persisted edit). + await act(async () => { + saved = await result.current.saveNow(); + }); + expect(saved).toBe(true); + expect(updateMock).toHaveBeenCalledTimes(1); + expect(onWritePersisted).toHaveBeenCalledTimes(1); + }); + + it("notifies onWritePersisted for debounced draft auto-saves", async () => { + const persistedDraft = { ...sessionPlaceholderSource, name: "Snark" }; + listMock.mockResolvedValue([sessionPlaceholderSource]); + readSourceMock.mockResolvedValue(sessionPlaceholderSource); + updateMock.mockResolvedValue(persistedDraft); + const onWritePersisted = vi.fn(); + + const { result } = renderHook(() => + usePersonaSource(path, { builderSessionId: "sess-1", onWritePersisted }), + ); + await flushPromises(); + + act(() => result.current.update({ name: "Snark" })); + await act(async () => { + vi.advanceTimersByTime(450); + await Promise.resolve(); + }); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(onWritePersisted).toHaveBeenCalledTimes(1); + expect(onWritePersisted).toHaveBeenCalledWith(persistedDraft); + }); + it("preserves local model and avatar choices when the agent updates text fields", async () => { const firstSave = deferred(); const localChoices = { diff --git a/src/features/agents/hooks/usePersonaSource.ts b/src/features/agents/hooks/usePersonaSource.ts index 4d80c7dd7..c91230979 100644 --- a/src/features/agents/hooks/usePersonaSource.ts +++ b/src/features/agents/hooks/usePersonaSource.ts @@ -7,6 +7,7 @@ import { type PersonaSourcePatch, } from "@/features/agents/lib/agentBuilderSourceLifecycle"; import { isEmptyPlaceholderDraft } from "@/features/agents/lib/agentBuilderIdentity"; +import { perfLog } from "@/shared/lib/perfLog"; export type { PersonaSourcePatch } from "@/features/agents/lib/agentBuilderSourceLifecycle"; @@ -34,18 +35,26 @@ interface UsePersonaSourceResult { interface UsePersonaSourceOptions { builderSessionId?: string; onResolvedPathChange?: (source: AgentSourceEntry) => void; + /** + * Called with the persisted source after a flush write durably completes. + * A saveNow with nothing pending never reaches it, and a failed write never + * reaches it, so callers can treat every invocation as one real persisted + * edit (drafts included — filtering draft writes is the caller's call). + */ + onWritePersisted?: (source: AgentSourceEntry) => void; } export function usePersonaSource( path: string | null, options: UsePersonaSourceOptions = {}, ): UsePersonaSourceResult { - const { builderSessionId, onResolvedPathChange } = options; + const { builderSessionId, onResolvedPathChange, onWritePersisted } = options; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const dataRef = useRef(null); const onResolvedPathChangeRef = useRef(onResolvedPathChange); + const onWritePersistedRef = useRef(onWritePersisted); const missingPollsRef = useRef(0); const pendingPatchRef = useRef(null); const inFlightPatchRef = useRef(null); @@ -308,6 +317,19 @@ export function usePersonaSource( writePromise = (async (): Promise => { try { const updated = await updateAgentBuilderSource(writePath, patch); + // The write is durable no matter what the guards below decide about + // component state, so persisted-write observers hear about it even + // when the hook has since unmounted or switched sources. The call is + // contained so a throwing observer cannot fall through to the write's + // catch, which would re-queue — and later re-write — a patch that + // already persisted. + try { + onWritePersistedRef.current?.(updated); + } catch (observerError) { + perfLog( + `[telemetry] persisted-write observer failed: ${String(observerError)}`, + ); + } if ( externalOverrideVersion !== externalOverrideVersionRef.current || saveIdentityRef.current !== identity || @@ -393,6 +415,10 @@ export function usePersonaSource( onResolvedPathChangeRef.current = onResolvedPathChange; }, [onResolvedPathChange]); + useEffect(() => { + onWritePersistedRef.current = onWritePersisted; + }, [onWritePersisted]); + useEffect(() => { const updatePollingState = () => { setIsPollingActive(shouldPollPersonaSource()); diff --git a/src/features/agents/lib/agentTelemetry.ts b/src/features/agents/lib/agentTelemetry.ts new file mode 100644 index 000000000..eadf8d972 --- /dev/null +++ b/src/features/agents/lib/agentTelemetry.ts @@ -0,0 +1,58 @@ +/** + * Thin, feature-scoped wrappers over the vendored `berd_agent` event factories. + * + * These build the vendored schema events and hand them to the shared telemetry + * `track` chokepoint, inheriting its prod/staging gate, consent gating, and + * startup buffering for free. Keeping the wrappers here (rather than in + * `client.ts`) keeps `berd_agent` wiring additive and local to the agents + * feature. + */ +import { track } from "@/shared/telemetry/client"; +import { + berdAgentCreateCompleted, + berdAgentDeleteCompleted, + berdAgentEditCompleted, +} from "@/shared/telemetry/events"; + +// Optional provider/model only carry signal when configured; drop blanks so we +// never emit an empty-string attribute standing in for "not set". +function nonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +interface AgentCompletionParams { + provider?: string | null; + model?: string | null; +} + +/** An agent/persona creation flow completed successfully. */ +export function trackAgentCreateCompleted({ + provider, + model, +}: AgentCompletionParams): void { + track( + berdAgentCreateCompleted({ + provider: nonEmpty(provider), + model: nonEmpty(model), + }), + ); +} + +/** An agent/persona edit flow completed successfully. */ +export function trackAgentEditCompleted({ + provider, + model, +}: AgentCompletionParams): void { + track( + berdAgentEditCompleted({ + provider: nonEmpty(provider), + model: nonEmpty(model), + }), + ); +} + +/** An agent/persona deletion completed successfully. */ +export function trackAgentDeleteCompleted(): void { + track(berdAgentDeleteCompleted()); +} diff --git a/src/features/agents/ui/AgentBuilderRail.tsx b/src/features/agents/ui/AgentBuilderRail.tsx index 47f17c0e4..18b098366 100644 --- a/src/features/agents/ui/AgentBuilderRail.tsx +++ b/src/features/agents/ui/AgentBuilderRail.tsx @@ -34,6 +34,10 @@ import { usePersonaSource, type PersonaSourcePatch, } from "@/features/agents/hooks/usePersonaSource"; +import { + trackAgentCreateCompleted, + trackAgentEditCompleted, +} from "@/features/agents/lib/agentTelemetry"; import { fileStem, isPlaceholderAgentName, @@ -104,10 +108,26 @@ export function AgentBuilderRail({ }, [onDraftTargetChanged], ); + // Edit Completed is anchored to the persisted write itself: every saveNow + // entry point (the Save button, the leave-builder "Keep" save, closing the + // builder) funnels through the same flush, a no-op save never persists + // anything, and the event must not depend on the post-save promoteDraft + // lookup succeeding. Draft writes are the create flow's incremental saves; + // creation is tracked once, on the confirmed promote. + const handleWritePersisted = useCallback((source: AgentSourceEntry) => { + if (source.properties?.draft === true) { + return; + } + trackAgentEditCompleted({ + provider: source.properties?.provider, + model: source.properties?.model, + }); + }, []); const { data, isLoading, error, update, saveStatus, saveNow } = usePersonaSource(targetAgentPath, { builderSessionId: sessionId, onResolvedPathChange: handleResolvedPathChange, + onWritePersisted: handleWritePersisted, }); const [isPromoting, setIsPromoting] = useState(false); const [avatarPanel, setAvatarPanel] = useState<"closed" | "library">( @@ -412,6 +432,17 @@ export function AgentBuilderRail({ } const promoted = await promoteDraft(sessionId); if (promoted) { + if (requiresNewDraftFields) { + // Create Completed on confirmed promote success. The promoted + // source is authoritative: its properties carry the configured + // provider/model. Edits are not tracked here — Edit Completed rides + // the persisted write (handleWritePersisted), which a no-op save + // never reaches and a failed post-save lookup cannot lose. + trackAgentCreateCompleted({ + provider: promoted.properties?.provider, + model: promoted.properties?.model, + }); + } onDraftPromoted?.(promoted); } } finally { diff --git a/src/features/agents/ui/AgentsView.tsx b/src/features/agents/ui/AgentsView.tsx index 0740602b9..530cc5d25 100644 --- a/src/features/agents/ui/AgentsView.tsx +++ b/src/features/agents/ui/AgentsView.tsx @@ -46,6 +46,11 @@ import { } from "@/features/agents/lib/personaImport"; import { isEmptyAgentsGallerySimulated } from "@/features/agents/lib/emptyGallerySimulation"; import { canDeletePersona } from "@/features/agents/lib/personaPresentation"; +import { + trackAgentCreateCompleted, + trackAgentDeleteCompleted, + trackAgentEditCompleted, +} from "@/features/agents/lib/agentTelemetry"; import { runAgentViewTransition } from "@/features/agents/lib/agentViewTransitions"; import { deleteDraftAgentSession } from "@/features/agents/lib/agentBuilderSession"; import type { AppNavigationUpdateOptions } from "@/app/types/appNavigation"; @@ -274,7 +279,7 @@ export function AgentsView({ const handleDuplicatePersona = useCallback( async (persona: Persona) => { try { - await createPersona({ + const created = await createPersona({ displayName: t("view.copyName", { name: persona.displayName }), avatar: persona.avatar ?? undefined, systemPrompt: persona.systemPrompt, @@ -282,6 +287,11 @@ export function AgentsView({ modelProviderId: persona.modelProviderId, model: persona.model, }); + // Completed on confirmed success, after the duplicate is persisted. + trackAgentCreateCompleted({ + provider: created.provider, + model: created.model, + }); toast.success(t("editor.duplicated")); } catch (error) { toast.error(formatAgentError(error, t("editor.saveFailed"))); @@ -293,7 +303,12 @@ export function AgentsView({ const handleUpdateAvatar = useCallback( async (persona: Persona, avatar: string | null) => { try { - await updatePersonaViaHook(persona, { avatar }); + const updated = await updatePersonaViaHook(persona, { avatar }); + // Completed on confirmed success, after the avatar edit persists. + trackAgentEditCompleted({ + provider: updated.provider, + model: updated.model, + }); toast.success(t("editor.updated")); } catch (error) { toast.error(formatAgentError(error, t("editor.saveFailed"))); @@ -316,6 +331,8 @@ export function AgentsView({ if (!deletingPersona) return; try { await deletePersona(deletingPersona.id); + // Completed on confirmed success, after the delete resolves. + trackAgentDeleteCompleted(); if (currentActivePersonaId === deletingPersona.id) { setActivePersona(null, { replace: true }); } @@ -374,6 +391,11 @@ export function AgentsView({ // submits the reviewed selection. Do not independently reinterpret it // here or the persisted request can drift from what the user saw. const created = await createPersona(request); + // Completed on confirmed success, after the create resolves. + trackAgentCreateCompleted({ + provider: created.provider, + model: created.model, + }); setImageImport(null); setActivePersona(created.id); toast.success(t("imageImport.added")); @@ -410,6 +432,14 @@ export function AgentsView({ async (fileContents: string, fileName: string) => { try { const imported = await importPersonas(fileContents, fileName); + // Completed once per persona the import actually created (a native + // JSON export can carry several), after the creates resolve. + for (const persona of imported) { + trackAgentCreateCompleted({ + provider: persona.provider, + model: persona.model, + }); + } await refreshFromDisk(); const message = formatImportSuccessMessage(imported.length); toast.success(t(message.key, message.options)); diff --git a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx index 6af284ef4..4167a83ed 100644 --- a/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx +++ b/src/features/agents/ui/__tests__/AgentBuilderRail.test.tsx @@ -7,10 +7,18 @@ const toastMocks = vi.hoisted(() => ({ error: vi.fn(), })); +const agentTelemetryMocks = vi.hoisted(() => ({ + trackAgentCreateCompleted: vi.fn(), + trackAgentEditCompleted: vi.fn(), + trackAgentDeleteCompleted: vi.fn(), +})); + vi.mock("sonner", () => ({ toast: toastMocks, })); +vi.mock("@/features/agents/lib/agentTelemetry", () => agentTelemetryMocks); + vi.mock("@/features/agents/hooks/usePersonaSource", () => ({ usePersonaSource: vi.fn(), })); @@ -121,6 +129,9 @@ describe("AgentBuilderRail", () => { vi.mocked(promoteDraft).mockReset(); toastMocks.success.mockReset(); toastMocks.error.mockReset(); + agentTelemetryMocks.trackAgentCreateCompleted.mockReset(); + agentTelemetryMocks.trackAgentEditCompleted.mockReset(); + agentTelemetryMocks.trackAgentDeleteCompleted.mockReset(); vi.mocked(useAvatarLibrary).mockReturnValue({ catalog: null, cachedAvatarMediaById: {}, @@ -463,6 +474,14 @@ describe("AgentBuilderRail", () => { expect(promoteDraft).toHaveBeenCalledWith("s1"); expect(onDraftPromoted).toHaveBeenCalledWith(promotedSource); }); + expect(agentTelemetryMocks.trackAgentCreateCompleted).toHaveBeenCalledTimes( + 1, + ); + expect(agentTelemetryMocks.trackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5", + }); + expect(agentTelemetryMocks.trackAgentEditCompleted).not.toHaveBeenCalled(); }); it("does not promote when flushing rail edits fails", async () => { @@ -536,6 +555,142 @@ describe("AgentBuilderRail", () => { }); }); + describe("berd_agent Edit Completed", () => { + const existingAgentSource: AgentSourceEntry = { + ...baseSource, + path: "/Users/x/.agents/agents/code-reviewer.md", + name: "Code Reviewer", + content: "Review code carefully.", + properties: { provider: "openai", model: "gpt-5" }, + }; + + function lastPersonaSourceOptions() { + return vi.mocked(usePersonaSource).mock.calls.at(-1)?.[1]; + } + + // A saveNow double that behaves like the real flush persisting + // `persisted`: it reports the write through the rail's onWritePersisted + // before resolving, exactly as usePersonaSource does. + function persistingSaveNow(persisted: AgentSourceEntry) { + return vi.fn().mockImplementation(async () => { + lastPersonaSourceOptions()?.onWritePersisted?.(persisted); + return true; + }); + } + + function renderExistingAgentRail() { + return renderWithProviders( + , + ); + } + + it("does not fire for a no-op Save with nothing to persist", async () => { + const { saveNow } = mockHook({ data: existingAgentSource }); + vi.mocked(promoteDraft).mockResolvedValue(existingAgentSource); + renderExistingAgentRail(); + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(saveNow).toHaveBeenCalled(); + expect(promoteDraft).toHaveBeenCalledWith("s1"); + }); + expect( + agentTelemetryMocks.trackAgentEditCompleted, + ).not.toHaveBeenCalled(); + expect( + agentTelemetryMocks.trackAgentCreateCompleted, + ).not.toHaveBeenCalled(); + }); + + it("fires once from the persisted write when a real edit saves", async () => { + const persisted = { + ...existingAgentSource, + name: "Code Reviewer Deluxe", + }; + mockHook({ + data: existingAgentSource, + saveNow: persistingSaveNow(persisted), + }); + vi.mocked(promoteDraft).mockResolvedValue(persisted); + renderExistingAgentRail(); + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(promoteDraft).toHaveBeenCalledWith("s1"); + }); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledTimes( + 1, + ); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5", + }); + expect( + agentTelemetryMocks.trackAgentCreateCompleted, + ).not.toHaveBeenCalled(); + }); + + it("still fires when the post-save source lookup comes back empty", async () => { + const persisted = { + ...existingAgentSource, + name: "Code Reviewer Deluxe", + }; + const saveNow = persistingSaveNow(persisted); + mockHook({ data: existingAgentSource, saveNow }); + vi.mocked(promoteDraft).mockResolvedValue(null); + renderExistingAgentRail(); + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(saveNow).toHaveBeenCalled(); + expect(promoteDraft).toHaveBeenCalledWith("s1"); + }); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledTimes( + 1, + ); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5", + }); + }); + + it("tracks non-draft persisted writes and stays silent for draft writes", () => { + mockHook(); + renderWithProviders( + , + ); + const options = lastPersonaSourceOptions(); + + // A draft write is the create flow's incremental auto-save. + options?.onWritePersisted?.(baseSource); + expect( + agentTelemetryMocks.trackAgentEditCompleted, + ).not.toHaveBeenCalled(); + + // A non-draft write is a real edit no matter which caller ran saveNow + // (Save button, leave-builder Keep, builder close). + options?.onWritePersisted?.(existingAgentSource); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledTimes( + 1, + ); + expect(agentTelemetryMocks.trackAgentEditCompleted).toHaveBeenCalledWith({ + provider: "openai", + model: "gpt-5", + }); + }); + }); + it("does not show a back button in the agent editor", () => { mockHook({ data: { diff --git a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx index 65ca2f5ae..c8a8ac97a 100644 --- a/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx +++ b/src/features/agents/ui/__tests__/AgentsView.entry.test.tsx @@ -12,9 +12,14 @@ import { resolve } from "node:path"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { toast } from "sonner"; +import { importPersonas } from "@/shared/api/agents"; +import type { CreatePersonaRequest } from "@/shared/types/agents"; import { AgentsView } from "../AgentsView"; const mockCreatePersona = vi.hoisted(() => vi.fn()); +const mockUpdatePersona = vi.hoisted(() => vi.fn()); +const mockTrackAgentCreateCompleted = vi.hoisted(() => vi.fn()); +const mockTrackAgentEditCompleted = vi.hoisted(() => vi.fn()); const mockDraftSource = vi.hoisted(() => ({ type: "agent", @@ -95,9 +100,16 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("@/features/agents/lib/agentTelemetry", () => ({ + trackAgentCreateCompleted: mockTrackAgentCreateCompleted, + trackAgentEditCompleted: mockTrackAgentEditCompleted, + trackAgentDeleteCompleted: vi.fn(), +})); + vi.mock("@/features/agents/hooks/usePersonas", () => ({ usePersonas: () => ({ createPersona: mockCreatePersona, + updatePersona: mockUpdatePersona, deletePersona: vi.fn(), refreshFromDisk: vi.fn(), }), @@ -166,6 +178,28 @@ describe("AgentsView entry points", () => { beforeEach(() => { vi.clearAllMocks(); + // Mirrors the real API: the created persona carries the persisted + // identity the telemetry call sites are expected to report. + mockCreatePersona.mockImplementation( + async (request: CreatePersonaRequest) => ({ + id: "/Users/x/.agents/agents/created.md", + displayName: request.displayName, + systemPrompt: request.systemPrompt, + provider: request.provider, + modelProviderId: request.modelProviderId, + model: request.model, + isBuiltin: false, + writable: true, + }), + ); + // Mirrors the real API: the updated persona carries the persisted + // identity the telemetry call site is expected to report. + mockUpdatePersona.mockImplementation( + async (existing: typeof persona, request: Record) => ({ + ...existing, + ...request, + }), + ); useAgentStore.setState({ personas: [], personasLoading: false, @@ -521,6 +555,233 @@ describe("AgentsView entry points", () => { ); }); + describe("berd_agent Create Completed", () => { + function agentImageFixtureFile(): File { + const fixtureBytes = readFileSync( + resolve( + process.cwd(), + "src/features/agents/agent-snapshot/fixtures/buzz-v1-config-only.agent.png", + ), + ); + const file = new File([fixtureBytes], "shared.png", { + type: "image/png", + }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi + .fn() + .mockResolvedValue( + fixtureBytes.buffer.slice( + fixtureBytes.byteOffset, + fixtureBytes.byteOffset + fixtureBytes.byteLength, + ), + ), + }); + return file; + } + + function importTextFile(name: string, type: string): File { + const bytes = new TextEncoder().encode("{}"); + const file = new File([bytes], name, { type }); + Object.defineProperty(file, "arrayBuffer", { + configurable: true, + value: vi.fn().mockResolvedValue(bytes.buffer), + }); + return file; + } + + async function duplicateActivePersona(): Promise { + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "detail.moreActions" }), + ); + await user.click( + screen.getByRole("menuitem", { name: "common:actions.duplicate" }), + ); + } + + it("fires once with the created copy's identity after a successful duplicate", async () => { + const qualifiedPersona = { + ...persona, + provider: "goose", + modelProviderId: "openai", + model: "gpt-5.6", + }; + useAgentStore.setState({ personas: [qualifiedPersona] }); + render(); + + await duplicateActivePersona(); + + await waitFor(() => + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledTimes(1), + ); + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: "goose", + model: "gpt-5.6", + }); + }); + + it("does not fire when duplicating fails", async () => { + mockCreatePersona.mockRejectedValueOnce(new Error("create failed")); + useAgentStore.setState({ personas: [persona] }); + render(); + + await duplicateActivePersona(); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(mockTrackAgentCreateCompleted).not.toHaveBeenCalled(); + }); + + it("fires once per persona actually created by a file import", async () => { + vi.mocked(importPersonas).mockResolvedValue([ + { + id: "/Users/x/.agents/agents/imported-one.md", + displayName: "Imported one", + systemPrompt: "One.", + provider: "goose", + model: "gpt-5.6", + isBuiltin: false, + writable: true, + }, + { + id: "/Users/x/.agents/agents/imported-two.md", + displayName: "Imported two", + systemPrompt: "Two.", + isBuiltin: false, + writable: true, + }, + ]); + const { container } = render(); + const input = + container.querySelector('input[type="file"]'); + + fireEvent.change(input as HTMLInputElement, { + target: { + files: [importTextFile("team.agent.json", "application/json")], + }, + }); + + await waitFor(() => + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledTimes(2), + ); + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: "goose", + model: "gpt-5.6", + }); + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: undefined, + model: undefined, + }); + }); + + it("does not fire when a file import fails", async () => { + vi.mocked(importPersonas).mockRejectedValue(new Error("import failed")); + const { container } = render(); + const input = + container.querySelector('input[type="file"]'); + + fireEvent.change(input as HTMLInputElement, { + target: { + files: [importTextFile("reviewer.persona.md", "text/markdown")], + }, + }); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(mockTrackAgentCreateCompleted).not.toHaveBeenCalled(); + }); + + it("fires once after a confirmed agent-image import", async () => { + const { container } = render(); + const input = container.querySelector( + 'input[type="file"][accept*="image/png"]', + ); + + fireEvent.change(input as HTMLInputElement, { + target: { files: [agentImageFixtureFile()] }, + }); + await screen.findByRole("heading", { name: "imageImport.description" }); + expect(mockTrackAgentCreateCompleted).not.toHaveBeenCalled(); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "imageImport.add" })); + + await waitFor(() => + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledTimes(1), + ); + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: undefined, + model: undefined, + }); + }); + + it("does not fire when the agent-image import create fails", async () => { + mockCreatePersona.mockRejectedValueOnce(new Error("create failed")); + const { container } = render(); + const input = container.querySelector( + 'input[type="file"][accept*="image/png"]', + ); + + fireEvent.change(input as HTMLInputElement, { + target: { files: [agentImageFixtureFile()] }, + }); + await screen.findByRole("heading", { name: "imageImport.description" }); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "imageImport.add" })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(mockTrackAgentCreateCompleted).not.toHaveBeenCalled(); + }); + }); + + describe("berd_agent Edit Completed", () => { + async function saveCustomAvatarUrl(url: string): Promise { + const user = userEvent.setup(); + await user.click( + screen.getByRole("button", { name: "editor.customizeAvatar" }), + ); + await user.type(screen.getByLabelText("editor.avatarUrl"), url); + await user.click( + screen.getByRole("button", { name: "common:actions.save" }), + ); + } + + it("fires once with the persisted identity after a detail-page avatar change", async () => { + const qualifiedPersona = { + ...persona, + provider: "goose", + model: "gpt-5.6", + }; + useAgentStore.setState({ personas: [qualifiedPersona] }); + render(); + + await saveCustomAvatarUrl("https://example.com/avatar.png"); + + await waitFor(() => + expect(mockTrackAgentEditCompleted).toHaveBeenCalledTimes(1), + ); + expect(mockUpdatePersona).toHaveBeenCalledWith( + expect.objectContaining({ id: qualifiedPersona.id }), + { avatar: "https://example.com/avatar.png" }, + ); + expect(mockTrackAgentEditCompleted).toHaveBeenCalledWith({ + provider: "goose", + model: "gpt-5.6", + }); + }); + + it("does not fire when the avatar update fails", async () => { + mockUpdatePersona.mockRejectedValueOnce(new Error("update failed")); + useAgentStore.setState({ personas: [persona] }); + render(); + + await saveCustomAvatarUrl("https://example.com/avatar.png"); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(mockTrackAgentEditCompleted).not.toHaveBeenCalled(); + }); + }); + it("starts a gallery-to-profile view transition when opening detail", () => { const resolved = Promise.resolve(); const startViewTransition = vi.fn((callback: () => void) => { diff --git a/src/features/berdctl/commands/impl/createAgent.ts b/src/features/berdctl/commands/impl/createAgent.ts index e8d90f5b7..f82e4bf0f 100644 --- a/src/features/berdctl/commands/impl/createAgent.ts +++ b/src/features/berdctl/commands/impl/createAgent.ts @@ -48,6 +48,10 @@ Result: import("@/features/agents/stores/agentStore"), import("@/shared/api/agents"), ]); + // Deliberately no berd_agent Create Completed telemetry: berdctl creates + // are agent/automation-driven, and the event tracks human-driven UI + // surfaces only — matching the documented berdctl exclusion in the chat + // send path (fireChatSendTelemetry in useChatSessionController). const persona = await createPersona({ displayName: args.name, systemPrompt: args.system_prompt, diff --git a/src/features/berdctl/commands/impl/createProject.ts b/src/features/berdctl/commands/impl/createProject.ts index 8ce4b83b5..becc81cd0 100644 --- a/src/features/berdctl/commands/impl/createProject.ts +++ b/src/features/berdctl/commands/impl/createProject.ts @@ -44,6 +44,11 @@ Result: import("@/features/projects/lib/projectIcons"), import("@/features/projects/stores/projectStore"), ]); + // Deliberately no berd_project Create Completed telemetry: berdctl + // creates are agent/automation-driven, and the event tracks human-driven + // UI surfaces only — matching the documented berdctl exclusions in the + // chat send path (fireChatSendTelemetry in useChatSessionController) and + // `berdctl agent create` (createAgent.ts). const project = await useProjectStore .getState() .addProject( diff --git a/src/features/berdctl/commands/impl/setProjectStartupMode.ts b/src/features/berdctl/commands/impl/setProjectStartupMode.ts index ed426cde2..0e780858d 100644 --- a/src/features/berdctl/commands/impl/setProjectStartupMode.ts +++ b/src/features/berdctl/commands/impl/setProjectStartupMode.ts @@ -172,6 +172,13 @@ Result: ); } + // Deliberately no berd_project Edit Completed telemetry: this rewrite of + // projectWorkspaces/workingDirs/useWorktrees is a genuine configuration + // edit by the event's own params, but berdctl mutations are + // agent/automation-driven and the event tracks human-driven UI surfaces + // only — matching the documented berdctl exclusions in the chat send path + // (fireChatSendTelemetry in useChatSessionController) and `berdctl agent + // create` (createAgent.ts). const updated = await useProjectStore.getState().editProject( project.id, project.name, diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts index 4f2018aaf..cb5e540b6 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.compaction.test.ts @@ -370,7 +370,13 @@ describe("useChatSessionController compaction behavior", () => { }); expect(mockCompactConversation).toHaveBeenCalledOnce(); - expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined); + expect(mockSendMessage).toHaveBeenCalledWith( + "hello", + undefined, + undefined, + // The chat send telemetry commit hook rides on every foreground send. + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), + ); expect(mockCompactConversation.mock.invocationCallOrder[0]).toBeLessThan( mockSendMessage.mock.invocationCallOrder[0], ); @@ -395,7 +401,13 @@ describe("useChatSessionController compaction behavior", () => { }); expect(mockCompactConversation).toHaveBeenCalledOnce(); - expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined); + expect(mockSendMessage).toHaveBeenCalledWith( + "hello", + undefined, + undefined, + // The chat send telemetry commit hook rides on every foreground send. + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), + ); }); it("keeps compaction enabled for goose agent sessions backed by model providers", async () => { @@ -424,7 +436,13 @@ describe("useChatSessionController compaction behavior", () => { }); expect(mockCompactConversation).toHaveBeenCalledOnce(); - expect(mockSendMessage).toHaveBeenCalledWith("hello", undefined, undefined); + expect(mockSendMessage).toHaveBeenCalledWith( + "hello", + undefined, + undefined, + // The chat send telemetry commit hook rides on every foreground send. + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), + ); }); it("compacts the queued persona session before sending", async () => { @@ -459,6 +477,7 @@ describe("useChatSessionController compaction behavior", () => { "hello", { id: "persona-a" }, undefined, + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), ); }); @@ -490,6 +509,7 @@ describe("useChatSessionController compaction behavior", () => { "hello", { id: null }, undefined, + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), ); }); @@ -534,6 +554,7 @@ describe("useChatSessionController compaction behavior", () => { "hello", { id: "persona-a" }, undefined, + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), ); }); @@ -573,6 +594,7 @@ describe("useChatSessionController compaction behavior", () => { "hello", { id: "persona-a" }, undefined, + expect.objectContaining({ onUserMessageCommitted: expect.any(Function) }), ); }); }); diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index 95152412a..db8276a8a 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -12,7 +12,10 @@ import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStor import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; import type { Persona } from "@/shared/types/agents"; -import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { + type ChatAttachmentDraft, + createUserMessage, +} from "@/shared/types/messages"; import { useChatStore } from "../../stores/chatStore"; import { type ChatSession, @@ -36,6 +39,8 @@ const mockSupportedModelsList = vi.fn(); const mockToastError = vi.fn(); const mockUseChatSendMessage = vi.fn(); const mockUseChatSteerMessage = vi.fn(); +const mockTrackChatMessageSent = vi.fn(); +const mockTrackChatSessionStarted = vi.fn(); const mockUseChatHook = vi.fn(); const mockUseMessageQueue = vi.fn(); const mockPickerOpen = vi.fn(); @@ -264,6 +269,19 @@ vi.mock("../useAgentModelPickerState", () => ({ }), })); +// Wrappers are mocked so the tests can pin the fire points; CHAT_SOURCE_SURFACE +// and the rest of the module stay real. +vi.mock("@/features/chat/lib/chatTelemetry", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/chat/lib/chatTelemetry") + >()), + trackChatMessageSent: (...args: unknown[]) => + mockTrackChatMessageSent(...args), + trackChatSessionStarted: (...args: unknown[]) => + mockTrackChatSessionStarted(...args), +})); + +import { CHAT_SOURCE_SURFACE } from "../../lib/chatTelemetry"; import { useChatSessionController } from "../useChatSessionController"; function latestMessageQueueArgs() { @@ -554,6 +572,7 @@ describe("useChatSessionController", () => { draftAttachmentsBySession: {}, queuedMessageBySession: {}, scrollTargetMessageBySession: {}, + loadingSessionIds: new Set(), activeSessionId: null, isConnected: true, }); @@ -1634,7 +1653,7 @@ describe("useChatSessionController", () => { "next poem", undefined, undefined, - undefined, + { telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT }, undefined, ); expect(mockUseChatSendMessage).not.toHaveBeenCalled(); @@ -1662,7 +1681,7 @@ describe("useChatSessionController", () => { "help me with Berd", undefined, undefined, - undefined, + { telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT }, undefined, ); expect(mockUseChatSendMessage).not.toHaveBeenCalled(); @@ -1727,7 +1746,11 @@ describe("useChatSessionController", () => { const queuedSendOptions = enqueue.mock.calls[0]?.[3]; const queuedExecutionTarget = enqueue.mock.calls[0]?.[5]; - expect(queuedSendOptions).toBeUndefined(); + // Only the telemetry surface stamp is captured this early — no execution + // context may freeze before the workspace context is ready. + expect(queuedSendOptions).toEqual({ + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + }); expect(queuedExecutionTarget).toBeUndefined(); expect(mockUseChatSendMessage).not.toHaveBeenCalled(); @@ -1949,7 +1972,11 @@ describe("useChatSessionController", () => { expect(mockUseChatSteerMessage).toHaveBeenCalledWith( "make it shorter", undefined, - { displayText: "make it shorter" }, + expect.objectContaining({ + displayText: "make it shorter", + // The controller always wires the send-telemetry commit anchor. + onUserMessageCommitted: expect.any(Function), + }), ); }); @@ -2709,7 +2736,9 @@ describe("useChatSessionController", () => { text: "no persona", personaId: null, personaName: undefined, - sendOptions: undefined, + sendOptions: { + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + }, }, { text: "plan", @@ -2717,6 +2746,7 @@ describe("useChatSessionController", () => { personaName: "Codex Planner", sendOptions: { capturedPersonaSystemPrompt: expect.stringContaining("Plan clearly."), + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, }, }, { @@ -2726,6 +2756,7 @@ describe("useChatSessionController", () => { sendOptions: { capturedPersonaSystemPrompt: expect.stringContaining("Review carefully."), + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, }, }, ]); @@ -5053,6 +5084,9 @@ describe("useChatSessionController", () => { persona: { kind: "inherit" }, text: "", attachments: [imageDraft], + sendOptions: { + telemetrySourceSurface: CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER, + }, }); useChatSessionStore.setState((state) => ({ @@ -5081,6 +5115,11 @@ describe("useChatSessionController", () => { persona: { kind: "inherit" }, text: "", attachments: [imageDraft], + // The migrated record keeps its Home-composer surface stamp so a + // deferred-workspace release still reports where it was accepted. + sendOptions: { + telemetrySourceSurface: CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER, + }, }); }); expect( @@ -5544,4 +5583,492 @@ describe("useChatSessionController", () => { ); consoleError.mockRestore(); }); + + // Regression coverage for the `berd_chat` send-telemetry anchor: both events + // fire from the user-message-commit callback, so an attempt that fails + // before committing emits nothing and the queue's automatic retry of the + // same payload emits exactly once, when it finally commits. + describe("chat send telemetry", () => { + type DrainSend = ( + text: string, + overridePersona?: { id: string | null; name?: string }, + attachments?: ChatAttachmentDraft[], + sendOptions?: ChatSendOptions, + ) => boolean | Promise; + + // Mimics sendCore's commit contract: the user message is appended to the + // transcript, then the commit callback fires synchronously. + function commitUserMessage( + sessionId: string, + text: string, + sendOptions?: ChatSendOptions, + ) { + useChatStore.getState().addMessage(sessionId, createUserMessage(text)); + sendOptions?.onUserMessageCommitted?.(); + } + + function latestDrainSend(): DrainSend { + return latestMessageQueueArgs()[2] as DrainSend; + } + + function commitOnSendOnce() { + mockUseChatSendMessage.mockImplementationOnce( + async ( + options?: { __sessionId?: string }, + text?: string, + _persona?: unknown, + _attachments?: unknown, + sendOptions?: ChatSendOptions, + ) => { + commitUserMessage( + options?.__sessionId ?? "session-1", + text ?? "", + sendOptions, + ); + return true; + }, + ); + } + + it("emits Session Started and Message Sent once, only at the user-message commit", async () => { + // Captured for the outer assertions — an expect() inside the async send + // mock would be swallowed by the queue's void'ed send promise. + let trackCallsBeforeCommit = -1; + mockUseChatSendMessage.mockImplementationOnce( + async ( + options?: { __sessionId?: string }, + text?: string, + _persona?: unknown, + _attachments?: unknown, + sendOptions?: ChatSendOptions, + ) => { + trackCallsBeforeCommit = + mockTrackChatSessionStarted.mock.calls.length + + mockTrackChatMessageSent.mock.calls.length; + commitUserMessage( + options?.__sessionId ?? "session-1", + text ?? "", + sendOptions, + ); + return true; + }, + ); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + await act(async () => { + await result.current.handleSend("hello"); + }); + + expect(mockUseChatSendMessage).toHaveBeenCalledTimes(1); + // Nothing fired before the user message was committed. + expect(trackCallsBeforeCommit).toBe(0); + expect(mockTrackChatSessionStarted).toHaveBeenCalledTimes(1); + expect(mockTrackChatSessionStarted).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + sourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + }), + ); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + isFirstMessage: true, + }), + ); + }); + + // The anchor is observation-only by construction: it runs inside the + // send/steer commit callbacks, so a throwing wrapper contained here can + // never reject a dispatch the backend already accepted. + it("contains a throwing telemetry wrapper so a committed send still resolves", async () => { + mockTrackChatMessageSent.mockImplementationOnce(() => { + throw new Error("telemetry exploded"); + }); + commitOnSendOnce(); + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + const drainSend = latestDrainSend(); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await drainSend("hello"); + }); + + expect(accepted).toBe(true); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + }); + + it("emits nothing on a pre-commit failure and once when the automatic retry commits", async () => { + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + const drainSend = latestDrainSend(); + const queueCommitMarker = vi.fn(); + + // First attempt: preparation/dispatch fails before the user message is + // committed, so the queue keeps the record for its automatic retry. + mockUseChatSendMessage.mockImplementationOnce(async () => false); + await act(async () => { + await drainSend("hello", undefined, undefined, { + onUserMessageCommitted: queueCommitMarker, + }); + }); + + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).not.toHaveBeenCalled(); + expect(queueCommitMarker).not.toHaveBeenCalled(); + + // The retry re-dispatches the same payload; this time it commits. No + // user message committed before it, so it is still the first message. + commitOnSendOnce(); + await act(async () => { + await drainSend("hello", undefined, undefined, { + onUserMessageCommitted: queueCommitMarker, + }); + }); + + expect(mockTrackChatSessionStarted).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ isFirstMessage: true }), + ); + // The queue's own commit callback still fires through the telemetry + // wrapper — it is what stops the queue from retrying a committed send. + expect(queueCommitMarker).toHaveBeenCalledTimes(1); + }); + + it("emits Message Sent as not-first and no Session Started once a user message exists", async () => { + useChatStore + .getState() + .addMessage("session-1", createUserMessage("earlier message")); + commitOnSendOnce(); + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + const drainSend = latestDrainSend(); + + await act(async () => { + await drainSend("follow up"); + }); + + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ isFirstMessage: false }), + ); + }); + + // A resumed session replays its history asynchronously and nothing gates + // sending on that load, so the transcript a commit reads can still be + // empty for a conversation that started long ago. Those sends must report + // as follow-ups, not as a brand-new session. + describe("session history that has not replayed", () => { + it("emits Message Sent as not-first and no Session Started while the history is still replaying", async () => { + useChatSessionStore.setState({ + sessions: [sessionFixture({ messageCount: 12 })], + }); + // The session was just opened: its replay is in flight, so the + // transcript is empty until the load flushes it. + useChatStore.getState().setSessionLoading("session-1", true); + commitOnSendOnce(); + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + const drainSend = latestDrainSend(); + + await act(async () => { + await drainSend("typed before the transcript landed"); + }); + + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + isFirstMessage: false, + }), + ); + }); + + it("emits Message Sent as not-first when a settled load left the session's history unreplayed", async () => { + // A failed load settles with an empty transcript (its error notice is + // a system message), so the record's backend count is what remains. + useChatSessionStore.setState({ + sessions: [sessionFixture({ messageCount: 12 })], + }); + commitOnSendOnce(); + renderHook(() => useChatSessionController({ sessionId: "session-1" })); + const drainSend = latestDrainSend(); + + await act(async () => { + await drainSend("typed after a failed load"); + }); + + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ isFirstMessage: false }), + ); + }); + }); + + // Steer sends commit a real user message through steerCore, whose commit + // callback fires only once the backend acknowledges the steer — so both + // steer paths ride the same anchor as regular sends: a rejected steer + // emits nothing, an accepted one emits Message Sent exactly once. + describe("steer sends", () => { + // Mimics steerCore's commit contract: the acknowledged steer's user + // message is in the transcript when the commit callback fires. + function commitOnSteerOnce() { + mockUseChatSteerMessage.mockImplementationOnce( + async ( + text?: string, + _attachments?: unknown, + sendOptions?: ChatSendOptions, + ) => { + useChatStore + .getState() + .addMessage("session-1", createUserMessage(text ?? "")); + sendOptions?.onUserMessageCommitted?.(); + return true; + }, + ); + } + + it("emits Message Sent once, only at the commit of a steered draft", async () => { + // Steering happens mid-run, so an earlier user message exists. + useChatStore + .getState() + .addMessage("session-1", createUserMessage("start the run")); + mockUseChatRuntime.chatState = "streaming"; + let trackCallsBeforeCommit = -1; + mockUseChatSteerMessage.mockImplementationOnce( + async ( + text?: string, + _attachments?: unknown, + sendOptions?: ChatSendOptions, + ) => { + trackCallsBeforeCommit = + mockTrackChatSessionStarted.mock.calls.length + + mockTrackChatMessageSent.mock.calls.length; + useChatStore + .getState() + .addMessage("session-1", createUserMessage(text ?? "")); + sendOptions?.onUserMessageCommitted?.(); + return true; + }, + ); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await result.current.steerDraftMessage("make it shorter"); + }); + + expect(accepted).toBe(true); + // Nothing fired before the steer was acknowledged and committed. + expect(trackCallsBeforeCommit).toBe(0); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + isFirstMessage: false, + }), + ); + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + }); + + it("emits nothing for a steered draft rejected before commit", async () => { + mockUseChatRuntime.chatState = "streaming"; + // A rejected steer rolls its user message back and never invokes the + // commit callback. + mockUseChatSteerMessage.mockResolvedValueOnce(false); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await result.current.steerDraftMessage("make it shorter"); + }); + + expect(accepted).toBe(false); + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).not.toHaveBeenCalled(); + }); + + it("emits Message Sent once for a steered queued message, chaining the record's own commit callback", async () => { + useChatStore + .getState() + .addMessage("session-1", createUserMessage("start the run")); + const recordCommitMarker = vi.fn(); + const dismiss = vi.fn(); + mockUseMessageQueue.mockImplementation(() => ({ + queuedMessage: { + text: "queued follow-up", + attachments: [], + sendOptions: { + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + onUserMessageCommitted: recordCommitMarker, + }, + }, + enqueue: vi.fn(), + dismiss, + })); + commitOnSteerOnce(); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await result.current.steerQueuedMessage(); + }); + + expect(accepted).toBe(true); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mockTrackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-1", + isFirstMessage: false, + }), + ); + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + // The payload's own commit callback still fires through the wrapper. + expect(recordCommitMarker).toHaveBeenCalledTimes(1); + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + // A throw escaping the anchor here would reject steerQueuedMessage + // after the backend acknowledged the steer, skipping queue.dismiss() — + // the already-steered record would then drain again as a duplicate + // user turn (LAWS/CHAT.md: at most one user turn per message). + it("dismisses the queued record even when the telemetry wrapper throws at the steer commit", async () => { + useChatStore + .getState() + .addMessage("session-1", createUserMessage("start the run")); + const dismiss = vi.fn(); + mockUseMessageQueue.mockImplementation(() => ({ + queuedMessage: { text: "queued follow-up" }, + enqueue: vi.fn(), + dismiss, + })); + mockTrackChatMessageSent.mockImplementationOnce(() => { + throw new Error("telemetry exploded"); + }); + commitOnSteerOnce(); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await result.current.steerQueuedMessage(); + }); + + expect(accepted).toBe(true); + expect(mockTrackChatMessageSent).toHaveBeenCalledTimes(1); + expect(dismiss).toHaveBeenCalledTimes(1); + }); + + it("emits nothing when a queued-message steer is rejected, keeping the record for the instrumented drain", async () => { + const dismiss = vi.fn(); + mockUseMessageQueue.mockImplementation(() => ({ + queuedMessage: { text: "queued follow-up" }, + enqueue: vi.fn(), + dismiss, + })); + mockUseChatSteerMessage.mockResolvedValueOnce(false); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + + let accepted: boolean | undefined; + await act(async () => { + accepted = await result.current.steerQueuedMessage(); + }); + + expect(accepted).toBe(false); + expect(mockTrackChatSessionStarted).not.toHaveBeenCalled(); + expect(mockTrackChatMessageSent).not.toHaveBeenCalled(); + expect(dismiss).not.toHaveBeenCalled(); + }); + }); + + // Captured payloads carry the surface that accepted them: a queued record + // can be released to the background queued-send pipeline by the + // deferred-workspace flow, which cannot recompute this controller's + // surface, so losing the stamp would silence that send's telemetry. + describe("captured payload surface stamp", () => { + function renderWithCapturingQueue( + options: Parameters[0], + ) { + const enqueue = vi.fn(); + mockUseMessageQueue.mockImplementation(() => ({ + queuedMessage: null, + enqueue, + dismiss: vi.fn(), + })); + const { result } = renderHook(() => useChatSessionController(options)); + return { result, enqueue }; + } + + it("stamps main-chat sends", () => { + const { result, enqueue } = renderWithCapturingQueue({ + sessionId: "session-1", + }); + + act(() => { + result.current.handleSend("hello"); + }); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0]?.[3]).toMatchObject({ + telemetrySourceSurface: CHAT_SOURCE_SURFACE.MAIN_CHAT, + }); + }); + + it("stamps Home composer sends as global composer", () => { + const { result, enqueue } = renderWithCapturingQueue({ + sessionId: null, + isHomeSession: true, + }); + + act(() => { + result.current.handleSend("hello"); + }); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0]?.[3]).toMatchObject({ + telemetrySourceSurface: CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER, + }); + }); + + it("stamps builder-session sends as agent builder", () => { + useChatSessionStore.setState({ + sessions: [ + sessionFixture({ + intent: "build-agent", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-4o", + modelName: "GPT-4o", + }, + }), + ], + }); + const { result, enqueue } = renderWithCapturingQueue({ + sessionId: "session-1", + }); + + act(() => { + result.current.handleSend("hello"); + }); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0]?.[3]).toMatchObject({ + telemetrySourceSurface: CHAT_SOURCE_SURFACE.AGENT_BUILDER, + }); + }); + }); + }); }); diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 70a5c9e5c..4bee6b149 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -105,6 +105,14 @@ import { recoverStrandedProviderSession, type RecreateSessionForProvider, } from "../model-selection/strandedProviderRecovery"; +import { perfLog } from "@/shared/lib/perfLog"; +import type { BerdChatChatSourceSurface } from "@/shared/telemetry/events"; +import { isFirstCommittedUserMessage } from "../lib/chatFirstMessage"; +import { + CHAT_SOURCE_SURFACE, + trackChatMessageSent, + trackChatSessionStarted, +} from "../lib/chatTelemetry"; import { isModelExecutionTarget, normalizeSessionExecutionTarget, @@ -1882,6 +1890,90 @@ export function useChatSessionController({ liveRuntime.activeRunId !== null || liveRuntime.isRunCancellationPending ); }, [stateSessionId]); + // Entry point this chat surface maps to for `berd_chat` session telemetry. An + // agent-builder session takes precedence over the composer it was launched + // from; otherwise Home's global composer vs the main chat view. + const chatSourceSurface = useMemo(() => { + if (session?.intent === "build-agent") { + return CHAT_SOURCE_SURFACE.AGENT_BUILDER; + } + return isHomeSession + ? CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER + : CHAT_SOURCE_SURFACE.MAIN_CHAT; + }, [isHomeSession, session?.intent]); + // Fires `berd_chat` send telemetry for a foreground send dispatched by this + // controller. A foreground send released from the deferred-workspace flow is + // dispatched by the background queued-send pipeline instead and fires there + // (`sendQueuedPromptToExistingSessionInBackground`), keyed off the surface + // captured in its payload; berdctl/background sends carry no surface and + // bypass telemetry entirely. It runs from the send's user-message-commit + // callback — synchronously after sendCore appends the user message to the + // transcript, or, for the steer paths below, once steerCore's backend + // acknowledgement makes the steered user message durable — so a send that + // fails before committing emits nothing and the queue's automatic retry of + // it cannot double-fire; each accepted send emits exactly once. + // Message.Sent fires every send; Session.Started fires once, on the + // session's first user message — both are intended to co-fire on that first + // send per the schema. + const fireChatSendTelemetry = useCallback( + ( + overridePersona?: { id: string | null; name?: string }, + attachments?: ChatAttachmentDraft[], + ) => { + if (!sessionId) { + return; + } + // Observation only, structurally: this runs inside the send and steer + // commit callbacks, where a throw would reject a dispatch the backend + // already accepted — for steerQueuedMessage that skips queue.dismiss() + // and the retained record re-sends as a duplicate user turn + // (LAWS/CHAT.md: at most one user turn per message). + try { + // Post-commit read: the user message this send committed is already in + // the transcript, so "first" means it is the only user message there — + // and only once the session's history has landed, since an unreplayed + // old session shows the same empty transcript (see chatFirstMessage). + const isFirstMessage = isFirstCommittedUserMessage(sessionId); + // An override with `id: null` is an explicit "send without a persona"; + // no override falls back to the session's selected persona. + const hasPersona = overridePersona + ? overridePersona.id !== null + : Boolean(selectedPersonaId); + const provider = selectedProvider; + const model = + session?.executionTarget?.modelId ?? effectiveModelSelection?.id; + if (isFirstMessage) { + trackChatSessionStarted({ + sessionId, + sourceSurface: chatSourceSurface, + hasProject: Boolean(effectiveProjectId), + hasPersona, + provider, + model, + }); + } + trackChatMessageSent({ + sessionId, + isFirstMessage, + hasAttachments: (attachments?.length ?? 0) > 0, + hasPersona, + provider, + model, + }); + } catch (error) { + perfLog(`[telemetry] chat send telemetry failed: ${String(error)}`); + } + }, + [ + chatSourceSurface, + effectiveModelSelection?.id, + effectiveProjectId, + selectedPersonaId, + selectedProvider, + session?.executionTarget?.modelId, + sessionId, + ], + ); const sendWithAutoCompact = useCallback( ( text: string, @@ -1927,10 +2019,22 @@ export function useChatSessionController({ recordSubmittedDraft(sessionId, text); } }; - const dispatchSend = () => - shouldPassSendOptions - ? sendMessage(text, overridePersona, attachments, nextSendOptions) - : sendMessage(text, overridePersona, attachments); + const dispatchSend = () => { + const baseSendOptions = shouldPassSendOptions + ? nextSendOptions + : undefined; + // Send telemetry is anchored to the user-message commit: firing it + // here, before dispatch, would emit for preparation/dispatch failures + // that commit nothing, and the queue's automatic retry of those would + // double-fire Message.Sent and Session.Started. + return sendMessage(text, overridePersona, attachments, { + ...baseSendOptions, + onUserMessageCommitted: () => { + baseSendOptions?.onUserMessageCommitted?.(); + fireChatSendTelemetry(overridePersona, attachments); + }, + }); + }; if ( !canAutoCompactBeforeSend( @@ -1967,6 +2071,7 @@ export function useChatSessionController({ artifactFolderInstructions, canAutoCompactBeforeSend, compactConversation, + fireChatSendTelemetry, isQueuedSendBlockedNow, recordSubmittedDraft, sendMessage, @@ -2215,19 +2320,20 @@ export function useChatSessionController({ availableSkillsCatalogPrompt, ) : undefined; - const sendOptions = - capturedPersonaSystemPrompt !== undefined || - executionSystemPrompt !== undefined - ? { - ...payload.sendOptions, - ...(capturedPersonaSystemPrompt !== undefined - ? { capturedPersonaSystemPrompt } - : {}), - ...(executionSystemPrompt !== undefined - ? { executionSystemPrompt } - : {}), - } - : payload.sendOptions; + const sendOptions = { + ...payload.sendOptions, + ...(capturedPersonaSystemPrompt !== undefined + ? { capturedPersonaSystemPrompt } + : {}), + ...(executionSystemPrompt !== undefined + ? { executionSystemPrompt } + : {}), + // A captured payload can be dispatched outside this controller — a + // deferred-workspace first send is released to the background + // queued-send pipeline — so its send telemetry keeps the surface that + // accepted it instead of losing it to that pipeline. + telemetrySourceSurface: chatSourceSurface, + }; return { ...payload, persona: @@ -2243,6 +2349,7 @@ export function useChatSessionController({ [ appSkillsCatalogPrompt, availableSkillsCatalogPrompt, + chatSourceSurface, includedWorkspacesPrompt, selectedPersona, workspaceContextReady, @@ -2512,13 +2619,30 @@ export function useChatSessionController({ const accepted = await steerMessage( queuedMessage.text, queuedMessage.attachments, - queuedMessage.sendOptions, + { + ...queuedMessage.sendOptions, + // Same telemetry anchor as dispatchSend: steerCore fires this only + // once the backend acknowledges the steer (the provisional append is + // rolled back otherwise), so a rejected steer emits nothing and the + // retained record can still emit when it later drains or re-steers. + onUserMessageCommitted: () => { + queuedMessage.sendOptions?.onUserMessageCommitted?.(); + fireChatSendTelemetry(undefined, queuedMessage.attachments); + }, + }, ); if (accepted) { queue.dismiss(); } return accepted; - }, [queue, readOnly, sessionId, steerMessage, supportsSteering]); + }, [ + fireChatSendTelemetry, + queue, + readOnly, + sessionId, + steerMessage, + supportsSteering, + ]); const steerDraftMessage = useCallback( async ( @@ -2536,9 +2660,23 @@ export function useChatSessionController({ return false; } - return steerMessage(text, attachments, sendOptions); + return steerMessage(text, attachments, { + ...sendOptions, + // Same telemetry anchor as dispatchSend; see steerQueuedMessage. + onUserMessageCommitted: () => { + sendOptions?.onUserMessageCommitted?.(); + fireChatSendTelemetry(undefined, attachments); + }, + }); }, - [chatState, readOnly, sessionId, steerMessage, supportsSteering], + [ + chatState, + fireChatSendTelemetry, + readOnly, + sessionId, + steerMessage, + supportsSteering, + ], ); const handleCreatePersona = useCallback(() => { diff --git a/src/features/chat/lib/__tests__/steerCore.test.ts b/src/features/chat/lib/__tests__/steerCore.test.ts index 428a1752b..8f5602679 100644 --- a/src/features/chat/lib/__tests__/steerCore.test.ts +++ b/src/features/chat/lib/__tests__/steerCore.test.ts @@ -90,3 +90,91 @@ describe("steerPromptInSession payload budget", () => { expect(mockAcpSteerMessage).toHaveBeenCalledTimes(1); }); }); + +// A steer's user-message append is provisional until the backend acknowledges +// it, so the ChatSendOptions commit callback — the anchor `berd_chat` send +// telemetry rides on — must fire exactly at the durable commit: never at the +// append, never for a rolled-back steer, and still for a steer whose delivery +// was established even though the acknowledgement errored. +describe("steerPromptInSession commit callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + activeSessionId: null, + isConnected: true, + }); + }); + + it("fires only once the backend acknowledges the steer", async () => { + const onUserMessageCommitted = vi.fn(); + let commitCallsAtDispatch = -1; + mockAcpSteerMessage.mockImplementation(async () => { + // The provisional user-message append has already happened by the time + // the ACP call goes out; the commit callback must not have fired yet. + commitCallsAtDispatch = onUserMessageCommitted.mock.calls.length; + return { runId: "run-1", messageId: "msg-1" }; + }); + + const accepted = await steerPromptInSession( + "session-1", + "make it shorter", + undefined, + { onUserMessageCommitted }, + ); + + expect(accepted).toBe(true); + expect(commitCallsAtDispatch).toBe(0); + expect(onUserMessageCommitted).toHaveBeenCalledTimes(1); + }); + + it("does not fire for a steer that is rolled back", async () => { + const onUserMessageCommitted = vi.fn(); + mockAcpSteerMessage.mockRejectedValue(new Error("backend down")); + + const accepted = await steerPromptInSession( + "session-1", + "make it shorter", + undefined, + { onUserMessageCommitted }, + ); + + expect(accepted).toBe(false); + expect(onUserMessageCommitted).not.toHaveBeenCalled(); + // The rollback removed the provisional user message; only the error + // notification remains. + const messages = + useChatStore.getState().messagesBySession["session-1"] ?? []; + expect(messages.some((message) => message.role === "user")).toBe(false); + }); + + it("fires when delivery was established despite an acknowledgement error", async () => { + const onUserMessageCommitted = vi.fn(); + mockAcpSteerMessage.mockImplementation(async () => { + // The backend delivered the steer (the notification handler flips the + // message's delivery metadata) before the acknowledgement was lost, so + // the user message stays committed. + const store = useChatStore.getState(); + const userMessage = store.messagesBySession["session-1"]?.find( + (message) => message.role === "user", + ); + if (!userMessage) throw new Error("provisional user message missing"); + store.updateMessage("session-1", userMessage.id, (message) => ({ + ...message, + metadata: { ...message.metadata, delivery: "steer" }, + })); + throw new Error("acknowledgement lost"); + }); + + const accepted = await steerPromptInSession( + "session-1", + "make it shorter", + undefined, + { onUserMessageCommitted }, + ); + + expect(accepted).toBe(true); + expect(onUserMessageCommitted).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/chat/lib/chatFirstMessage.test.ts b/src/features/chat/lib/chatFirstMessage.test.ts new file mode 100644 index 000000000..9dfe93592 --- /dev/null +++ b/src/features/chat/lib/chatFirstMessage.test.ts @@ -0,0 +1,138 @@ +// Regression coverage for the `berd_chat` `is_first_message` read: the +// post-commit transcript read only counts as evidence once the session's +// history has landed, so a send into a just-opened old session whose replay is +// still in flight is never reported as the session's first message. +import { beforeEach, describe, expect, it } from "vitest"; + +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { useChatStore } from "@/features/chat/stores/chatStore"; +import { + createSystemNotificationMessage, + createUserMessage, +} from "@/shared/types/messages"; + +import { isFirstCommittedUserMessage } from "./chatFirstMessage"; + +const SESSION_ID = "session-1"; + +function sessionFixture(overrides: Partial = {}): ChatSession { + return { + id: SESSION_ID, + title: "Chat", + createdAt: "2026-08-13T00:00:00.000Z", + updatedAt: "2026-08-13T00:00:00.000Z", + messageCount: 0, + ...overrides, + }; +} + +/** Mimics sendCore's commit: the user message lands in the transcript. */ +function commitUserMessage(text = "hello") { + useChatStore.getState().addMessage(SESSION_ID, createUserMessage(text)); +} + +describe("isFirstCommittedUserMessage", () => { + beforeEach(() => { + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + loadingSessionIds: new Set(), + activeSessionId: null, + }); + useChatSessionStore.setState({ + sessions: [sessionFixture()], + activeSessionId: null, + hasHydratedSessions: true, + }); + }); + + it("reports the committed message as first for a settled empty session", () => { + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(true); + }); + + // The L1 race: replayed history accumulates in the replay buffer and only + // reaches the store when the load finishes, so an old session under replay + // looks exactly like a brand-new one. + it("reports not-first while the session's history is still replaying", () => { + useChatStore.getState().setSessionLoading(SESSION_ID, true); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + // Same race after the load settles without landing history — a failed load + // leaves the transcript empty (its error notice is a system message), and + // the session's backend message count is the only surviving evidence. + it("reports not-first when the session record counts backend messages", () => { + useChatSessionStore.setState({ + sessions: [sessionFixture({ messageCount: 12 })], + }); + useChatStore + .getState() + .addMessage( + SESSION_ID, + createSystemNotificationMessage("Failed to load session", "error"), + ); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + it("reports not-first once replayed history is in the transcript", () => { + useChatStore + .getState() + .addMessage(SESSION_ID, createUserMessage("an earlier turn")); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + it("reports not-first for a session with no record to vouch for it", () => { + useChatSessionStore.setState({ sessions: [] }); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + // A pinned Home widget inserts a placeholder record for a session missing + // from the list, so its zero message count is a default rather than the + // backend's; "failed" means the hydration never resolved. + it("reports not-first for a pinned record still hydrating", () => { + useChatSessionStore.setState({ + sessions: [sessionFixture({ pinnedLoadState: "loading" })], + }); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + it("reports not-first for a pinned record whose hydration failed", () => { + useChatSessionStore.setState({ + sessions: [sessionFixture({ pinnedLoadState: "failed" })], + }); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); + + it("ignores system notifications when counting the session's user messages", () => { + useChatStore + .getState() + .addMessage( + SESSION_ID, + createSystemNotificationMessage("Working directory missing", "warning"), + ); + commitUserMessage(); + + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(true); + }); + + it("reports not-first before any message has committed", () => { + expect(isFirstCommittedUserMessage(SESSION_ID)).toBe(false); + }); +}); diff --git a/src/features/chat/lib/chatFirstMessage.ts b/src/features/chat/lib/chatFirstMessage.ts new file mode 100644 index 000000000..9e6ca2b38 --- /dev/null +++ b/src/features/chat/lib/chatFirstMessage.ts @@ -0,0 +1,78 @@ +/** + * Replay-aware `is_first_message` read for the `berd_chat` send events. + * + * Both send-telemetry sites — the foreground controller's + * `fireChatSendTelemetry` and the background released-deferred leg in + * `queuedSessionSend` — fire from the send's user-message-commit callback and + * ask the same question: is the user message that just committed the session's + * first? `is_first_message` carries the answer, and `Session Started` fires + * only when it is true. + * + * Reading that answer straight off the transcript ("it holds exactly one user + * message, and that is the one just committed") is only sound while the + * transcript is complete — and it is not complete while a session's history is + * replaying. Replayed messages accumulate in a module-level buffer and reach + * the store as a single flush when the load finishes + * (`sessionActivation.performSessionMessagesLoad`), so a just-opened old + * session shows an *empty* transcript until then. Nothing gates sending on that + * load: the queue drain only requires an idle runtime + * (`queuedMessageReadiness`), and a popped-out session window renders its + * composer while its own load is still in flight. Typing into an old session + * right after opening it would therefore report a long-running conversation as + * brand new. + * + * So the transcript read only stands once the session's history is accounted + * for: no load in flight, and a session record that positively reports an empty + * session. Anything else — a replay still landing, a pinned placeholder or a + * failed pinned hydration, a backend message count above zero — is read as "the + * session already had messages". That direction is deliberate: suppressing the + * event costs one session's `Session Started`, while emitting it falsely + * reports a resumed conversation as a new one. + * + * App-restart queue restore never reaches the unresolved case: restored records + * are skipped by the drain (`useMessageQueue`) until `markQueuedMessagesReady` + * runs, which `loadSessionMessages` only calls after the replay has landed. + */ +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { useChatStore } from "@/features/chat/stores/chatStore"; + +/** + * True when the user message a send just committed is the session's first. + * + * Call from the send's `onUserMessageCommitted` callback: the committed message + * is already in the transcript, so "first" means it is the only user message + * there *and* the session is known to have had none before it. + */ +export function isFirstCommittedUserMessage(sessionId: string): boolean { + const transcript = useChatStore.getState().messagesBySession[sessionId] ?? []; + const committedUserMessages = transcript.filter( + (message) => message.role === "user", + ).length; + if (committedUserMessages !== 1) { + return false; + } + return isSessionHistoryAccountedFor(sessionId); +} + +/** + * True when the store's view of the session's history is both settled and + * empty, so a lone user message in the transcript really is the first one. + */ +function isSessionHistoryAccountedFor(sessionId: string): boolean { + // A replay in flight flushes the session's history into the transcript when + // it lands; until then an empty transcript proves nothing about the session. + if (useChatStore.getState().loadingSessionIds.has(sessionId)) { + return false; + } + const session = useChatSessionStore.getState().getSession(sessionId); + // No record to vouch for the session, or only the placeholder a pinned Home + // widget inserts for a session missing from the list — its zero count is a + // default, not backend metadata, and "failed" means the load never resolved. + if (!session || session.pinnedLoadState) { + return false; + } + // The backend's own count of the session's persisted messages, carried on the + // session list/info. The local commit above does not touch it, so it still + // describes the session as it was before this send. + return session.messageCount === 0; +} diff --git a/src/features/chat/lib/chatTelemetry.ts b/src/features/chat/lib/chatTelemetry.ts new file mode 100644 index 000000000..256950cb2 --- /dev/null +++ b/src/features/chat/lib/chatTelemetry.ts @@ -0,0 +1,96 @@ +/** + * Thin, feature-scoped wrappers over the vendored `berd_chat` event factories, + * mirroring `src/features/agents/lib/agentTelemetry.ts`. + * + * Each wrapper builds the vendored schema event and hands it to the shared + * telemetry `track` chokepoint, inheriting its prod/staging gate, consent + * gating, and startup buffering/backdating for free. Keeping the wrappers + * here (rather than in `client.ts`) keeps `berd_chat` wiring additive and local + * to the chat feature. + */ +import { track } from "@/shared/telemetry/client"; +import { + type BerdChatChatSourceSurface, + berdChatMessageSent, + berdChatSessionStarted, +} from "@/shared/telemetry/events"; + +/** + * The `source_surface` values this feature emits. These are the exact schema + * values reachable from the chat controller flows wired here. Detached + * `session:*` windows run the same controller flows and report MAIN_CHAT; + * there is no separate session-window surface on the wire. + */ +export const CHAT_SOURCE_SURFACE = { + MAIN_CHAT: "CHAT_SOURCE_SURFACE_MAIN_CHAT", + GLOBAL_COMPOSER: "CHAT_SOURCE_SURFACE_GLOBAL_COMPOSER", + AGENT_BUILDER: "CHAT_SOURCE_SURFACE_AGENT_BUILDER", +} as const satisfies Record; + +// Optional provider/model only carry signal when configured; drop blanks so we +// never emit an empty-string attribute standing in for "not set". +function nonEmpty(value: string | null | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * A chat session begins, fired when the session's first user message is + * committed to the transcript (a session id already exists at this point). + * "First" is decided by `chatFirstMessage`, which withholds the event while a + * session's history has yet to replay rather than call a resumed session new. + */ +export function trackChatSessionStarted({ + sessionId, + sourceSurface, + hasProject, + hasPersona, + provider, + model, +}: { + sessionId: string; + sourceSurface: BerdChatChatSourceSurface; + hasProject: boolean; + hasPersona: boolean; + provider?: string | null; + model?: string | null; +}): void { + track( + berdChatSessionStarted({ + session_id: sessionId, + source_surface: sourceSurface, + has_project: hasProject, + has_persona: hasPersona, + provider: nonEmpty(provider), + model: nonEmpty(model), + }), + ); +} + +/** The user sends a chat message. */ +export function trackChatMessageSent({ + sessionId, + isFirstMessage, + hasAttachments, + hasPersona, + provider, + model, +}: { + sessionId: string; + isFirstMessage: boolean; + hasAttachments: boolean; + hasPersona: boolean; + provider?: string | null; + model?: string | null; +}): void { + track( + berdChatMessageSent({ + session_id: sessionId, + is_first_message: isFirstMessage, + has_attachments: hasAttachments, + has_persona: hasPersona, + provider: nonEmpty(provider), + model: nonEmpty(model), + }), + ); +} diff --git a/src/features/chat/lib/queuedSessionSend.test.ts b/src/features/chat/lib/queuedSessionSend.test.ts new file mode 100644 index 000000000..bfdb2777b --- /dev/null +++ b/src/features/chat/lib/queuedSessionSend.test.ts @@ -0,0 +1,370 @@ +// Regression coverage for `berd_chat` send telemetry on the released +// deferred-workspace leg: a foreground composer send that was deferred for +// workspace setup is dispatched by this background pipeline, so its events +// must anchor to the user-message commit here — while berdctl/background +// payloads (which carry no captured surface) stay untracked by design. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { ensureReplayBuffer } from "@/features/chat/hooks/replayBuffer"; +import { resetSessionTargetCoordinatorsForTests } from "@/features/chat/lib/sessionTargetCoordinator"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { + type QueuedMessageRecord, + useChatStore, +} from "@/features/chat/stores/chatStore"; +import type { ProjectInfo } from "@/features/projects/api/projects"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { createUserMessage } from "@/shared/types/messages"; + +const mocks = vi.hoisted(() => ({ + acpGetSessionInfo: vi.fn(), + acpLoadSession: vi.fn(), + acpPrepareSession: vi.fn(), + acpSendMessage: vi.fn(), + listProjects: vi.fn(), + resolveSessionCwd: vi.fn(), + loadWorkspaceInstructionFiles: vi.fn(), + listSkills: vi.fn(), + trackChatMessageSent: vi.fn(), + trackChatSessionStarted: vi.fn(), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpGetSessionInfo: (...args: unknown[]) => mocks.acpGetSessionInfo(...args), + acpLoadSession: (...args: unknown[]) => mocks.acpLoadSession(...args), + acpPrepareSession: (...args: unknown[]) => mocks.acpPrepareSession(...args), + acpSendMessage: (...args: unknown[]) => mocks.acpSendMessage(...args), +})); + +vi.mock("@/features/projects/api/projects", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/projects/api/projects") + >()), + listProjects: (...args: unknown[]) => mocks.listProjects(...args), +})); + +vi.mock( + "@/features/projects/lib/sessionCwdSelection", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/projects/lib/sessionCwdSelection") + >()), + resolveSessionCwd: (...args: unknown[]) => mocks.resolveSessionCwd(...args), + }), +); + +vi.mock("@/features/chat/api/workspaceContext", () => ({ + loadWorkspaceInstructionFiles: (...args: unknown[]) => + mocks.loadWorkspaceInstructionFiles(...args), +})); + +vi.mock("@/features/skills/api/skills", () => ({ + listSkills: (...args: unknown[]) => mocks.listSkills(...args), +})); + +// Wrappers are mocked so the tests can pin the fire points; CHAT_SOURCE_SURFACE +// and the rest of the module stay real. +vi.mock("@/features/chat/lib/chatTelemetry", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/chat/lib/chatTelemetry") + >()), + trackChatMessageSent: (...args: unknown[]) => + mocks.trackChatMessageSent(...args), + trackChatSessionStarted: (...args: unknown[]) => + mocks.trackChatSessionStarted(...args), +})); + +import { CHAT_SOURCE_SURFACE } from "@/features/chat/lib/chatTelemetry"; +import { sendQueuedPromptToExistingSessionInBackground } from "./queuedSessionSend"; + +const SESSION_ID = "deferred-release-session"; +const EXECUTION_TARGET = { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-6-berd", + modelName: "GPT-6 Berd", +} as const; + +const PROJECT: ProjectInfo = { + id: "project-1", + path: "/tmp/project-source", + name: "Project One", + description: "", + prompt: "", + icon: "", + color: "", + projectWorkspaces: [], + workingDirs: ["/tmp/project"], + useWorktrees: false, + order: 0, + archivedAt: null, +}; + +function releasedRecord( + overrides: Partial = {}, +): QueuedMessageRecord & { kind: "transport-ready" } { + return { + kind: "transport-ready", + recordId: "released-record-1", + releasedFromDeferred: true, + payload: { + text: "held first prompt", + persona: { kind: "persona", id: "reviewer", name: "Reviewer" }, + attachments: [ + { + id: "attachment-1", + kind: "file", + name: "notes.txt", + path: "/tmp/notes.txt", + }, + ], + sendOptions: { + telemetrySourceSurface: CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER, + }, + ...overrides, + }, + }; +} + +function trackCallCount(): number { + return ( + mocks.trackChatSessionStarted.mock.calls.length + + mocks.trackChatMessageSent.mock.calls.length + ); +} + +describe("sendQueuedPromptToExistingSessionInBackground telemetry", () => { + beforeEach(() => { + resetSessionTargetCoordinatorsForTests(); + vi.clearAllMocks(); + useChatStore.setState({ + messagesBySession: {}, + sessionStateById: {}, + queuedMessageBySession: {}, + draftsBySession: {}, + activeSessionId: null, + isViewingActiveSession: false, + loadingSessionIds: new Set(), + scrollTargetMessageBySession: {}, + }); + useChatSessionStore.setState({ + sessions: [ + { + id: SESSION_ID, + title: "Deferred release", + executionTarget: EXECUTION_TARGET, + projectId: PROJECT.id, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + messageCount: 0, + }, + ], + activeSessionId: null, + activeWorkspaceBySession: {}, + hasHydratedSessions: true, + }); + useProjectStore.setState({ projects: [], hasFetchedProjects: true }); + useAgentStore.setState({ + personas: [ + { + id: "reviewer", + displayName: "Reviewer", + systemPrompt: "Review carefully.", + isBuiltin: false, + writable: true, + }, + ], + }); + + mocks.acpGetSessionInfo.mockResolvedValue(null); + mocks.acpLoadSession.mockResolvedValue(undefined); + mocks.acpPrepareSession.mockResolvedValue(undefined); + // Mirrors the real transport contract sendCore relies on: the user + // message commits at onPromptDispatching, before the turn settles. + mocks.acpSendMessage.mockImplementation((...args: unknown[]) => { + const options = args[2] as + | { onPromptDispatching?: () => void; onPromptDispatched?: () => void } + | undefined; + options?.onPromptDispatching?.(); + options?.onPromptDispatched?.(); + return Promise.resolve(undefined); + }); + mocks.listProjects.mockResolvedValue([PROJECT]); + mocks.resolveSessionCwd.mockResolvedValue("/tmp/project"); + mocks.loadWorkspaceInstructionFiles.mockResolvedValue([]); + mocks.listSkills.mockResolvedValue([]); + }); + + it("emits Session Started and Message Sent exactly once, at the user-message commit", async () => { + // Captured inside the transport mock: the commit has not happened yet + // when the transport is invoked, so nothing may have fired by then. + let trackCallsBeforeCommit = -1; + mocks.acpSendMessage.mockImplementationOnce((...args: unknown[]) => { + trackCallsBeforeCommit = trackCallCount(); + const options = args[2] as + | { onPromptDispatching?: () => void; onPromptDispatched?: () => void } + | undefined; + options?.onPromptDispatching?.(); + options?.onPromptDispatched?.(); + return Promise.resolve(undefined); + }); + + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord(), + ); + + expect(trackCallsBeforeCommit).toBe(0); + expect(mocks.trackChatSessionStarted).toHaveBeenCalledTimes(1); + expect(mocks.trackChatSessionStarted).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + sourceSurface: CHAT_SOURCE_SURFACE.GLOBAL_COMPOSER, + hasProject: true, + hasPersona: true, + provider: EXECUTION_TARGET.harnessId, + model: EXECUTION_TARGET.modelId, + }); + expect(mocks.trackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mocks.trackChatMessageSent).toHaveBeenCalledWith({ + sessionId: SESSION_ID, + isFirstMessage: true, + hasAttachments: true, + hasPersona: true, + provider: EXECUTION_TARGET.harnessId, + model: EXECUTION_TARGET.modelId, + }); + }); + + it("emits nothing when the dispatch fails before the user message commits", async () => { + mocks.acpSendMessage.mockImplementationOnce(() => { + throw new Error("transport refused before dispatch"); + }); + + await expect( + sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord(), + ), + ).rejects.toThrow("transport refused before dispatch"); + + expect(mocks.trackChatSessionStarted).not.toHaveBeenCalled(); + expect(mocks.trackChatMessageSent).not.toHaveBeenCalled(); + }); + + it("emits Message Sent as not-first and no Session Started when a user message already exists", async () => { + useChatStore + .getState() + .addMessage(SESSION_ID, createUserMessage("earlier message")); + + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord(), + ); + + expect(mocks.trackChatSessionStarted).not.toHaveBeenCalled(); + expect(mocks.trackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mocks.trackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ isFirstMessage: false }), + ); + }); + + // A session with backend history and an empty local transcript replays that + // history during the background hydration (hydration hard-fails without the + // replay), so the release commits into the replayed transcript and must not + // read as a brand-new session. + it("reports a release that replays the session's history during hydration as not-first", async () => { + useChatSessionStore.setState({ + sessions: [ + { + id: SESSION_ID, + title: "Deferred release", + executionTarget: EXECUTION_TARGET, + projectId: PROJECT.id, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + messageCount: 12, + }, + ], + }); + // The load replays the session's history into the buffer, the way the real + // notification stream does while `session/load` is in flight. + mocks.acpLoadSession.mockImplementation(async () => { + ensureReplayBuffer(SESSION_ID).push( + createUserMessage("replayed history"), + ); + return undefined; + }); + + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord(), + ); + + expect(mocks.trackChatSessionStarted).not.toHaveBeenCalled(); + expect(mocks.trackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mocks.trackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ isFirstMessage: false }), + ); + }); + + it("reports an explicit no-persona release as persona-less", async () => { + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord({ persona: { kind: "none" }, attachments: undefined }), + ); + + expect(mocks.trackChatMessageSent).toHaveBeenCalledTimes(1); + expect(mocks.trackChatMessageSent).toHaveBeenCalledWith( + expect.objectContaining({ hasPersona: false, hasAttachments: false }), + ); + expect(mocks.trackChatSessionStarted).toHaveBeenCalledWith( + expect.objectContaining({ hasPersona: false }), + ); + }); + + // The anchor is observation-only by construction: sendCore runs the commit + // callback inside the dispatch path, so an uncontained throw would reject a + // release the backend already accepted and skip the post-commit state + // transitions. + it("resolves the release even when the telemetry wrapper throws at the commit", async () => { + mocks.trackChatMessageSent.mockImplementationOnce(() => { + throw new Error("telemetry exploded"); + }); + + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord(), + ); + + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + expect(mocks.trackChatMessageSent).toHaveBeenCalledTimes(1); + // The commit stood: the released user message is in the transcript. + expect( + useChatStore + .getState() + .messagesBySession[SESSION_ID]?.some( + (message) => message.role === "user", + ), + ).toBe(true); + }); + + it("emits nothing for a released payload without a captured surface (berdctl origin)", async () => { + await sendQueuedPromptToExistingSessionInBackground( + SESSION_ID, + releasedRecord({ + persona: { kind: "inherit" }, + attachments: undefined, + // A berdctl-deferred payload carries origin metadata but no captured + // composer surface — the documented berdctl telemetry exclusion. + sendOptions: { + userMessageMetadata: { origin: "berdctl_cross_session" }, + }, + }), + ); + + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + expect(mocks.trackChatSessionStarted).not.toHaveBeenCalled(); + expect(mocks.trackChatMessageSent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/chat/lib/queuedSessionSend.ts b/src/features/chat/lib/queuedSessionSend.ts index 6c2665d7e..992e12cc4 100644 --- a/src/features/chat/lib/queuedSessionSend.ts +++ b/src/features/chat/lib/queuedSessionSend.ts @@ -13,6 +13,11 @@ import { import { loadWorkspaceInstructionFiles } from "@/features/chat/api/workspaceContext"; import { sendPromptInBackground } from "@/features/chat/lib/backgroundSend"; +import { isFirstCommittedUserMessage } from "@/features/chat/lib/chatFirstMessage"; +import { + trackChatMessageSent, + trackChatSessionStarted, +} from "@/features/chat/lib/chatTelemetry"; import { loadSessionMessages } from "@/features/chat/lib/sessionActivation"; import { SessionDispatchContentionError, @@ -41,6 +46,7 @@ import { type SessionExecutionTarget, } from "@/features/chat/lib/sessionExecutionTarget"; import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/gooseServeExecutionTarget"; +import { perfLog } from "@/shared/lib/perfLog"; async function findPersona(personaId: string): Promise { const cached = useAgentStore.getState().getPersonaById(personaId); @@ -342,6 +348,56 @@ export async function sendQueuedPromptToExistingSessionInBackground( sendOptions.systemPrompt ?? workspaceContextPrompt, ); assertSessionExecutionTarget(sessionId, preparedExecutionTarget); + // A foreground composer send that was deferred for workspace setup is + // dispatched here, not by its controller, so its `berd_chat` send + // telemetry anchors to this dispatch's user-message commit — the same + // anchor the foreground path uses (fireChatSendTelemetry in + // useChatSessionController): a pre-commit failure emits nothing, and each + // accepted send emits exactly once. The surface rides in the payload + // (`telemetrySourceSurface`); berdctl/background payloads never carry one + // and stay untracked by design. + const telemetrySourceSurface = sendOptions.telemetrySourceSurface; + const fireSendTelemetry = telemetrySourceSurface + ? () => { + // Observation only, structurally (matching fireChatSendTelemetry): + // this runs inside sendCore's commit callback, where a throw would + // reject a send the backend already accepted and skip the state + // transitions that follow the commit. + try { + // Post-commit read, matching the foreground anchor: the user + // message this send committed is already in the transcript, so + // "first" means it is the only user message there, once the + // session's history has landed (see chatFirstMessage). + const isFirstMessage = isFirstCommittedUserMessage(sessionId); + const hasPersona = Boolean(persona); + const provider = preparedExecutionTarget.harnessId; + const model = preparedExecutionTarget.modelId; + if (isFirstMessage) { + trackChatSessionStarted({ + sessionId, + sourceSurface: telemetrySourceSurface, + hasProject: Boolean( + useChatSessionStore.getState().getSession(sessionId) + ?.projectId, + ), + hasPersona, + provider, + model, + }); + } + trackChatMessageSent({ + sessionId, + isFirstMessage, + hasAttachments: (payload.attachments?.length ?? 0) > 0, + hasPersona, + provider, + model, + }); + } catch (error) { + perfLog(`[telemetry] chat send telemetry failed: ${String(error)}`); + } + } + : undefined; await sendPromptInBackground( sessionId, payload.text, @@ -353,7 +409,7 @@ export async function sendQueuedPromptToExistingSessionInBackground( }, payload.attachments, beforeUserMessageCommitted, - undefined, + fireSendTelemetry, () => assertSessionExecutionTarget(sessionId, preparedExecutionTarget), onPromptDispatched, ); diff --git a/src/features/chat/lib/steerCore.ts b/src/features/chat/lib/steerCore.ts index a6c8dc5d6..2dd1a930e 100644 --- a/src/features/chat/lib/steerCore.ts +++ b/src/features/chat/lib/steerCore.ts @@ -163,7 +163,6 @@ export async function steerPromptInSession( useChatSessionStore.getState().patchSession(sessionId, { updatedAt: new Date().toISOString(), }); - return true; } catch (err) { const liveStore = useChatStore.getState(); const liveMessage = liveStore.messagesBySession[sessionId]?.find( @@ -172,26 +171,33 @@ export async function steerPromptInSession( message.metadata?.steeringRequestId === userMessage.id, ); const deliveryWasEstablished = liveMessage?.metadata?.delivery === "steer"; - if (deliveryWasEstablished) { - return true; - } - - const liveMessageId = liveMessage?.id ?? userMessage.id; - liveStore.removeMessage(sessionId, liveMessageId); - if ( - liveStore.getSessionRuntime(sessionId).pendingInterventionBoundary - ?.interventionMessageId === liveMessageId - ) { - liveStore.setPendingInterventionBoundary(sessionId, null); - } - const errorMessage = formatSteerErrorMessage(err); - liveStore.addMessage( - sessionId, - createSystemNotificationMessage(errorMessage, "error"), - ); - if (options.throwOnError) { - throw new Error(errorMessage); + if (!deliveryWasEstablished) { + const liveMessageId = liveMessage?.id ?? userMessage.id; + liveStore.removeMessage(sessionId, liveMessageId); + if ( + liveStore.getSessionRuntime(sessionId).pendingInterventionBoundary + ?.interventionMessageId === liveMessageId + ) { + liveStore.setPendingInterventionBoundary(sessionId, null); + } + const errorMessage = formatSteerErrorMessage(err); + liveStore.addMessage( + sessionId, + createSystemNotificationMessage(errorMessage, "error"), + ); + if (options.throwOnError) { + throw new Error(errorMessage); + } + return false; } - return false; } + // Unlike sendCore, the user-message append above is provisional — the catch + // rolls it back when the steer never reached the backend. The commit + // callback therefore fires here instead, once the message is durably + // committed: after acknowledgement, or after delivery was established + // despite an acknowledgement error. It runs outside the try so a throwing + // callback cannot trip the rollback of an acknowledged steer. Callers that + // do not wire it (berdctl, voice conversation) get no commit notification. + sendOptions?.onUserMessageCommitted?.(); + return true; } diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 8c72ca590..ff145c1c0 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -1,6 +1,7 @@ import type { ReactNode, RefObject } from "react"; import type { AcpProvider } from "@/shared/api/acp"; import type { AgentProviderReadiness } from "@/features/providers/hooks/useAgentProviderStatus"; +import type { BerdChatChatSourceSurface } from "@/shared/telemetry/events"; import type { Persona } from "@/shared/types/agents"; import type { ChatAttachmentDraft, @@ -56,6 +57,15 @@ export interface ChatSendOptions { onUserMessageCommitted?: () => void; /** Fully composed execution prompt captured for a queued send. */ executionSystemPrompt?: string; + /** + * Composer surface that accepted this send, captured for `berd_chat` send + * telemetry. A queued record can outlive the surface that accepted it — a + * deferred-workspace first send is released to the background queued-send + * pipeline, which cannot recompute the surface — so it rides with the + * payload. berdctl/background-origin payloads never set it; their sends + * stay untracked by design. + */ + telemetrySourceSurface?: BerdChatChatSourceSurface; /** Persona-only prompt captured while workspace context is still loading. */ capturedPersonaSystemPrompt?: string; displayText?: string; diff --git a/src/features/feedback/FeedbackDialog.test.tsx b/src/features/feedback/FeedbackDialog.test.tsx index 8007dffe4..f344821c0 100644 --- a/src/features/feedback/FeedbackDialog.test.tsx +++ b/src/features/feedback/FeedbackDialog.test.tsx @@ -14,7 +14,6 @@ const mockGetVersion = vi.hoisted(() => vi.fn()); const mockInvoke = vi.hoisted(() => vi.fn()); const mockOpenDialog = vi.hoisted(() => vi.fn()); const mockInspectAttachmentPaths = vi.hoisted(() => vi.fn()); -const mockTrackFeedbackSubmitted = vi.hoisted(() => vi.fn()); vi.mock("@tauri-apps/api/app", () => ({ getVersion: mockGetVersion, @@ -33,10 +32,6 @@ vi.mock("@/shared/api/system", () => ({ inspectAttachmentPaths: mockInspectAttachmentPaths, })); -vi.mock("@/shared/telemetry/client", () => ({ - trackFeedbackSubmitted: mockTrackFeedbackSubmitted, -})); - vi.mock("sonner", () => ({ toast: { error: vi.fn(), @@ -101,22 +96,6 @@ describe("FeedbackDialog", () => { await waitFor(() => { expect(toast.error).toHaveBeenCalledWith(message); }); - expect(mockTrackFeedbackSubmitted).not.toHaveBeenCalled(); - }); - - it("tracks feedback submitted after the backend accepts it", async () => { - const user = userEvent.setup(); - vi.mocked(submitFeedbackIssue).mockResolvedValueOnce({}); - - render(); - - await fillRequiredFields(user); - await user.click(screen.getByRole("button", { name: "Submit" })); - - await waitFor(() => { - expect(submitFeedbackIssue).toHaveBeenCalled(); - }); - expect(mockTrackFeedbackSubmitted).toHaveBeenCalledTimes(1); }); it("keeps pasted image names scoped to the current paste batch", async () => { diff --git a/src/features/feedback/submitFeedbackReport.test.ts b/src/features/feedback/submitFeedbackReport.test.ts index 1cbb3d6a3..9e1f87053 100644 --- a/src/features/feedback/submitFeedbackReport.test.ts +++ b/src/features/feedback/submitFeedbackReport.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { runDoctor } from "@/shared/api/doctor"; import { submitFeedbackIssue } from "@/shared/api/feedback"; -import { trackFeedbackSubmitted } from "@/shared/telemetry/client"; import { submitFeedbackReport } from "./submitFeedbackReport"; const mockGetVersion = vi.hoisted(() => vi.fn()); @@ -10,9 +9,6 @@ vi.mock("@tauri-apps/api/app", () => ({ getVersion: mockGetVersion })); vi.mock("@/shared/lib/platform", () => ({ getPlatform: () => "mac" })); vi.mock("@/shared/api/doctor", () => ({ runDoctor: vi.fn() })); vi.mock("@/shared/api/feedback", () => ({ submitFeedbackIssue: vi.fn() })); -vi.mock("@/shared/telemetry/client", () => ({ - trackFeedbackSubmitted: vi.fn(), -})); describe("submitFeedbackReport", () => { beforeEach(() => { @@ -42,7 +38,6 @@ describe("submitFeedbackReport", () => { doctorReport: null, labelIds: undefined, }); - expect(trackFeedbackSubmitted).toHaveBeenCalledOnce(); }); it("runs Doctor only after explicit diagnostics opt-in", async () => { @@ -81,7 +76,7 @@ describe("submitFeedbackReport", () => { ); }); - it("does not track telemetry when submission fails", async () => { + it("propagates a submission failure to the caller", async () => { vi.mocked(submitFeedbackIssue).mockRejectedValue(new Error("offline")); await expect( @@ -91,6 +86,5 @@ describe("submitFeedbackReport", () => { includeLogs: false, }), ).rejects.toThrow("offline"); - expect(trackFeedbackSubmitted).not.toHaveBeenCalled(); }); }); diff --git a/src/features/feedback/submitFeedbackReport.ts b/src/features/feedback/submitFeedbackReport.ts index 52f1c5761..6abee23e8 100644 --- a/src/features/feedback/submitFeedbackReport.ts +++ b/src/features/feedback/submitFeedbackReport.ts @@ -5,7 +5,6 @@ import { submitFeedbackIssue, } from "@/shared/api/feedback"; import { getPlatform } from "@/shared/lib/platform"; -import { trackFeedbackSubmitted } from "@/shared/telemetry/client"; export interface SubmitFeedbackReportInput { title: string; @@ -44,7 +43,7 @@ export async function submitFeedbackReport( } input.beforeSubmit?.(); - const result = await submitFeedbackIssue({ + return await submitFeedbackIssue({ title: `${input.title.trim()}${input.titleSuffix ?? ""}`, description: buildEnhancedDescription( input.description.trim(), @@ -58,8 +57,6 @@ export async function submitFeedbackReport( doctorReport, labelIds: input.labelIds, }); - trackFeedbackSubmitted(); - return result; } export function buildEnhancedDescription( diff --git a/src/features/home/hooks/usePinToHomeWidget.test.tsx b/src/features/home/hooks/usePinToHomeWidget.test.tsx index 2c4b16e21..2358981a8 100644 --- a/src/features/home/hooks/usePinToHomeWidget.test.tsx +++ b/src/features/home/hooks/usePinToHomeWidget.test.tsx @@ -7,12 +7,21 @@ import { HOME_LAYOUT_ID, saveLayoutItems, } from "@/features/layout/api/layout"; +import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; +import { homeWidgetsToLayoutItems } from "../lib/homeLayoutMapper"; +import { resetHomePinTelemetryForTests } from "../lib/homePinTelemetry"; +import { + trackHomeItemPinned, + trackHomeItemUnpinned, +} from "../lib/homeTelemetry"; import { resetHomeWidgetStoreForTests, useHomeWidgetStore, } from "../stores/homeWidgetStore"; +import type { WidgetInstance } from "../widgets/types"; import { choosePinPlacementCenter, + usePinBatchToHome, usePinToHomeWidget, } from "./usePinToHomeWidget"; @@ -41,6 +50,11 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("../lib/homeTelemetry", () => ({ + trackHomeItemPinned: vi.fn(), + trackHomeItemUnpinned: vi.fn(), +})); + function layout(overrides: Partial = {}): Layout { return { layoutId: HOME_LAYOUT_ID, @@ -76,11 +90,15 @@ function layout(overrides: Partial = {}): Layout { beforeEach(() => { resetHomeWidgetStoreForTests(); + resetHomePinTelemetryForTests(); + useChatSessionStore.setState({ sessions: [] }); vi.mocked(getLayout).mockReset(); vi.mocked(saveLayoutItems).mockReset(); vi.mocked(toast.error).mockClear(); vi.mocked(toast.success).mockClear(); vi.mocked(toast.warning).mockClear(); + vi.mocked(trackHomeItemPinned).mockClear(); + vi.mocked(trackHomeItemUnpinned).mockClear(); localStorage.clear(); localStorage.setItem(ONBOARDING_STICKIES_SEEDED_STORAGE_KEY, "6"); }); @@ -307,6 +325,141 @@ describe("usePinToHomeWidget", () => { expect(toast.success).toHaveBeenCalledWith("widgets.unpinFromHome.success"); }); + it("reports a pin only once the layout save is confirmed", async () => { + seedReadyStore([]); + type PendingSave = { + items: Parameters[0]["items"]; + resolve: (result: Awaited>) => void; + }; + let pendingSave: PendingSave | null = null; + vi.mocked(saveLayoutItems).mockImplementation( + (request) => + new Promise((resolve) => { + pendingSave = { items: request.items, resolve }; + }), + ); + + const { result } = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "session-1" }), + ); + + await act(async () => { + await result.current.pinToHome(); + }); + + await waitFor(() => expect(pendingSave).not.toBeNull()); + // The canvas already shows the pin, but nothing is persisted yet. + expect(result.current.isPinned).toBe(true); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + + const save = pendingSave as unknown as PendingSave; + await act(async () => { + save.resolve({ + ok: true, + layout: layout({ itemRevision: 2, items: save.items }), + }); + }); + + await waitFor(() => expect(trackHomeItemPinned).toHaveBeenCalledOnce()); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports no pin when the save fails and the canvas rolls back", async () => { + seedReadyStore([]); + vi.mocked(saveLayoutItems).mockRejectedValue(new Error("save failed")); + + const { result } = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "session-1" }), + ); + + await act(async () => { + await result.current.pinToHome(); + }); + + // The rollback restores the last confirmed layout, which has no pin. + await waitFor(() => expect(result.current.isPinned).toBe(false)); + expect(useHomeWidgetStore.getState().instances).toEqual([]); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + }); + + it("reports the pin that landed when the unpin queued behind it fails", async () => { + seedReadyStore([]); + type PendingSave = { + items: Parameters[0]["items"]; + resolve: (result: Awaited>) => void; + }; + let pendingSave: PendingSave | null = null; + vi.mocked(saveLayoutItems) + .mockImplementationOnce( + (request) => + new Promise((resolve) => { + pendingSave = { items: request.items, resolve }; + }), + ) + .mockRejectedValue(new Error("save failed")); + + const { result } = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "session-1" }), + ); + + await act(async () => { + await result.current.pinToHome(); + }); + await waitFor(() => expect(pendingSave).not.toBeNull()); + + // The user changes their mind while the pin's save is still in flight, so + // the removal is queued behind it. + act(() => { + result.current.unpinFromHome(); + }); + expect(result.current.isPinned).toBe(false); + + const save = pendingSave as unknown as PendingSave; + await act(async () => { + save.resolve({ + ok: true, + layout: layout({ itemRevision: 2, items: save.items }), + }); + }); + + // The pin is confirmed; the unpin behind it fails, and the rollback + // restores the confirmed layout — so the item is durably pinned. + await waitFor(() => expect(result.current.isPinned).toBe(true)); + await waitFor(() => expect(trackHomeItemPinned).toHaveBeenCalledOnce()); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports no unpin when a revision conflict keeps the pin", async () => { + const pins = [chatPin("session-1"), chatPin("session-2")]; + seedReadyStore(pins); + // A conflict merges only additions forward, so the removal never lands: + // the backend layout still carries the pin the user just removed. + vi.mocked(saveLayoutItems).mockResolvedValue({ + ok: false, + reason: "revisionConflict", + layout: layout({ + itemRevision: 9, + items: homeWidgetsToLayoutItems(pins), + }), + }); + + const { result } = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "session-1" }), + ); + + expect(result.current.isPinned).toBe(true); + act(() => { + result.current.unpinFromHome(); + }); + expect(result.current.isPinned).toBe(false); + + await waitFor(() => expect(result.current.isPinned).toBe(true)); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + }); + it("rewrites chat pins from a draft session id to the backend session id", () => { useHomeWidgetStore.setState({ instances: [ @@ -351,4 +504,182 @@ describe("usePinToHomeWidget", () => { }), ]); }); + + it("reports a pinned draft chat immediately and pairs the unpin across promotion", async () => { + seedReadyStore([]); + mockConfirmedSaves(); + useChatSessionStore.setState({ + sessions: [ + { + id: "draft-1", + clientSessionId: "draft-1", + creationState: "pending", + title: "New chat", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + messageCount: 0, + }, + ], + }); + + const draftPin = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "draft-1" }), + ); + + await act(async () => { + await draftPin.result.current.pinToHome(); + }); + + // Persisted under the draft id the first send is about to rewrite — which + // no longer matters: no id rides the event, so the pin reports as soon as + // it survives persistence. + await waitFor(() => + expect( + useHomeWidgetStore.getState().lastConfirmedLayout?.items, + ).toHaveLength(1), + ); + await waitFor(() => expect(trackHomeItemPinned).toHaveBeenCalledOnce()); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + + // First send: the draft becomes a real session and its pin is rewritten. + await act(async () => { + useChatSessionStore + .getState() + .promoteDraftSession("draft-1", "backend-1"); + useHomeWidgetStore + .getState() + .replaceChatPinSessionId("draft-1", "backend-1"); + }); + + // The surfaces now know the chat by its backend id; the unpin still + // resolves as an act on the entity the reported pin was for. + const promotedPin = renderHook(() => + usePinToHomeWidget({ kind: "chat", id: "backend-1" }), + ); + expect(promotedPin.result.current.isPinned).toBe(true); + + act(() => { + promotedPin.result.current.unpinFromHome(); + }); + + await waitFor(() => expect(trackHomeItemUnpinned).toHaveBeenCalledOnce()); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "chat" }); + // The promotion's in-place rewrite of the pin never read as a second pin. + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + }); +}); + +function chatPin(sessionId: string): WidgetInstance { + return { + id: `chat-pin-${sessionId}`, + type: "chatPin", + x: 0, + y: 0, + z: 1, + state: { sessionId }, + }; +} + +function seedReadyStore(instances: WidgetInstance[]) { + useHomeWidgetStore.setState({ + instances, + loadStatus: "ready", + itemRevision: 1, + cameraRevision: 1, + camera: { centerX: 0, centerY: 0, zoomBps: 10_000 }, + constraints: layout().constraints, + // Pin telemetry resolves against the confirmed layout, so the seeded + // canvas has to be backed by one. + lastConfirmedLayout: layout({ items: homeWidgetsToLayoutItems(instances) }), + }); +} + +function mockConfirmedSaves() { + vi.mocked(saveLayoutItems).mockImplementation(async (request) => ({ + ok: true, + layout: layout({ itemRevision: 2, items: request.items }), + })); +} + +describe("usePinBatchToHome", () => { + it("emits one Unpinned per removed item on bulk unpin, skipping duplicate and unpinned ids", async () => { + seedReadyStore([ + chatPin("session-1"), + chatPin("session-2"), + { + id: "agent-pin-1", + type: "agentPin", + x: 100, + y: 0, + z: 2, + state: { agentId: "agent-1" }, + }, + ]); + mockConfirmedSaves(); + + const { result } = renderHook(() => usePinBatchToHome()); + + act(() => { + result.current.unpinBatchFromHome("chat", [ + "session-1", + "session-1", + "session-2", + "session-3", + ]); + }); + + // One event per removed item; with no id on the event, the call count is + // what carries the per-item semantics. + await waitFor(() => expect(trackHomeItemUnpinned).toHaveBeenCalledTimes(2)); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(useHomeWidgetStore.getState().instances).toEqual([ + expect.objectContaining({ id: "agent-pin-1" }), + ]); + expect(toast.success).toHaveBeenCalledWith( + "widgets.unpinBatchFromHome.success", + ); + }); + + it("emits nothing on bulk unpin when the layout is not ready", async () => { + useHomeWidgetStore.setState({ + instances: [chatPin("session-1")], + loadStatus: "loading", + }); + + const { result } = renderHook(() => usePinBatchToHome()); + + act(() => { + result.current.unpinBatchFromHome("chat", ["session-1"]); + }); + + await act(async () => {}); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + expect(useHomeWidgetStore.getState().instances).toEqual([ + expect.objectContaining({ id: "chat-pin-session-1" }), + ]); + expect(toast.error).toHaveBeenCalledWith( + "widgets.unpinBatchFromHome.error", + ); + }); + + it("emits one Pinned per newly pinned item on bulk pin, skipping already-pinned ids", async () => { + seedReadyStore([chatPin("session-1")]); + mockConfirmedSaves(); + + const { result } = renderHook(() => usePinBatchToHome()); + + await act(async () => { + await result.current.pinBatchToHome("chat", [ + "session-1", + "session-2", + "session-3", + ]); + }); + + // Two newly pinned items, one event each; the already-pinned id is what + // the count of two (not three) pins down. + await waitFor(() => expect(trackHomeItemPinned).toHaveBeenCalledTimes(2)); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(useHomeWidgetStore.getState().instances).toHaveLength(3); + }); }); diff --git a/src/features/home/hooks/usePinToHomeWidget.ts b/src/features/home/hooks/usePinToHomeWidget.ts index 89c165256..d1babe1bf 100644 --- a/src/features/home/hooks/usePinToHomeWidget.ts +++ b/src/features/home/hooks/usePinToHomeWidget.ts @@ -2,7 +2,18 @@ import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import type { LayoutConstraints } from "@/features/layout/api/layout"; -import { areSkillPinIdsEquivalent } from "@/features/home/lib/skillPinIdentity"; +import { + recordHomeItemPinIntent, + recordHomeItemUnpinIntent, +} from "../lib/homePinTelemetry"; +import { + findPinnedHomeWidgetId, + isPinnedToHome, + normalizedTargetId, + PIN_TARGET_CONFIG, + type PinToHomeTarget, + type PinToHomeTargetKind, +} from "../lib/homePinTargets"; import { clampToLayoutConstraints, snapPoint } from "../lib/snapToGrid"; import { useHomeWidgetStore } from "../stores/homeWidgetStore"; import { @@ -11,32 +22,10 @@ import { } from "../widgets/catalog"; import type { WidgetInstance, WidgetSize } from "../widgets/types"; -const PIN_TARGET_CONFIG = { - agent: { widgetType: "agentPin", stateKey: "agentId" }, - chat: { widgetType: "chatPin", stateKey: "sessionId" }, - project: { widgetType: "projectArtifactPin", stateKey: "projectId" }, - automation: { widgetType: "automationOutputPin", stateKey: "automationId" }, - skill: { widgetType: "skillPin", stateKey: "skillId" }, -} as const; - const PLACEMENT_PADDING = 24; const PLACEMENT_STEP = 72; const PLACEMENT_ATTEMPTS = 36; -export type PinToHomeTargetKind = keyof typeof PIN_TARGET_CONFIG; - -export interface PinToHomeTarget { - kind: PinToHomeTargetKind; - id: string | null | undefined; - /** All historical pin ids this target's current id should still resolve - * for. See areSkillPinIdsEquivalent. */ - legacyIds?: readonly string[] | null; -} - -function normalizedTargetId(id: string | null | undefined): string | null { - return typeof id === "string" && id.trim() ? id.trim() : null; -} - function rectsOverlap( left: { x: number; y: number; width: number; height: number }, right: { x: number; y: number; width: number; height: number }, @@ -151,38 +140,6 @@ export function choosePinPlacementCenter({ return centerForTopLeft(topLeft, defaultSize); } -export function isPinnedToHome( - instances: WidgetInstance[], - target: PinToHomeTarget, -): boolean { - return findPinnedHomeWidgetId(instances, target) !== null; -} - -function findPinnedHomeWidgetId( - instances: WidgetInstance[], - target: PinToHomeTarget, -): string | null { - const targetId = normalizedTargetId(target.id); - if (!targetId) { - return null; - } - - const config = PIN_TARGET_CONFIG[target.kind]; - return ( - instances.find((instance) => { - if (instance.type !== config.widgetType) return false; - const pinnedId = instance.state?.[config.stateKey]; - return target.kind === "skill" - ? areSkillPinIdsEquivalent( - typeof pinnedId === "string" ? pinnedId : null, - targetId, - target.legacyIds, - ) - : pinnedId === targetId; - })?.id ?? null - ); -} - export function usePinToHomeWidget(target: PinToHomeTarget) { const { t } = useTranslation("home"); const [isPinning, setIsPinning] = useState(false); @@ -219,6 +176,7 @@ export function usePinToHomeWidget(target: PinToHomeTarget) { } state.removeWidget(currentPinnedWidgetId); + recordHomeItemUnpinIntent({ kind, itemId: targetId, legacyIds }); toast.success(t("widgets.unpinFromHome.success")); } catch { toast.error(t("widgets.unpinFromHome.error")); @@ -270,6 +228,7 @@ export function usePinToHomeWidget(target: PinToHomeTarget) { { [config.stateKey]: targetId }, readyState.constraints ?? undefined, ); + recordHomeItemPinIntent({ kind, itemId: targetId, legacyIds }); toast.success(t("widgets.pinToHome.success")); } catch { toast.error(t("widgets.pinToHome.error")); @@ -373,6 +332,7 @@ export function usePinBatchToHome() { { [config.stateKey]: id }, readyState.constraints ?? undefined, ); + recordHomeItemPinIntent({ kind, itemId: id }); }); if (skipped > 0) { @@ -423,6 +383,7 @@ export function usePinBatchToHome() { }); if (widgetId) { state.removeWidget(widgetId); + recordHomeItemUnpinIntent({ kind, itemId: id }); removed += 1; } } diff --git a/src/features/home/lib/chatPinIdentity.test.ts b/src/features/home/lib/chatPinIdentity.test.ts new file mode 100644 index 000000000..753fb2256 --- /dev/null +++ b/src/features/home/lib/chatPinIdentity.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { resolveChatPinIdentity } from "./chatPinIdentity"; + +function session( + overrides: Partial & { id: string }, +): ChatSession { + return { + title: "Chat", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + messageCount: 0, + ...overrides, + }; +} + +beforeEach(() => { + useChatSessionStore.setState({ sessions: [] }); +}); + +describe("resolveChatPinIdentity", () => { + it("resolves a session the store does not know to itself", () => { + // The ids are the caller's own, echoed back: there is no session record + // behind them, so no promotion can change them. + expect(resolveChatPinIdentity("session-1")).toEqual({ + keyId: "session-1", + matchIds: ["session-1"], + }); + }); + + it("resolves a draft whose backend session is still being created to itself", () => { + useChatSessionStore.setState({ + sessions: [ + session({ + id: "draft-1", + clientSessionId: "draft-1", + creationState: "pending", + }), + ], + }); + + expect(resolveChatPinIdentity("draft-1")).toEqual({ + keyId: "draft-1", + matchIds: ["draft-1"], + }); + }); + + it("resolves a promoted session by the draft id its pin still carries", () => { + useChatSessionStore.setState({ + sessions: [session({ id: "backend-1", clientSessionId: "draft-1" })], + }); + + expect(resolveChatPinIdentity("draft-1")).toEqual({ + keyId: "draft-1", + matchIds: ["draft-1", "backend-1"], + }); + }); + + it("keys a promoted session the same way whichever id the pin carries", () => { + useChatSessionStore.setState({ + sessions: [session({ id: "backend-1", clientSessionId: "draft-1" })], + }); + + expect(resolveChatPinIdentity("backend-1")).toEqual({ + keyId: "draft-1", + matchIds: ["backend-1", "draft-1"], + }); + }); + + it("resolves a session that never was a draft to itself", () => { + useChatSessionStore.setState({ + sessions: [session({ id: "session-1", messageCount: 12 })], + }); + + expect(resolveChatPinIdentity("session-1")).toEqual({ + keyId: "session-1", + matchIds: ["session-1"], + }); + }); +}); diff --git a/src/features/home/lib/chatPinIdentity.ts b/src/features/home/lib/chatPinIdentity.ts new file mode 100644 index 000000000..94eac6a88 --- /dev/null +++ b/src/features/home/lib/chatPinIdentity.ts @@ -0,0 +1,68 @@ +/** + * Identity of a pinned chat across draft promotion. + * + * A chat pinned before its first send is pinned under the draft session's + * client-generated id. When that send creates the backend session, promotion + * rewrites the pin in place (`replaceChatPinSessionId`), so a single pinned chat + * can be stored under two ids over its life. Pin telemetry has to survive that: + * a pin recorded before promotion and its resolution after it must still be + * recognized as the same entity, or one user action reads as two. + * + * No chat id ever rides the wire — the pin events carry only `item_type` — so + * the two ids answer bookkeeping questions only: + * + * - `keyId` — the id the session was first created under (its `clientSessionId`, + * or its own id when it never was a draft). Stable across promotion, so a pin + * recorded before promotion and an unpin recorded after it are recognized as + * acts on the same entity. + * - `matchIds` — every id the pin may be stored under right now, since the + * confirmed layout can lag a promotion or arrive ahead of it. + * + * A session the store does not know — not loaded yet, or gone — resolves to + * itself. That is the honest answer, and it keeps every ordinary chat pin + * behaving exactly as it did before drafts entered the picture. + */ +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; + +export interface ChatPinIdentity { + /** Per-entity key, stable across promotion. */ + keyId: string; + /** Every id the pin may currently be stored under. */ + matchIds: string[]; +} + +function findSession( + sessions: readonly ChatSession[], + sessionId: string, +): ChatSession | undefined { + return ( + sessions.find((session) => session.id === sessionId) ?? + // Stored under the draft id it was pinned with, and since promoted. + sessions.find((session) => session.clientSessionId === sessionId) + ); +} + +function uniqueIds(ids: (string | null | undefined)[]): string[] { + return [...new Set(ids.filter((id): id is string => Boolean(id)))]; +} + +export function resolveChatPinIdentity(sessionId: string): ChatPinIdentity { + const session = findSession( + useChatSessionStore.getState().sessions, + sessionId, + ); + if (!session) { + return { + keyId: sessionId, + matchIds: [sessionId], + }; + } + + return { + keyId: session.clientSessionId ?? session.id, + matchIds: uniqueIds([sessionId, session.id, session.clientSessionId]), + }; +} diff --git a/src/features/home/lib/homePinTargets.ts b/src/features/home/lib/homePinTargets.ts new file mode 100644 index 000000000..36111d1f5 --- /dev/null +++ b/src/features/home/lib/homePinTargets.ts @@ -0,0 +1,101 @@ +/** + * Identity and matching for the pinnable entities on the Home canvas: which + * widget type carries which entity id, and whether a given entity is currently + * pinned in a set of widget instances. + * + * This lives in `lib/` rather than in `usePinToHomeWidget` because both the pin + * hooks and the confirmed-persistence telemetry gate (`homePinTelemetry.ts`) + * need it, and the gate must not import the hooks module (the hooks import the + * gate). + */ +import { areSkillPinIdsEquivalent } from "@/features/home/lib/skillPinIdentity"; +import type { WidgetInstance } from "../widgets/types"; + +export const PIN_TARGET_CONFIG = { + agent: { widgetType: "agentPin", stateKey: "agentId" }, + chat: { widgetType: "chatPin", stateKey: "sessionId" }, + project: { widgetType: "projectArtifactPin", stateKey: "projectId" }, + automation: { widgetType: "automationOutputPin", stateKey: "automationId" }, + skill: { widgetType: "skillPin", stateKey: "skillId" }, +} as const; + +export type PinToHomeTargetKind = keyof typeof PIN_TARGET_CONFIG; + +export interface PinToHomeTarget { + kind: PinToHomeTargetKind; + id: string | null | undefined; + /** All historical pin ids this target's current id should still resolve + * for. See areSkillPinIdsEquivalent. */ + legacyIds?: readonly string[] | null; +} + +export function normalizedTargetId( + id: string | null | undefined, +): string | null { + return typeof id === "string" && id.trim() ? id.trim() : null; +} + +// Reverse of PIN_TARGET_CONFIG: canvas widget type -> the pinnable entity it +// represents. Used to fire pin/unpin telemetry for the canvas-native picker and +// frame-remove paths, which mutate the widget store directly and so bypass +// pinToHome/unpinFromHome. +const WIDGET_TYPE_TO_TARGET: Record< + string, + { kind: PinToHomeTargetKind; stateKey: string } +> = Object.fromEntries( + (Object.keys(PIN_TARGET_CONFIG) as PinToHomeTargetKind[]).map((kind) => { + const { widgetType, stateKey } = PIN_TARGET_CONFIG[kind]; + return [widgetType, { kind, stateKey }]; + }), +); + +/** + * Maps a Home canvas widget (its type + persisted state) back to the pinnable + * entity it represents, or null for the utility widgets (clock/note/checklist) + * that are *added* to the canvas rather than *pinned*. For a chat pin the id is + * the chat session id. Used by the canvas-native pin/unpin telemetry wiring. + */ +export function entityPinTargetFromWidget( + type: string, + state: Record | undefined, +): { kind: PinToHomeTargetKind; id: string } | null { + const config = WIDGET_TYPE_TO_TARGET[type]; + if (!config) { + return null; + } + const raw = state?.[config.stateKey]; + const id = normalizedTargetId(typeof raw === "string" ? raw : null); + return id ? { kind: config.kind, id } : null; +} + +export function findPinnedHomeWidgetId( + instances: WidgetInstance[], + target: PinToHomeTarget, +): string | null { + const targetId = normalizedTargetId(target.id); + if (!targetId) { + return null; + } + + const config = PIN_TARGET_CONFIG[target.kind]; + return ( + instances.find((instance) => { + if (instance.type !== config.widgetType) return false; + const pinnedId = instance.state?.[config.stateKey]; + return target.kind === "skill" + ? areSkillPinIdsEquivalent( + typeof pinnedId === "string" ? pinnedId : null, + targetId, + target.legacyIds, + ) + : pinnedId === targetId; + })?.id ?? null + ); +} + +export function isPinnedToHome( + instances: WidgetInstance[], + target: PinToHomeTarget, +): boolean { + return findPinnedHomeWidgetId(instances, target) !== null; +} diff --git a/src/features/home/lib/homePinTelemetry.test.ts b/src/features/home/lib/homePinTelemetry.test.ts new file mode 100644 index 000000000..f9e10ffd4 --- /dev/null +++ b/src/features/home/lib/homePinTelemetry.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Layout } from "@/features/layout/api/layout"; +import { HOME_LAYOUT_ID } from "@/features/layout/api/layout"; +import { + type ChatSession, + useChatSessionStore, +} from "@/features/chat/stores/chatSessionStore"; +import { + HOME_WIDGET_SAVE_CONFIRMED_EVENT, + HOME_WIDGET_SAVE_DISCARDED_EVENT, +} from "@/features/home/onboarding/homeWidgetSaveLifecycle"; +import { + resetHomeWidgetStoreForTests, + useHomeWidgetStore, +} from "../stores/homeWidgetStore"; +import type { WidgetInstance } from "../widgets/types"; +import { homeWidgetsToLayoutItems } from "./homeLayoutMapper"; +import { + recordHomeItemPinIntent, + recordHomeItemUnpinIntent, + resetHomePinTelemetryForTests, +} from "./homePinTelemetry"; +import { trackHomeItemPinned, trackHomeItemUnpinned } from "./homeTelemetry"; + +vi.mock("./homeTelemetry", () => ({ + trackHomeItemPinned: vi.fn(), + trackHomeItemUnpinned: vi.fn(), +})); + +function chatPin(sessionId: string): WidgetInstance { + return { + id: `chat-pin-${sessionId}`, + type: "chatPin", + x: 0, + y: 0, + z: 1, + state: { sessionId }, + }; +} + +function skillPin(skillId: string): WidgetInstance { + return { + id: "skill-pin-1", + type: "skillPin", + x: 0, + y: 0, + z: 1, + state: { skillId }, + }; +} + +function layoutOf(instances: WidgetInstance[]): Layout { + return { + layoutId: HOME_LAYOUT_ID, + itemRevision: 1, + cameraRevision: 1, + camera: { centerX: 0, centerY: 0, zoomBps: 10_000 }, + constraints: { + minCenter: -100_000, + maxCenter: 100_000, + minSize: 1, + maxSize: 10_000, + minZoomBps: 1_000, + maxZoomBps: 20_000, + maxTitleOverrideLength: 120, + maxItems: 100, + }, + items: homeWidgetsToLayoutItems(instances), + }; +} + +/** The layout the backend has confirmed; local edits never change it. */ +function setConfirmedLayout(instances: WidgetInstance[]): void { + useHomeWidgetStore.setState({ + instances, + loadStatus: "ready", + itemRevision: 1, + lastConfirmedLayout: layoutOf(instances), + }); +} + +/** A chat session still waiting for its backend session to be created. */ +function draftSession(id: string): ChatSession { + return { + id, + clientSessionId: id, + creationState: "pending", + title: "New chat", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + messageCount: 0, + }; +} + +/** The same session after promotion: backend id, draft id kept as the client id. */ +function promotedSession(draftId: string, backendId: string): ChatSession { + return { + ...draftSession(draftId), + id: backendId, + clientSessionId: draftId, + creationState: undefined, + }; +} + +function setSessions(sessions: ChatSession[]): void { + useChatSessionStore.setState({ sessions }); +} + +function settleSave(outcome: "confirmed" | "discarded"): void { + window.dispatchEvent( + new Event( + outcome === "confirmed" + ? HOME_WIDGET_SAVE_CONFIRMED_EVENT + : HOME_WIDGET_SAVE_DISCARDED_EVENT, + ), + ); +} + +beforeEach(() => { + resetHomeWidgetStoreForTests(); + resetHomePinTelemetryForTests(); + setSessions([]); + vi.mocked(trackHomeItemPinned).mockClear(); + vi.mocked(trackHomeItemUnpinned).mockClear(); +}); + +describe("homePinTelemetry", () => { + it("reports a pin only once the confirmed layout contains it", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports nothing when a failed save rolls the pin back", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + // The rollback restores the last confirmed layout, which never had the pin. + settleSave("discarded"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports nothing when a revision conflict keeps the unpinned item", () => { + setConfirmedLayout([chatPin("session-1")]); + + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + // A conflict adopts the backend layout and merges only additions forward, + // so the removal never happened: the pin is still there. + settleSave("discarded"); + + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + }); + + it("reports an unpin once the confirmed layout drops it", () => { + setConfirmedLayout([chatPin("session-1"), chatPin("session-2")]); + + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + setConfirmedLayout([chatPin("session-2")]); + settleSave("confirmed"); + + expect(trackHomeItemUnpinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + }); + + it("matches a skill pin stored under its legacy id", () => { + const legacyId = "global:/Users/test/.agents/skills/agent-builder"; + const itemId = + "app:/Users/test/Library/Application Support/xyz.block.berd/skills/agent-builder"; + setConfirmedLayout([skillPin(legacyId)]); + + recordHomeItemUnpinIntent({ kind: "skill", itemId, legacyIds: [legacyId] }); + setConfirmedLayout([]); + settleSave("confirmed"); + + expect(trackHomeItemUnpinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "skill" }); + }); + + it("reports nothing for a pin the user undid before the save settled", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + settleSave("confirmed"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports a pin that landed even though the user has since asked to unpin it", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + // The user changes their mind, but the pin's own save is the one that + // lands: the unpin behind it fails and the canvas rolls back to the pin. + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + setConfirmedLayout([chatPin("session-1")]); + settleSave("discarded"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("reports a durable pin across a pin/unpin/re-pin sequence", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + // The pin lands first, so this is the user's own earlier action arriving — + // not another writer's layout, even though it is the opposite of what they + // asked for last. + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + + // The unpin lands, then the user pins again and that lands too. The item + // ends durably pinned, which is what the one reported Pin Pinned says. + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + setConfirmedLayout([]); + settleSave("confirmed"); + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("does not attribute a transition the user did not ask for", () => { + setConfirmedLayout([]); + + recordHomeItemUnpinIntent({ kind: "chat", itemId: "session-1" }); + // Another writer's layout arrives with the item pinned: a real transition, + // but the opposite of what this user asked for. + setConfirmedLayout([chatPin("session-1")]); + settleSave("discarded"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("resolves each intent exactly once", () => { + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "agent", itemId: "agent-1" }); + setConfirmedLayout([ + { + id: "agent-pin-1", + type: "agentPin", + x: 0, + y: 0, + z: 1, + state: { agentId: "agent-1" }, + }, + ]); + settleSave("confirmed"); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + }); + + it("reports a confirmed pin of a still-draft chat immediately", () => { + setSessions([draftSession("draft-1")]); + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "draft-1" }); + // The pin is persisted under an id promotion is about to rewrite — which + // no longer matters: no id rides the event, so nothing waits for one. + setConfirmedLayout([chatPin("draft-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + + // First send: the draft is promoted and the pin is rewritten in place, + // which is itself a layout save. The resolved intent is spent — the + // rewrite must not read as a second pin. + setSessions([promotedSession("draft-1", "backend-1")]); + setConfirmedLayout([chatPin("backend-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + + // The surfaces now know the chat by its backend id; the unpin still + // resolves as an act on the same entity. + recordHomeItemUnpinIntent({ kind: "chat", itemId: "backend-1" }); + setConfirmedLayout([]); + settleSave("confirmed"); + + expect(trackHomeItemUnpinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("matches a pin the confirmed layout still stores under the draft id", () => { + setSessions([draftSession("draft-1")]); + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "draft-1" }); + setSessions([promotedSession("draft-1", "backend-1")]); + // The pin's own save confirms before the promotion's rewrite does, so the + // confirmed layout is a promotion behind the session store. + setConfirmedLayout([chatPin("draft-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("reports both the pin and the unpin of a draft chat that never promotes", () => { + setSessions([draftSession("draft-1")]); + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "draft-1" }); + setConfirmedLayout([chatPin("draft-1")]); + settleSave("confirmed"); + + recordHomeItemUnpinIntent({ kind: "chat", itemId: "draft-1" }); + setConfirmedLayout([]); + settleSave("confirmed"); + + // Each change survived its own save window, so each is a real user action + // to count; with no id on the event there is no promotion to wait for. + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).toHaveBeenCalledOnce(); + }); + + it("pairs an unpin recorded under the promoted id with a pin stored under the draft id", () => { + setSessions([draftSession("draft-1")]); + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "draft-1" }); + setConfirmedLayout([chatPin("draft-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + + // Promotion, then the user removes the pin before the rewrite settles: the + // unpin arrives under the backend id while the confirmed layout still + // stores the pin under the draft id — the same entity either way. + setSessions([promotedSession("draft-1", "backend-1")]); + recordHomeItemUnpinIntent({ kind: "chat", itemId: "backend-1" }); + setConfirmedLayout([]); + settleSave("confirmed"); + + expect(trackHomeItemUnpinned).toHaveBeenCalledOnce(); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("detaches the save-lifecycle listeners on reset and re-subscribes after it", () => { + const removeEventListener = vi.spyOn(window, "removeEventListener"); + setConfirmedLayout([]); + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + + resetHomePinTelemetryForTests(); + + expect(removeEventListener).toHaveBeenCalledWith( + HOME_WIDGET_SAVE_CONFIRMED_EVENT, + expect.any(Function), + ); + expect(removeEventListener).toHaveBeenCalledWith( + HOME_WIDGET_SAVE_DISCARDED_EVENT, + expect.any(Function), + ); + removeEventListener.mockRestore(); + + // Detaching must not leave the gate deaf: the next intent re-subscribes. + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("reports a chat that was never a draft as an ordinary pin", () => { + setSessions([ + { + id: "session-1", + title: "Old chat", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + messageCount: 12, + }, + ]); + setConfirmedLayout([]); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("reports nothing when no layout has been confirmed yet", () => { + useHomeWidgetStore.setState({ lastConfirmedLayout: null }); + + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + settleSave("confirmed"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/home/lib/homePinTelemetry.ts b/src/features/home/lib/homePinTelemetry.ts new file mode 100644 index 000000000..f882295ce --- /dev/null +++ b/src/features/home/lib/homePinTelemetry.ts @@ -0,0 +1,238 @@ +/** + * Confirmed-persistence gate for the `berd_home` Pin Pinned / Unpin Unpinned + * events. + * + * Every pin surface mutates the widget store optimistically: the local canvas + * updates synchronously and the layout is persisted afterwards by the runtime's + * save loop. Emitting at the mutation therefore reports pin changes that may + * never exist on the backend — the save can fail and roll the canvas back to + * the last confirmed layout, and a revision conflict adopts the backend layout + * while merging only *additions* forward, so a removal can silently un-happen. + * + * So the pin surfaces do not emit. They record an *intent* here ("the user + * acted on this entity's pin state"), and the intent is resolved when the save + * settles, against the layout the backend actually confirmed: + * + * - pinned in the confirmed layout, and it was not before -> Pin Pinned + * - gone from the confirmed layout, and it was there before -> Unpin Unpinned + * - no net change (rollback, conflict, or a pin the user undid inside the same + * save window) -> nothing, because nothing survived persistence + * + * The observed transition must also be one the user asked for, so a change they + * did not make (a conflicting layout from another window) is never attributed + * to them. One save window can carry more than one request for the same entity + * — pin, then unpin before anything settles — and either can be the change that + * survives: the pin's own save can land while the unpin behind it is still in + * flight, and a failed unpin then rolls the canvas back onto that pin. So an + * intent remembers every direction the user asked for, not only the latest one. + * + * Resolution is terminal. Both save-lifecycle events fire only once the save + * queue has drained or been dropped, so nothing the user asked for is still in + * flight when an intent resolves, and holding one back would leave a later + * transition — one another window made — looking like their action arriving. + * + * The events carry only the item kind — no entity id rides the wire — but a + * pinned chat's *identity* still moves under the intent: pinning a draft pins + * it under a client-generated id that promotion later rewrites. So a chat + * intent resolves through `chatPinIdentity`, keyed under the id the session + * was created under and matched under every id the pin may currently be + * stored under, so a pin recorded before promotion and a resolution after it + * read as one action on one entity. Since no id is reported, a confirmed pin + * of a still-draft chat resolves immediately rather than waiting on the id + * promotion would assign. + * + * Programmatic canvas writes — starter-agent seeding, the onboarding reset — + * record no intent and so stay silent, which is what the raw `seedWidget` + * store path exists for. + */ +import { + HOME_WIDGET_SAVE_CONFIRMED_EVENT, + HOME_WIDGET_SAVE_DISCARDED_EVENT, +} from "@/features/home/onboarding/homeWidgetSaveLifecycle"; +import { useHomeWidgetStore } from "../stores/homeWidgetStore"; +import type { WidgetInstance } from "../widgets/types"; +import { resolveChatPinIdentity } from "./chatPinIdentity"; +import { layoutItemsToHomeWidgets } from "./homeLayoutMapper"; +import { isPinnedToHome, type PinToHomeTargetKind } from "./homePinTargets"; +import { trackHomeItemPinned, trackHomeItemUnpinned } from "./homeTelemetry"; + +export interface HomePinIntent { + kind: PinToHomeTargetKind; + /** + * Entity id as the surface saw it — never reported, only used to key the + * intent and match it against the confirmed layout. For a chat item this is + * the chat session id, which is a draft id when the chat has not sent its + * first message yet (see chatPinIdentity). + */ + itemId: string; + /** Prior ids a skill pin may still be stored under; see skillPinIdentity. */ + legacyIds?: readonly string[] | null; +} + +interface PendingPinIntent extends HomePinIntent { + /** Whether the user asked to pin this entity during this save window. */ + requestedPin: boolean; + /** Whether they asked to unpin it during this save window. */ + requestedUnpin: boolean; + /** Whether the entity was pinned in the confirmed layout when they first acted. */ + pinnedBefore: boolean; +} + +// Keyed by entity, so repeated actions on one item inside a single save window +// resolve as one net change rather than a burst of unpaired events. +const pendingIntents = new Map(); +let subscribedToSaveLifecycle = false; + +interface ResolvedPinIdentity { + /** Map key: one entry per entity, stable across a draft chat's promotion. */ + key: string; + /** Every id the pin may currently be stored under. */ + matchIds: string[]; +} + +/** + * Only chat ids move: every other pinnable entity is pinned under an id it keeps + * for life, so its id serves both roles at once. + */ +function resolvePinIdentity(intent: HomePinIntent): ResolvedPinIdentity { + if (intent.kind !== "chat") { + return { + key: `${intent.kind}:${intent.itemId}`, + matchIds: [intent.itemId], + }; + } + + const { keyId, matchIds } = resolveChatPinIdentity(intent.itemId); + return { + key: `chat:${keyId}`, + matchIds, + }; +} + +/** + * The last layout the backend confirmed. Local mutations never touch it, so it + * is the only honest answer to "is this item actually pinned". Before the first + * confirmed layout nothing is persisted, hence the empty fallback. + */ +function confirmedInstances(): WidgetInstance[] { + const { lastConfirmedLayout } = useHomeWidgetStore.getState(); + return lastConfirmedLayout + ? layoutItemsToHomeWidgets(lastConfirmedLayout.items) + : []; +} + +function isPinnedInConfirmedLayout( + instances: WidgetInstance[], + { kind, legacyIds }: HomePinIntent, + matchIds: string[], +): boolean { + return matchIds.some((id) => + isPinnedToHome(instances, { kind, id, legacyIds }), + ); +} + +function flushPendingIntents(): void { + if (pendingIntents.size === 0) { + return; + } + + const confirmed = confirmedInstances(); + const intents = [...pendingIntents.values()]; + pendingIntents.clear(); + + for (const intent of intents) { + const { matchIds } = resolvePinIdentity(intent); + const pinnedNow = isPinnedInConfirmedLayout(confirmed, intent, matchIds); + if (pinnedNow === intent.pinnedBefore) { + continue; + } + // The transition also has to be one this user asked for; anything else came + // from another writer's layout. Every direction they asked for in this + // window counts, not just their latest one: when they pin and then unpin + // before anything settles, the pin can still be the change that survives, + // and it is theirs to report. + if (!(pinnedNow ? intent.requestedPin : intent.requestedUnpin)) { + continue; + } + + if (pinnedNow) { + trackHomeItemPinned({ kind: intent.kind }); + } else { + trackHomeItemUnpinned({ kind: intent.kind }); + } + } +} + +function ensureSaveLifecycleSubscription(): void { + if (subscribedToSaveLifecycle || typeof window === "undefined") { + return; + } + + // Both outcomes resolve the same way: the confirmed layout decides. The + // discarded event covers a rejected save (rolled back) and a revision + // conflict (backend layout adopted), and both drop the queued local edits. + window.addEventListener( + HOME_WIDGET_SAVE_CONFIRMED_EVENT, + flushPendingIntents, + ); + window.addEventListener( + HOME_WIDGET_SAVE_DISCARDED_EVENT, + flushPendingIntents, + ); + subscribedToSaveLifecycle = true; +} + +function recordIntent(intent: HomePinIntent, action: "pin" | "unpin"): void { + ensureSaveLifecycleSubscription(); + + const { key, matchIds } = resolvePinIdentity(intent); + const existing = pendingIntents.get(key); + if (existing) { + // Keep the baseline from when this window opened, and remember this + // direction alongside any earlier one: either can be the change that lands. + if (action === "pin") { + existing.requestedPin = true; + } else { + existing.requestedUnpin = true; + } + return; + } + + pendingIntents.set(key, { + ...intent, + requestedPin: action === "pin", + requestedUnpin: action === "unpin", + pinnedBefore: isPinnedInConfirmedLayout( + confirmedInstances(), + intent, + matchIds, + ), + }); +} + +/** The user pinned an item to Home; reported if the pin survives persistence. */ +export function recordHomeItemPinIntent(intent: HomePinIntent): void { + recordIntent(intent, "pin"); +} + +/** The user unpinned an item from Home; reported if the removal persists. */ +export function recordHomeItemUnpinIntent(intent: HomePinIntent): void { + recordIntent(intent, "unpin"); +} + +export function resetHomePinTelemetryForTests(): void { + pendingIntents.clear(); + if (subscribedToSaveLifecycle && typeof window !== "undefined") { + window.removeEventListener( + HOME_WIDGET_SAVE_CONFIRMED_EVENT, + flushPendingIntents, + ); + window.removeEventListener( + HOME_WIDGET_SAVE_DISCARDED_EVENT, + flushPendingIntents, + ); + } + // The next recorded intent re-subscribes, so a reset leaves the module in the + // state a fresh import would be in rather than a half-torn-down one. + subscribedToSaveLifecycle = false; +} diff --git a/src/features/home/lib/homeTelemetry.ts b/src/features/home/lib/homeTelemetry.ts new file mode 100644 index 000000000..46f9dbca7 --- /dev/null +++ b/src/features/home/lib/homeTelemetry.ts @@ -0,0 +1,58 @@ +/** + * Thin, feature-scoped wrappers over the vendored `berd_home` event factories, + * mirroring `src/features/agents/lib/agentTelemetry.ts` and + * `src/features/chat/lib/chatTelemetry.ts`. + * + * Each wrapper builds the vendored schema event and hands it to the shared + * telemetry `track` chokepoint, inheriting its prod/staging gate, consent + * gating, and startup buffering/backdating for free. Keeping the wrappers + * here (rather than in `client.ts`) keeps `berd_home` wiring additive and local + * to the home feature. + * + * Pin surfaces must not call these directly: pin changes are optimistic, so the + * only caller is `homePinTelemetry.ts`, which reports a change once the backend + * has confirmed it. Record an intent there instead. + */ +import { track } from "@/shared/telemetry/client"; +import { + type BerdHomeHomeItemType, + berdHomePinPinned, + berdHomeUnpinUnpinned, +} from "@/shared/telemetry/events"; +import type { PinToHomeTargetKind } from "./homePinTargets"; + +/** + * The five discriminated pin `kind` tokens map 1:1 onto the schema's + * `HomeItemType` enum. The mapping is total — `PinToHomeTargetKind` is exactly + * these five kinds — and adding a new pinnable kind would fail this `Record`'s + * exhaustiveness check at compile time. + */ +const KIND_TO_ITEM_TYPE = { + agent: "HOME_ITEM_TYPE_AGENT", + chat: "HOME_ITEM_TYPE_CHAT", + project: "HOME_ITEM_TYPE_PROJECT", + automation: "HOME_ITEM_TYPE_AUTOMATION", + skill: "HOME_ITEM_TYPE_SKILL", +} as const satisfies Record; + +interface HomeItemTelemetryParams { + kind: PinToHomeTargetKind; +} + +/** The user pinned an item to the Home page (a confirmed pin). */ +export function trackHomeItemPinned({ kind }: HomeItemTelemetryParams): void { + track( + berdHomePinPinned({ + item_type: KIND_TO_ITEM_TYPE[kind], + }), + ); +} + +/** The user unpinned an item from the Home page (a confirmed unpin). */ +export function trackHomeItemUnpinned({ kind }: HomeItemTelemetryParams): void { + track( + berdHomeUnpinUnpinned({ + item_type: KIND_TO_ITEM_TYPE[kind], + }), + ); +} diff --git a/src/features/home/stores/homeWidgetStore.ts b/src/features/home/stores/homeWidgetStore.ts index 574827aad..d8768091c 100644 --- a/src/features/home/stores/homeWidgetStore.ts +++ b/src/features/home/stores/homeWidgetStore.ts @@ -693,6 +693,10 @@ function createHomeWidgetStore() { ), ); }, + // Promotion rewrites a pinned draft chat's id in place, so a pinned chat + // can be stored under two ids over its life. Pin telemetry resolves that + // through the session store rather than watching this write; see + // lib/chatPinIdentity.ts. replaceChatPinSessionId: (draftSessionId, backendSessionId) => { applyMutation((instances) => { let changed = false; diff --git a/src/features/home/ui/HomeView.test.tsx b/src/features/home/ui/HomeView.test.tsx index 4d7efb03d..ae173e85b 100644 --- a/src/features/home/ui/HomeView.test.tsx +++ b/src/features/home/ui/HomeView.test.tsx @@ -16,6 +16,11 @@ import { saveLayoutCamera, saveLayoutItems, } from "@/features/layout/api/layout"; +import { resetHomePinTelemetryForTests } from "../lib/homePinTelemetry"; +import { + trackHomeItemPinned, + trackHomeItemUnpinned, +} from "../lib/homeTelemetry"; import { resetHomeWidgetStoreForTests, useHomeWidgetStore, @@ -62,6 +67,11 @@ vi.mock("./WidgetCanvas", () => ({ WidgetCanvas: widgetCanvasMock, })); +vi.mock("../lib/homeTelemetry", () => ({ + trackHomeItemPinned: vi.fn(), + trackHomeItemUnpinned: vi.fn(), +})); + function layout(overrides: Partial = {}): Layout { return { layoutId: HOME_LAYOUT_ID, @@ -95,6 +105,17 @@ function layout(overrides: Partial = {}): Layout { }; } +function bundledPersona(displayName: string): Persona { + return { + id: `/Users/test/.agents/agents/${displayName.toLowerCase()}.md`, + displayName, + systemPrompt: "Help.", + isBuiltin: false, + writable: true, + sourceProperties: { metadata: { berdBundled: true } }, + }; +} + function TopBarActionsHost() { const actions = useTopBarActions(); return
{actions}
; @@ -143,6 +164,7 @@ function renderHomeViewWithVisibleStarterTasks() { beforeEach(() => { resetHomeWidgetStoreForTests(); + resetHomePinTelemetryForTests(); widgetCanvasMock.mockClear(); vi.mocked(getLayout).mockReset(); vi.mocked(saveLayoutItems).mockReset(); @@ -155,6 +177,8 @@ beforeEach(() => { ok: true, layout: layout({ camera: request.camera, cameraRevision: 2 }), })); + vi.mocked(trackHomeItemPinned).mockClear(); + vi.mocked(trackHomeItemUnpinned).mockClear(); localStorage.clear(); localStorage.setItem(ONBOARDING_STICKIES_SEEDED_STORAGE_KEY, "6"); useAgentStore.setState({ personas: [], personasLoading: false }); @@ -496,18 +520,6 @@ describe("HomeView", () => { it("does not add bundled starter agents to an existing customized Home", async () => { vi.mocked(getLayout).mockResolvedValue(layout()); - const bundledPersona = (displayName: string): Persona => ({ - id: `/Users/test/.agents/agents/${displayName.toLowerCase()}.md`, - displayName, - systemPrompt: "Help.", - isBuiltin: false, - writable: true, - sourceProperties: { - metadata: { - berdBundled: true, - }, - }, - }); useAgentStore.setState({ personas: [ bundledPersona("Wildcard"), @@ -584,14 +596,6 @@ describe("HomeView", () => { }); it("seeds starter agents through the empty-Home lifecycle without duplicating them after remount", async () => { - const bundledPersona = (displayName: string): Persona => ({ - id: `/Users/test/.agents/agents/${displayName.toLowerCase()}.md`, - displayName, - systemPrompt: "Help.", - isBuiltin: false, - writable: true, - sourceProperties: { metadata: { berdBundled: true } }, - }); const personas = [bundledPersona("Tinker"), bundledPersona("Wildcard")]; useAgentStore.setState({ personas, personasLoading: false }); vi.mocked(getLayout).mockResolvedValue(layout({ items: [] })); @@ -636,14 +640,6 @@ describe("HomeView", () => { }); it("waits for both starter personas before persisting either pin", async () => { - const bundledPersona = (displayName: string): Persona => ({ - id: `/Users/test/.agents/agents/${displayName.toLowerCase()}.md`, - displayName, - systemPrompt: "Help.", - isBuiltin: false, - writable: true, - sourceProperties: { metadata: { berdBundled: true } }, - }); const tinker = bundledPersona("Tinker"); const wildcard = bundledPersona("Wildcard"); vi.mocked(getLayout).mockResolvedValue(layout()); @@ -683,18 +679,6 @@ describe("HomeView", () => { it("adds bundled starter agents to a newly seeded Home", async () => { vi.mocked(getLayout).mockResolvedValue(layout()); - const bundledPersona = (displayName: string): Persona => ({ - id: `/Users/test/.agents/agents/${displayName.toLowerCase()}.md`, - displayName, - systemPrompt: "Help.", - isBuiltin: false, - writable: true, - sourceProperties: { - metadata: { - berdBundled: true, - }, - }, - }); const personas = [bundledPersona("Tinker"), bundledPersona("Wildcard")]; useAgentStore.setState({ personas, personasLoading: false }); markStarterAgentPinsEligible(); @@ -716,6 +700,128 @@ describe("HomeView", () => { ).toEqual(personas.map((persona) => persona.id)); }); + it("seeds starter agents without emitting pin telemetry", async () => { + vi.mocked(getLayout).mockResolvedValue(layout()); + const personas = [bundledPersona("Tinker"), bundledPersona("Wildcard")]; + useAgentStore.setState({ personas, personasLoading: false }); + markStarterAgentPinsEligible(); + + renderHomeView(); + + // The seeded marker is written only after the runtime confirms the save, + // so waiting on it settles the whole seed lifecycle. + await waitFor(() => + expect( + localStorage.getItem("goose:home:starter-agent-pins-seeded-v2"), + ).toBe("1"), + ); + expect( + useHomeWidgetStore + .getState() + .instances.filter((instance) => instance.type === "agentPin"), + ).toHaveLength(2); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("re-runs the starter-agent seed silently after a discarded save", async () => { + vi.mocked(getLayout).mockResolvedValue(layout()); + const personas = [bundledPersona("Tinker"), bundledPersona("Wildcard")]; + useAgentStore.setState({ personas, personasLoading: false }); + markStarterAgentPinsEligible(); + vi.mocked(saveLayoutItems).mockRejectedValueOnce(new Error("save failed")); + + renderHomeView(); + + await waitFor(() => + expect( + localStorage.getItem("goose:home:starter-agent-pins-seeded-v2"), + ).toBe("1"), + ); + expect( + useHomeWidgetStore + .getState() + .instances.filter((instance) => instance.type === "agentPin"), + ).toHaveLength(2); + // The rejected save was discarded, so reaching the seeded marker proves + // the seeding effect ran at least twice — still with zero pin telemetry. + expect(vi.mocked(saveLayoutItems).mock.calls.length).toBeGreaterThanOrEqual( + 2, + ); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + + it("keeps pin/unpin telemetry for canvas-native user mutations", async () => { + vi.mocked(getLayout).mockResolvedValue(layout()); + + renderHomeView(); + await screen.findByText("widget canvas"); + + const canvasProps = widgetCanvasMock.mock.calls.at(-1)?.[0]; + if (!canvasProps) { + throw new Error("WidgetCanvas was not rendered"); + } + + act(() => { + canvasProps.mutations.addWidget("agentPin", 480, 480, { + agentId: "agent-user-pinned", + }); + }); + // Optimistic canvas mutation: nothing is reported until the save confirms. + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + await waitFor(() => expect(saveLayoutItems).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(trackHomeItemPinned).toHaveBeenCalledOnce()); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "agent" }); + + const pinned = useHomeWidgetStore + .getState() + .instances.find((instance) => instance.type === "agentPin"); + if (!pinned) { + throw new Error("agent pin was not added"); + } + act(() => { + canvasProps.mutations.removeWidget(pinned.id); + }); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + await waitFor(() => expect(saveLayoutItems).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(trackHomeItemUnpinned).toHaveBeenCalledOnce()); + expect(trackHomeItemUnpinned).toHaveBeenCalledWith({ kind: "agent" }); + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + }); + + it("reports no pin telemetry when the canvas save fails", async () => { + vi.mocked(getLayout).mockResolvedValue(layout()); + vi.mocked(saveLayoutItems).mockRejectedValueOnce(new Error("save failed")); + + renderHomeView(); + await screen.findByText("widget canvas"); + + const canvasProps = widgetCanvasMock.mock.calls.at(-1)?.[0]; + if (!canvasProps) { + throw new Error("WidgetCanvas was not rendered"); + } + + act(() => { + canvasProps.mutations.addWidget("agentPin", 480, 480, { + agentId: "agent-user-pinned", + }); + }); + + await waitFor(() => expect(saveLayoutItems).toHaveBeenCalledTimes(1)); + // The failed save rolls the canvas back to the last confirmed layout, so + // the pin the user saw never existed. + await waitFor(() => + expect( + useHomeWidgetStore + .getState() + .instances.some((instance) => instance.type === "agentPin"), + ).toBe(false), + ); + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + }); + it("removes Berdy from the canvas when its experiment is disabled", async () => { setExperimentEnabled(BERDY_ONBOARDING_EXPERIMENT_ID, false); vi.mocked(getLayout).mockResolvedValue( diff --git a/src/features/home/ui/HomeView.tsx b/src/features/home/ui/HomeView.tsx index d8f86ba71..1a341483f 100644 --- a/src/features/home/ui/HomeView.tsx +++ b/src/features/home/ui/HomeView.tsx @@ -8,6 +8,11 @@ import { useExperiment } from "@/features/experiments/experimentPreferences"; import { useSetTopBarActions } from "@/app/contexts/TopBarActionsContext"; import type { SkillInfo } from "@/features/skills/api/skills"; import { TopBarIconButton } from "@/shared/ui/top-bar-icon-button"; +import { + recordHomeItemPinIntent, + recordHomeItemUnpinIntent, +} from "../lib/homePinTelemetry"; +import { entityPinTargetFromWidget } from "../lib/homePinTargets"; import { clampLayoutCamera } from "../lib/layoutCamera"; import { getPinnedHomeChatSessionIds } from "../lib/pinnedHomeChats"; import { useHomeWidgetStore } from "../stores/homeWidgetStore"; @@ -123,6 +128,7 @@ export function HomeView({ retryInitialize, copyErrorDetails, widgetMutations, + seedWidget, camera, constraints, saveCamera, @@ -228,7 +234,7 @@ export function HomeView({ const center = placement ? starterLayoutCenter(placement) : { x: -420 + index * 220, y: 410 }; - return widgetMutations.addWidget( + return seedWidget( "agentPin", center.x, center.y, @@ -241,7 +247,7 @@ export function HomeView({ starterAgentSeedAttemptedRef.current = true; pendingStarterAgentSeedRef.current = true; } - }, [instances, loadStatus, personas, personasLoading, widgetMutations]); + }, [instances, loadStatus, personas, personasLoading, seedWidget]); useEffect(() => { if ( @@ -780,23 +786,62 @@ function useHomeWidgetLayoutController() { void initialize(); }, [initialize]); + // The canvas-native pin/unpin paths bypass usePinToHomeWidget: the WidgetPicker + // adds entity pins (and utility widgets) via addWidget, and the widget frame / + // UnpinPill removes them via removeWidget. Wrap both here — the single point + // both flow through — to record berd_home pin intents, filtered to the pinnable + // entity kinds (clock/note/checklist widgets map to null and record nothing). + // The intent becomes an event only if the change survives persistence; see + // homePinTelemetry.ts. + const addWidgetWithTelemetry = useCallback( + (type, x, y, state, bounds, options) => { + const added = addWidget(type, x, y, state, bounds, options); + if (!added) { + return added; + } + const target = entityPinTargetFromWidget(type, state); + if (target) { + recordHomeItemPinIntent({ kind: target.kind, itemId: target.id }); + } + return added; + }, + [addWidget], + ); + + const removeWidgetWithTelemetry = useCallback( + (id) => { + // Resolve the entity before removal, while the instance still exists. + const instance = useHomeWidgetStore + .getState() + .instances.find((candidate) => candidate.id === id); + const target = instance + ? entityPinTargetFromWidget(instance.type, instance.state) + : null; + removeWidget(id); + if (target) { + recordHomeItemUnpinIntent({ kind: target.kind, itemId: target.id }); + } + }, + [removeWidget], + ); + const widgetMutations = useMemo( () => ({ - addWidget, + addWidget: addWidgetWithTelemetry, moveWidget, resizeWidget, bumpZ, applyStarterLayout, - removeWidget, + removeWidget: removeWidgetWithTelemetry, updateWidgetState, }), [ - addWidget, + addWidgetWithTelemetry, moveWidget, resizeWidget, bumpZ, applyStarterLayout, - removeWidget, + removeWidgetWithTelemetry, updateWidgetState, ], ); @@ -808,6 +853,11 @@ function useHomeWidgetLayoutController() { retryInitialize, copyErrorDetails, widgetMutations, + // The raw store mutation, for programmatic seeding. Unlike + // widgetMutations.addWidget it records no Pin Pinned intent: the + // first-run starter-agent seed (and its re-run after a discarded save) + // is not a user pin and must not count as one. + seedWidget: addWidget, camera, constraints, saveCamera, diff --git a/src/features/onboarding/ui/OnboardingFlow.test.tsx b/src/features/onboarding/ui/OnboardingFlow.test.tsx new file mode 100644 index 000000000..8862e1c29 --- /dev/null +++ b/src/features/onboarding/ui/OnboardingFlow.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { toast } from "sonner"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import type { CreatePersonaRequest } from "@/shared/types/agents"; +import { + dispatchOnboarding, + resetOnboardingStoreForTests, +} from "../model/onboardingStore"; +import { OnboardingFlow } from "./OnboardingFlow"; + +const mockCreatePersona = vi.hoisted(() => vi.fn()); +const mockListPersonas = vi.hoisted(() => vi.fn()); +const mockTrackAgentCreateCompleted = vi.hoisted(() => vi.fn()); + +vi.mock("@/shared/api/agents", () => ({ + createPersona: mockCreatePersona, + listPersonas: mockListPersonas, +})); + +vi.mock("@/features/agents/lib/agentTelemetry", () => ({ + trackAgentCreateCompleted: mockTrackAgentCreateCompleted, +})); + +vi.mock("@/shared/api/artifacts", () => ({ + ARTIFACTS_QUERY_KEY: ["artifacts"], + getArtifacts: vi + .fn() + .mockResolvedValue({ catalogVersion: "test", assets: [] }), + selectAvatarImageUrl: vi.fn(), +})); + +vi.mock("@/shared/api/avatars", () => ({ + avatarCachedRefQueryKey: (avatarRef: string) => ["avatar", avatarRef], + getCachedAvatarForRef: vi.fn().mockResolvedValue(null), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +vi.mock("@/shared/hooks/useAvatarSrc", () => ({ + useAvatarImage: () => undefined, + useAvatarMedia: () => ({ + src: "asset://localhost/avatar.webm", + mediaType: "video", + alphaMode: "stacked", + }), +})); + +vi.mock("@/shared/ui/avatar-media", () => ({ + AvatarMedia: ({ className }: { className?: string }) => ( + + ), +})); + +function createdPersona(request: CreatePersonaRequest) { + return { + id: `/Users/x/.agents/agents/${request.displayName.toLowerCase()}.md`, + displayName: request.displayName, + systemPrompt: request.systemPrompt, + provider: request.provider, + modelProviderId: request.modelProviderId, + model: request.model, + isBuiltin: false, + writable: true, + }; +} + +function renderFlow() { + return render( + + + , + ); +} + +async function keepRecommendedAgents(): Promise { + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /keep/i })); + // Adoption finished once the flow leaves the recommendations step. + await waitFor(() => + expect( + screen.queryByRole("button", { name: /keep/i }), + ).not.toBeInTheDocument(), + ); +} + +// The "engineering" work type recommends Builder, Debugger, and Reviewer, in +// catalog order — pinned here so the per-agent assertions below stay readable. +describe("OnboardingFlow agent adoption telemetry", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetOnboardingStoreForTests(); + dispatchOnboarding({ type: "start" }); + dispatchOnboarding({ + type: "set-work-types", + workTypeIds: ["engineering"], + }); + dispatchOnboarding({ type: "go-to", step: "recommendations" }); + useAgentStore.setState({ + personas: [], + personasLoading: false, + providers: [], + }); + mockListPersonas.mockResolvedValue([]); + mockCreatePersona.mockImplementation( + async (request: CreatePersonaRequest) => createdPersona(request), + ); + }); + + it("emits Create Completed once per persona actually created by Keep", async () => { + // Builder already exists, so keeping the recommendations only creates + // Debugger and Reviewer. + mockListPersonas.mockResolvedValue([ + { + id: "/Users/x/.agents/agents/builder.md", + displayName: "Builder", + systemPrompt: "Existing.", + isBuiltin: false, + writable: true, + }, + ]); + renderFlow(); + + await keepRecommendedAgents(); + + expect(mockCreatePersona).toHaveBeenCalledTimes(2); + // One event per persona actually created; with no id on the event the + // count of two is what pins the per-persona emission. + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledTimes(2); + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledWith({ + provider: undefined, + model: undefined, + }); + }); + + it("does not emit for a persona whose creation fails", async () => { + // Builder succeeds, Debugger's create rejects, Reviewer succeeds. + mockCreatePersona + .mockImplementationOnce(async (request: CreatePersonaRequest) => + createdPersona(request), + ) + .mockRejectedValueOnce(new Error("create failed")); + renderFlow(); + + await keepRecommendedAgents(); + + expect(mockCreatePersona).toHaveBeenCalledTimes(3); + // Three creates attempted, two succeeded: the failed one emits nothing, + // which the count of two pins now that no id rides the event. + expect(mockTrackAgentCreateCompleted).toHaveBeenCalledTimes(2); + expect(toast.warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/onboarding/ui/OnboardingFlow.tsx b/src/features/onboarding/ui/OnboardingFlow.tsx index d9f2796d6..73247c981 100644 --- a/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/src/features/onboarding/ui/OnboardingFlow.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import { i18n } from "@/shared/i18n"; import { CURATED_PROVIDER_CATALOG_BY_ID } from "@/features/providers/curatedProviders"; import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { trackAgentCreateCompleted } from "@/features/agents/lib/agentTelemetry"; import { dispatchOnboarding, isWorkTypeId, @@ -54,6 +55,12 @@ async function adoptAgents( systemPrompt: `You are ${agent.canonicalName}, ${agent.canonicalPromptDescription.toLowerCase()} Help the user thoughtfully and directly.`, }); useAgentStore.getState().addPersona(persona); + // Completed once per persona actually created, on confirmed success. + // Already-adopted names above create nothing and emit nothing. + trackAgentCreateCompleted({ + provider: persona.provider, + model: persona.model, + }); existingNames.add(agent.canonicalName.toLowerCase()); adopted.push(agent.canonicalName); } catch { diff --git a/src/features/projects/lib/projectTelemetry.ts b/src/features/projects/lib/projectTelemetry.ts new file mode 100644 index 000000000..72159ad49 --- /dev/null +++ b/src/features/projects/lib/projectTelemetry.ts @@ -0,0 +1,84 @@ +/** + * Thin, feature-scoped wrappers over the vendored `berd_project` event + * factories, mirroring `src/features/agents/lib/agentTelemetry.ts`, + * `src/features/chat/lib/chatTelemetry.ts`, and + * `src/features/home/lib/homeTelemetry.ts`. + * + * These build the vendored schema events and hand them to the shared telemetry + * `track` chokepoint, inheriting its prod/staging gate, consent gating, and + * startup buffering for free. Keeping the wrappers here (rather than in + * `client.ts`) keeps `berd_project` wiring additive and local to the projects + * feature. + */ +import { track } from "@/shared/telemetry/client"; +import { + berdProjectCreateCompleted, + berdProjectDeleteCompleted, + berdProjectEditCompleted, +} from "@/shared/telemetry/events"; +import type { ProjectInfo } from "../api/projects"; + +/** + * `has_working_dir` / `had_working_dir`: the project has at least one working + * directory configured. `ProjectInfo.workingDirs` is the persisted list (see + * `toProjectInfo` in `../api/projects`). + */ +function hasWorkingDir(project: ProjectInfo): boolean { + return project.workingDirs.length > 0; +} + +/** + * `has_prompt`: the project has non-blank configured instructions / prompt + * text. `ProjectInfo.prompt` is the persisted source content — the "describe" + * field of the create/edit dialog. + */ +function hasPrompt(project: ProjectInfo): boolean { + return project.prompt.trim().length > 0; +} + +/** + * `had_artifact`: the project had an associated generated artifact — the 3D hero + * artifact whose metadata rides on `ProjectInfo.artifact` (populated via + * `parseProjectArtifactMetadata`, `null` when absent/malformed). NOTE: the + * create path always generates this metadata (`createArtifactMetadata` in + * `../api/projects`), so for anything created through the app this is + * effectively always true; it only reads false for legacy projects saved before + * artifact metadata existed. + */ +function hasArtifact(project: ProjectInfo): boolean { + return project.artifact != null; +} + +/** A project creation flow completed successfully. */ +export function trackProjectCreateCompleted(project: ProjectInfo): void { + track( + berdProjectCreateCompleted({ + has_working_dir: hasWorkingDir(project), + has_prompt: hasPrompt(project), + }), + ); +} + +/** A project edit flow completed successfully. */ +export function trackProjectEditCompleted(project: ProjectInfo): void { + track( + berdProjectEditCompleted({ + has_working_dir: hasWorkingDir(project), + has_prompt: hasPrompt(project), + }), + ); +} + +/** + * A project deletion completed successfully. `project` is the pre-deletion + * snapshot, so `had_working_dir`/`had_artifact` reflect the deleted project's + * state. + */ +export function trackProjectDeleteCompleted(project: ProjectInfo): void { + track( + berdProjectDeleteCompleted({ + had_working_dir: hasWorkingDir(project), + had_artifact: hasArtifact(project), + }), + ); +} diff --git a/src/features/projects/ui/CreateProjectDialog.tsx b/src/features/projects/ui/CreateProjectDialog.tsx index 901ab4305..32bd9beb1 100644 --- a/src/features/projects/ui/CreateProjectDialog.tsx +++ b/src/features/projects/ui/CreateProjectDialog.tsx @@ -52,6 +52,10 @@ import { type WorkspaceAddCandidate, } from "@/features/chat/ui/widgets/WorkspaceAddDialog"; import { WorkspaceIdentity } from "@/features/chat/ui/widgets/WorkspaceIdentity"; +import { + trackProjectCreateCompleted, + trackProjectEditCompleted, +} from "../lib/projectTelemetry"; import { buildEditorText, parseEditorText } from "../lib/projectPromptText"; import { useProjectIconSelection } from "../hooks/useProjectIconSelection"; import { DEFAULT_PROJECT_ICON } from "../lib/projectIcons"; @@ -632,6 +636,9 @@ export function CreateProjectDialog({ useWorktrees: editingProject.useWorktrees, projectWorkspaces: sanitizedProjectWorkspaces, }); + // Completed only after the persist resolves; the returned ProjectInfo is + // authoritative for has_working_dir / has_prompt. + trackProjectEditCompleted(savedProject); } else { savedProject = await createProject( name.trim(), @@ -643,6 +650,7 @@ export function CreateProjectDialog({ false, sanitizedProjectWorkspaces, ); + trackProjectCreateCompleted(savedProject); } onCreated(savedProject); onClose(); diff --git a/src/features/projects/ui/ProjectsView.tsx b/src/features/projects/ui/ProjectsView.tsx index 605e09099..c7d21e442 100644 --- a/src/features/projects/ui/ProjectsView.tsx +++ b/src/features/projects/ui/ProjectsView.tsx @@ -30,6 +30,7 @@ import { import { CreateProjectDialog } from "./CreateProjectDialog"; import { ProjectIcon } from "./ProjectIcon"; import { deleteProject, type ProjectInfo } from "../api/projects"; +import { trackProjectDeleteCompleted } from "../lib/projectTelemetry"; import { useProjectStore } from "../stores/projectStore"; function ProjectCardMenu({ @@ -125,6 +126,9 @@ export function ProjectsView({ onStartChat }: ProjectsViewProps) { if (!deletingProject) return; try { await deleteProject(deletingProject.id); + // Completed only after the delete resolves; `deletingProject` is the + // pre-deletion snapshot for had_working_dir / had_artifact. + trackProjectDeleteCompleted(deletingProject); await loadProjects(); } catch { // best-effort diff --git a/src/features/search/ui/SearchView.tsx b/src/features/search/ui/SearchView.tsx index 06fe67da1..6e04fe5a0 100644 --- a/src/features/search/ui/SearchView.tsx +++ b/src/features/search/ui/SearchView.tsx @@ -26,6 +26,7 @@ import { type SectionId, } from "@/features/settings/ui/settingsSections"; import { useProfileCapabilities } from "@/shared/profile/capabilities"; +import { telemetryConsentEnforced } from "@/shared/telemetry/consent"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { selectLocalMessageCountsBySession } from "@/features/chat/stores/chatSelectors"; import { @@ -186,10 +187,22 @@ export function SearchView({ enabled: Boolean(onOpenSettings), translate: (key) => t(`settings:${key}`), visibleSections: visibleSettingsSections, - hiddenItemIds: capabilities.agentTools ? [] : ["chat-tips"], + // Hidden entries mirror rows their pages do not render: chat tips + // without agent tools, and the telemetry toggle both in enforced + // builds and without the `telemetry` capability, which is what + // TelemetryConsentRow itself hides on + // (telemetryConsentEnforced() is a build constant, so it needs no + // memo dependency; the capability is reactive and does). + hiddenItemIds: [ + ...(capabilities.agentTools ? [] : ["chat-tips"]), + ...(telemetryConsentEnforced() || !capabilities.telemetry + ? ["telemetry"] + : []), + ], }), [ capabilities.agentTools, + capabilities.telemetry, onOpenSettings, t, trimmedDebouncedQuery, diff --git a/src/features/search/ui/__tests__/SearchView.test.tsx b/src/features/search/ui/__tests__/SearchView.test.tsx index b1fceda0c..9423adeb8 100644 --- a/src/features/search/ui/__tests__/SearchView.test.tsx +++ b/src/features/search/ui/__tests__/SearchView.test.tsx @@ -15,6 +15,8 @@ import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { sessionSearchStamp } from "@/shared/api/sessionSearch"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { SearchView } from "../SearchView"; const mockListSkills = vi.hoisted(() => vi.fn()); @@ -110,6 +112,12 @@ describe("SearchView", () => { afterEach(() => { vi.unstubAllEnvs(); + // Profile capabilities resolve from this store, so a test that disables a + // feature toggle has to hand the next one an unloaded store back. + useRuntimeConfigStore.setState({ + loaded: false, + config: DEFAULT_RUNTIME_CONFIG, + }); }); it("does not render stale or duplicate extension results", async () => { @@ -326,6 +334,69 @@ describe("SearchView", () => { ).not.toBeInTheDocument(); }); + it("finds the telemetry toggle while the telemetry capability is available", async () => { + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "Universal search" }); + await user.type(input, "usage data"); + + expect( + await screen.findByRole("button", { + name: "Open Share usage data settings", + }), + ).toHaveTextContent("Settings > Share usage data"); + }); + + // The row itself is hidden without the capability (TelemetryConsentRow), so + // the search hit has to go with it — otherwise the result navigates to a + // System page that renders no such control. + it("hides the telemetry toggle when runtime config disables telemetry", async () => { + useRuntimeConfigStore.setState({ + loaded: true, + config: { + ...DEFAULT_RUNTIME_CONFIG, + featureToggles: { telemetry: false }, + }, + }); + mockListSkills.mockResolvedValue([]); + useAgentStore.setState({ personas: [] }); + const user = userEvent.setup(); + render( + , + ); + + const input = screen.getByRole("textbox", { name: "Universal search" }); + await user.type(input, "usage data"); + + expect( + await screen.findByText('No matches for "usage data"'), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open Share usage data settings" }), + ).not.toBeInTheDocument(); + }); + it("excludes automations without IDs from results and counts", async () => { mockListSkills.mockResolvedValue([]); useAgentStore.setState({ personas: [] }); diff --git a/src/features/settings/ui/ArchivedProjectsSection.tsx b/src/features/settings/ui/ArchivedProjectsSection.tsx index 264eeee89..7e6e24b86 100644 --- a/src/features/settings/ui/ArchivedProjectsSection.tsx +++ b/src/features/settings/ui/ArchivedProjectsSection.tsx @@ -20,6 +20,7 @@ import { type ProjectInfo, } from "@/features/projects/api/projects"; import { ProjectIcon } from "@/features/projects/ui/ProjectIcon"; +import { trackProjectDeleteCompleted } from "@/features/projects/lib/projectTelemetry"; import { useProjectStore } from "@/features/projects/stores/projectStore"; export function ArchivedProjectsSection() { @@ -47,10 +48,13 @@ export function ArchivedProjectsSection() { } } - async function handleDelete(id: string) { + async function handleDelete(project: ProjectInfo) { try { - await deleteProject(id); - setArchivedProjects((prev) => prev.filter((p) => p.id !== id)); + await deleteProject(project.id); + // Completed only after the delete resolves; `project` is the pre-deletion + // snapshot for had_working_dir / had_artifact. + trackProjectDeleteCompleted(project); + setArchivedProjects((prev) => prev.filter((p) => p.id !== project.id)); } catch { // best-effort } @@ -126,7 +130,7 @@ export function ArchivedProjectsSection() { })} onClick={() => { if (deletingProject) { - void handleDelete(deletingProject.id); + void handleDelete(deletingProject); setDeletingProject(null); } }} diff --git a/src/features/settings/ui/SystemSettings.tsx b/src/features/settings/ui/SystemSettings.tsx index 8ce8c3318..7607f865a 100644 --- a/src/features/settings/ui/SystemSettings.tsx +++ b/src/features/settings/ui/SystemSettings.tsx @@ -25,6 +25,7 @@ import { useArtifactRootPreference } from "@/shared/artifacts/useArtifactRootPre import { useTerminalFallbackCwdPreference } from "@/features/terminal/lib/terminalCwdPreference"; import { useProfileCapability } from "@/shared/profile/capabilities"; import { RuntimeConfigSettings } from "./RuntimeConfigSettings"; +import { TelemetryConsentRow } from "./TelemetryConsentRow"; import { DoctorSettings } from "./DoctorSettings"; import { useDoctorStatusSummary } from "@/shared/api/useDoctorReport"; import { @@ -349,6 +350,11 @@ export function SystemSettings() { ) : null} + {/* Renders nothing in enforced builds, where telemetry consent is + build policy and not a user choice, or when the `telemetry` + capability is off, where no event can emit whatever consent says. */} + + {/* bb CLI and Runtime config are both one-off developer-facing rows (not a growing list), so they share a single "Developer tools" section instead of each getting its own subheading -- this also diff --git a/src/features/settings/ui/TelemetryConsentRow.tsx b/src/features/settings/ui/TelemetryConsentRow.tsx new file mode 100644 index 000000000..0bec7a162 --- /dev/null +++ b/src/features/settings/ui/TelemetryConsentRow.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { useProfileCapability } from "@/shared/profile/capabilities"; +import { SettingsRow } from "@/shared/ui/settings-row"; +import { SettingsSection } from "@/shared/ui/settings-section"; +import { Switch } from "@/shared/ui/switch"; +import { + ensureTelemetryConsentLoaded, + telemetryConsentEnforced, + updateTelemetryEnabled, + useTelemetryConsentStore, +} from "@/shared/telemetry/consent"; + +// The telemetry consent toggle: a plain persisted write against the +// Rust-owned setting (default OFF), applied immediately by the per-event and +// native export gates — no restart. +// +// Not rendered at all when consent cannot decide anything: +// - enforced builds (managed internal distributions), where consent is +// build policy rather than a user choice; +// - builds and sessions without the `telemetry` capability +// (`VITE_TELEMETRY=0`, or `featureToggles.telemetry: false` in runtime +// config), where the client's own build gate refuses every event — a +// toggle that cannot produce a single event would let Settings claim +// sharing the product is incapable of. +// The capability is reactive, so a runtime-config answer arriving mid-session +// takes the row away. Hiding never writes: the persisted choice stays as the +// user left it and comes back with the row if the capability does. The +// matching search entry is hidden in SearchView.tsx. +// +// Not gated on the environment half of that build gate: development builds +// cannot emit either, but the toggle is how a dev session opts in before +// pointing itself at a real gateway. +export function TelemetryConsentRow() { + const { t } = useTranslation("settings"); + const enforced = telemetryConsentEnforced(); + const available = useProfileCapability("telemetry"); + const hidden = enforced || !available; + const loaded = useTelemetryConsentStore((state) => state.loaded); + const enabled = useTelemetryConsentStore((state) => state.enabled); + const [saving, setSaving] = useState(false); + + // The consent read normally happens during telemetry init, but that is + // skipped in dev and consent-disabled sessions — the toggle still has to + // show the persisted value there. Idempotent, so double-loading is free. + useEffect(() => { + if (!hidden) ensureTelemetryConsentLoaded(); + }, [hidden]); + + if (hidden) { + return null; + } + + async function handleEnabledChange(next: boolean) { + setSaving(true); + try { + await updateTelemetryEnabled(next); + } catch (error) { + console.warn("Failed to update the telemetry setting:", error); + toast.error(t("privacy.telemetry.saveError")); + } finally { + setSaving(false); + } + } + + return ( + + + void handleEnabledChange(next)} + disabled={!loaded || saving} + aria-label={t("privacy.telemetry.label")} + /> + + + ); +} diff --git a/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx b/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx new file mode 100644 index 000000000..3c639144a --- /dev/null +++ b/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx @@ -0,0 +1,169 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "@/test/render"; +import enSettings from "@/shared/i18n/locales/en/settings.json"; +import { TelemetryConsentRow } from "../TelemetryConsentRow"; +import { useTelemetryConsentStore } from "@/shared/telemetry/consent"; + +const ensureLoaded = vi.fn(); +const updateEnabled = vi.fn(); +const enforced = vi.fn(() => false); +const toastError = vi.fn(); +// The reactive `telemetry` capability: false in a build compiled with +// telemetry off, or once runtime config answers with the toggle disabled. +let telemetryAvailable = true; + +vi.mock("@/shared/profile/capabilities", () => ({ + useProfileCapability: (capability: string) => + capability === "telemetry" ? telemetryAvailable : true, +})); + +// The consent module is mocked at its boundary — the row's contract is what +// it renders from the store and which consent calls it makes, not the store's +// own load/persist behavior (consent.test.ts covers that). The store itself +// stays a real zustand store so `setState` drives renders as in production. +vi.mock("@/shared/telemetry/consent", async () => { + const { create } = await import("zustand"); + return { + useTelemetryConsentStore: create(() => ({ + loaded: false, + enabled: false, + })), + ensureTelemetryConsentLoaded: (...args: unknown[]) => ensureLoaded(...args), + updateTelemetryEnabled: (...args: unknown[]) => + updateEnabled(...args) as Promise, + telemetryConsentEnforced: () => enforced(), + }; +}); + +vi.mock("sonner", () => ({ + toast: { error: (...args: unknown[]) => toastError(...args) }, +})); + +describe("TelemetryConsentRow", () => { + beforeEach(() => { + ensureLoaded.mockClear(); + updateEnabled.mockReset().mockResolvedValue(undefined); + enforced.mockReturnValue(false); + telemetryAvailable = true; + toastError.mockClear(); + useTelemetryConsentStore.setState({ loaded: true, enabled: false }); + }); + + it("renders the toggle off by default and kicks the persisted read", () => { + renderWithProviders(); + + expect( + screen.getByText(enSettings.privacy.telemetry.label), + ).toBeInTheDocument(); + const toggle = screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }); + expect(toggle).not.toBeChecked(); + expect(toggle).toBeEnabled(); + expect(ensureLoaded).toHaveBeenCalled(); + }); + + it("keeps the toggle disabled until the persisted setting has loaded", () => { + useTelemetryConsentStore.setState({ loaded: false, enabled: false }); + renderWithProviders(); + + expect( + screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }), + ).toBeDisabled(); + }); + + it("reflects an enabled persisted setting", () => { + useTelemetryConsentStore.setState({ loaded: true, enabled: true }); + renderWithProviders(); + + expect( + screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }), + ).toBeChecked(); + }); + + it("persists an opt-in through the consent store", async () => { + renderWithProviders(); + + await userEvent.click( + screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }), + ); + + expect(updateEnabled).toHaveBeenCalledWith(true); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("surfaces a failed write instead of showing an unpersisted state", async () => { + updateEnabled.mockRejectedValue(new Error("read-only disk")); + const consoleWarn = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + renderWithProviders(); + + await userEvent.click( + screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }), + ); + + await waitFor(() => { + expect(toastError).toHaveBeenCalledWith( + enSettings.privacy.telemetry.saveError, + ); + }); + // The switch renders the store, which the failed write never touched. + expect( + screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }), + ).not.toBeChecked(); + consoleWarn.mockRestore(); + }); + + it("renders nothing in enforced builds", () => { + enforced.mockReturnValue(true); + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + expect(ensureLoaded).not.toHaveBeenCalled(); + }); + + it("renders nothing without the telemetry capability", () => { + telemetryAvailable = false; + const { container } = renderWithProviders(); + + expect(container).toBeEmptyDOMElement(); + expect(ensureLoaded).not.toHaveBeenCalled(); + }); + + // Hiding the row is a display decision, not a consent decision: nothing is + // persisted on the way out, so the choice is intact if the capability + // returns (a runtime config that re-enables the toggle, say). + it("keeps the persisted choice while the capability is unavailable", () => { + useTelemetryConsentStore.setState({ loaded: true, enabled: true }); + telemetryAvailable = false; + const { container, rerender } = renderWithProviders( + , + ); + + expect(container).toBeEmptyDOMElement(); + expect(updateEnabled).not.toHaveBeenCalled(); + + telemetryAvailable = true; + rerender(); + + const toggle = screen.getByRole("switch", { + name: enSettings.privacy.telemetry.label, + }); + expect(toggle).toBeChecked(); + expect(toggle).toBeEnabled(); + expect(ensureLoaded).toHaveBeenCalled(); + }); +}); diff --git a/src/features/settings/ui/settingsSearchItems.ts b/src/features/settings/ui/settingsSearchItems.ts index 1cd39bbfa..ddfe730d2 100644 --- a/src/features/settings/ui/settingsSearchItems.ts +++ b/src/features/settings/ui/settingsSearchItems.ts @@ -94,6 +94,13 @@ export const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ sectionId: "system", labelKey: "storage.cachedMedia.label", }, + // Hidden via hiddenItemIds wherever the row itself does not render: enforced + // builds, and builds/sessions without the `telemetry` capability. + { + id: "telemetry", + sectionId: "system", + labelKey: "privacy.telemetry.label", + }, { id: "bb-cli", sectionId: "system", diff --git a/src/main.test.tsx b/src/main.test.tsx index de146a30a..be53222c5 100644 --- a/src/main.test.tsx +++ b/src/main.test.tsx @@ -92,12 +92,14 @@ describe("main entrypoint telemetry startup", () => { }); }); - it("does not start launch telemetry for session windows", async () => { + it("runs the session window startup path without telemetry network or native-command work", async () => { await loadMainAt("?sessionKey=c2Vzc2lvbi0xMjM"); expect(await screen.findByTestId("session-app")).toHaveTextContent( "session-123", ); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(mockInvoke).not.toHaveBeenCalled(); expect(mockInstallRendererDiagnostics).toHaveBeenCalledWith({ windowKind: "session", }); @@ -124,4 +126,64 @@ describe("main entrypoint telemetry startup", () => { }); consoleError.mockRestore(); }); + + // Pins which boot branches initialize the telemetry pipeline vs. fire the + // launch event. Session windows run the same instrumented chat send paths as + // the main window, so they must initialize the pipeline (or every event they + // track is silently dropped) — but opening one is not an app start, so they + // must never emit the launch event. + describe("per-window-kind telemetry wiring", () => { + const mockInitTelemetry = vi.fn(); + const mockTrackAppLaunched = vi.fn(); + + beforeEach(() => { + vi.doMock("@/shared/telemetry/client", () => ({ + initTelemetry: mockInitTelemetry, + track: vi.fn(), + trackAppLaunched: mockTrackAppLaunched, + })); + }); + + afterEach(() => { + vi.doUnmock("@/shared/telemetry/client"); + }); + + it("main window initializes telemetry, then fires the launch event once", async () => { + await loadMainAt(""); + + await screen.findByTestId("main-app"); + expect(mockInitTelemetry).toHaveBeenCalledTimes(1); + expect(mockTrackAppLaunched).toHaveBeenCalledTimes(1); + expect(mockInitTelemetry.mock.invocationCallOrder[0]).toBeLessThan( + mockTrackAppLaunched.mock.invocationCallOrder[0], + ); + }); + + it("session window initializes telemetry without firing the launch event", async () => { + await loadMainAt("?sessionKey=c2Vzc2lvbi0xMjM"); + + await screen.findByTestId("session-app"); + expect(mockInitTelemetry).toHaveBeenCalledTimes(1); + expect(mockTrackAppLaunched).not.toHaveBeenCalled(); + }); + + it("malformed session window boot error initializes nothing", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + await loadMainAt("?sessionKey=*"); + + await waitFor(() => { + expect( + screen.getByRole("heading", { + name: "Session window failed to load", + }), + ).toBeInTheDocument(); + }); + expect(mockInitTelemetry).not.toHaveBeenCalled(); + expect(mockTrackAppLaunched).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + }); }); diff --git a/src/main.tsx b/src/main.tsx index 61e0f5db3..afeb3df72 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -135,6 +135,11 @@ if (bootError) { renderBootError(bootError); } else if (sessionId) { const decodedSessionId = sessionId; + // Detached session windows run the same instrumented chat send paths as the + // main window, so they need the full telemetry pipeline — without it their + // events buffer forever and are silently dropped. Deliberately no + // trackAppLaunched(): opening a session window is not an app start. + initTelemetry(); Promise.all([ import("@/app/SessionWindowApp"), import("@/app/SessionWindowRuntime"), @@ -161,6 +166,10 @@ if (bootError) { renderBootError("The session window bundle could not be loaded."); }); } else { + // Both run again whenever the renderer reloads (a WebKit reap, the crash + // screen's Reload button). Re-initializing is the point — the reloaded + // renderer needs a live pipeline — while trackAppLaunched() reports only on + // the first load of this window session, since a reload is not an app start. initTelemetry(); trackAppLaunched(); diff --git a/src/shared/api/invokeWithStartupRetry.ts b/src/shared/api/invokeWithStartupRetry.ts new file mode 100644 index 000000000..ecc024d28 --- /dev/null +++ b/src/shared/api/invokeWithStartupRetry.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; + +// During first launch the renderer can race ahead of the Tauri `setup()` +// closure: the hidden-but-loaded webview issues commands on Tokio threads +// before `app.manage(...)` has registered the command's state. Those calls +// reject with "state not managed" / "not registered" for a brief startup +// window, then succeed once setup catches up. Treat only those messages as a +// transient condition and retry with bounded backoff; every other rejection +// is a genuine error and propagates immediately. +const TRANSIENT_STATE_ERROR_PATTERN = /state not managed|not registered/i; +const MAX_STARTUP_RETRIES = 5; +const STARTUP_RETRY_BASE_DELAY_MS = 100; + +function isTransientStateError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return TRANSIENT_STATE_ERROR_PATTERN.test(message); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function invokeWithStartupRetry( + command: string, + args?: Record, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return args === undefined + ? await invoke(command) + : await invoke(command, args); + } catch (error) { + if (attempt >= MAX_STARTUP_RETRIES || !isTransientStateError(error)) { + throw error; + } + await delay(STARTUP_RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } +} diff --git a/src/shared/api/rendererTelemetry.ts b/src/shared/api/rendererTelemetry.ts index 0b8c8d8d3..3b111eee6 100644 --- a/src/shared/api/rendererTelemetry.ts +++ b/src/shared/api/rendererTelemetry.ts @@ -19,16 +19,25 @@ export interface RendererStatsPayload { export type RendererLogLevel = "info" | "warn" | "error"; +/** + * Log target for dev-time telemetry-viewer lines. The Rust side validates to + * this closed set (anything else falls back to its default target) and its + * Stdout formatter renders these records grey in the `just dev` terminal; + * the file target prints them uncolored. + */ +export type RendererLogTarget = "telemetry"; + /** Forward a renderer lifecycle event to the backend app log. */ export async function logRendererEvent( level: RendererLogLevel, message: string, + target?: RendererLogTarget, ): Promise { if (typeof window === "undefined" || !window.__TAURI_INTERNALS__) { return; } try { - await invoke("log_renderer_event", { level, message }); + await invoke("log_renderer_event", { level, message, target }); } catch { // Logging is best-effort; never let it break the UI. } diff --git a/src/shared/api/runtimeConfig.ts b/src/shared/api/runtimeConfig.ts index ecdffdd4b..6ef7f3dd0 100644 --- a/src/shared/api/runtimeConfig.ts +++ b/src/shared/api/runtimeConfig.ts @@ -1,50 +1,10 @@ -import { invoke } from "@tauri-apps/api/core"; import { type RuntimeConfig, type RuntimeConfigLoadResult, runtimeConfigLoadResultSchema, runtimeConfigSchema, } from "@/shared/runtime-config/schema"; - -// During first launch the renderer can race ahead of the Tauri `setup()` -// closure: the hidden-but-loaded webview issues runtime-config commands on -// Tokio threads before `app.manage(...)` has registered `RuntimeConfigState`. -// Those calls reject with "state not managed" / "not registered" for a brief -// startup window, then succeed once setup catches up. Treat only those -// messages as a transient condition and retry with bounded backoff; every -// other rejection is a genuine error and propagates immediately. -const TRANSIENT_STATE_ERROR_PATTERN = /state not managed|not registered/i; -const MAX_STARTUP_RETRIES = 5; -const STARTUP_RETRY_BASE_DELAY_MS = 100; - -function isTransientStateError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return TRANSIENT_STATE_ERROR_PATTERN.test(message); -} - -function delay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function invokeWithStartupRetry( - command: string, - args?: Record, -): Promise { - for (let attempt = 0; ; attempt += 1) { - try { - return args === undefined - ? await invoke(command) - : await invoke(command, args); - } catch (error) { - if (attempt >= MAX_STARTUP_RETRIES || !isTransientStateError(error)) { - throw error; - } - await delay(STARTUP_RETRY_BASE_DELAY_MS * 2 ** attempt); - } - } -} +import { invokeWithStartupRetry } from "./invokeWithStartupRetry"; function parseRuntimeConfigLoadResult(value: unknown): RuntimeConfigLoadResult { return runtimeConfigLoadResultSchema.parse(value); diff --git a/src/shared/api/telemetrySettings.test.ts b/src/shared/api/telemetrySettings.test.ts new file mode 100644 index 000000000..37c18c226 --- /dev/null +++ b/src/shared/api/telemetrySettings.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getTelemetrySettings, setTelemetryEnabled } from "./telemetrySettings"; + +const mockInvoke = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mockInvoke, +})); + +describe("telemetry settings api", () => { + beforeEach(() => { + mockInvoke.mockReset(); + }); + + it("reads the setting through the native command", async () => { + mockInvoke.mockResolvedValueOnce({ enabled: true }); + + await expect(getTelemetrySettings()).resolves.toEqual({ enabled: true }); + expect(mockInvoke).toHaveBeenCalledWith("get_telemetry_settings"); + }); + + it("writes the setting through the native command and returns the stored value", async () => { + mockInvoke.mockResolvedValueOnce({ enabled: false }); + + await expect(setTelemetryEnabled(false)).resolves.toEqual({ + enabled: false, + }); + expect(mockInvoke).toHaveBeenCalledWith("set_telemetry_enabled", { + enabled: false, + }); + }); + + it("rejects a malformed native answer instead of guessing at consent", async () => { + mockInvoke.mockResolvedValueOnce({ enabled: "yes" }); + + await expect(getTelemetrySettings()).rejects.toThrow(); + }); + + describe("transient startup retries", () => { + // Telemetry initializes at renderer boot — exactly the window where the + // webview can outrun `app.manage(TelemetryAuthState)` in `setup()`. That + // race must read as a retried transient, not as "no consent". + const stateNotManagedError = + "state not managed for field `state` on command `get_telemetry_settings`. You must call `.manage()` before using this command"; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('auto-retries a transient "state not managed" rejection and ultimately succeeds', async () => { + mockInvoke + .mockRejectedValueOnce(stateNotManagedError) + .mockResolvedValueOnce({ enabled: true }); + + const promise = getTelemetrySettings(); + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toEqual({ enabled: true }); + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + it("propagates genuine command failures immediately", async () => { + mockInvoke.mockRejectedValueOnce(new Error("disk unavailable")); + + await expect(getTelemetrySettings()).rejects.toThrow("disk unavailable"); + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/shared/api/telemetrySettings.ts b/src/shared/api/telemetrySettings.ts new file mode 100644 index 000000000..8680ca89d --- /dev/null +++ b/src/shared/api/telemetrySettings.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; +import { invokeWithStartupRetry } from "./invokeWithStartupRetry"; + +// Thin wrapper over the Rust-owned telemetry consent setting +// (`telemetry-settings.json` in the app-data dir). The renderer never reads +// or writes the file itself: the Rust side owns it so the native export gate +// in `export_otel_logs` can enforce the same value the UI shows. The startup +// retry matters here more than anywhere: telemetry initializes at renderer +// boot, exactly the window where `TelemetryAuthState` may not be managed yet. +const telemetrySettingsSchema = z.object({ enabled: z.boolean() }); + +export type TelemetrySettings = z.infer; + +export async function getTelemetrySettings(): Promise { + return telemetrySettingsSchema.parse( + await invokeWithStartupRetry("get_telemetry_settings"), + ); +} + +export async function setTelemetryEnabled( + enabled: boolean, +): Promise { + return telemetrySettingsSchema.parse( + await invokeWithStartupRetry("set_telemetry_enabled", { enabled }), + ); +} diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index efd535692..e51546740 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -464,6 +464,14 @@ }, "title": "Notifications" }, + "privacy": { + "telemetry": { + "description": "Help improve Berd by sharing usage events, like which features are used. Chat content is never sent.", + "label": "Share usage data", + "saveError": "Couldn't update the usage data setting. Try again." + }, + "title": "Privacy" + }, "projects": { "empty": "Archived projects will show here.", "sectionTitle": "Archived projects", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 4d9bb8291..3cd4236df 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -467,6 +467,14 @@ }, "title": "Notificaciones" }, + "privacy": { + "telemetry": { + "description": "Ayuda a mejorar Berd compartiendo eventos de uso, como qué funciones se usan. El contenido de los chats nunca se envía.", + "label": "Compartir datos de uso", + "saveError": "No se pudo actualizar la configuración de datos de uso. Inténtalo de nuevo." + }, + "title": "Privacidad" + }, "projects": { "empty": "No hay proyectos archivados.", "sectionTitle": "Proyectos archivados", diff --git a/src/shared/profile/buildProfile.test.ts b/src/shared/profile/buildProfile.test.ts index 10c2768b0..7f2e4b925 100644 --- a/src/shared/profile/buildProfile.test.ts +++ b/src/shared/profile/buildProfile.test.ts @@ -24,6 +24,7 @@ describe("buildProfile", () => { feedback: false, managedConnections: false, telemetry: true, + telemetryEnforced: false, voiceConversation: true, voiceDictation: false, securityMl: false, @@ -100,6 +101,18 @@ describe("buildProfile", () => { expect(getFreshBuildFeatureState().telemetry).toBe(true); }); + it("enforces telemetry consent only when VITE_TELEMETRY_ENFORCED is exactly 1", async () => { + vi.resetModules(); + vi.stubEnv("VITE_TELEMETRY_ENFORCED", "1"); + const { getBuildFeatureState: enforced } = await import("./buildProfile"); + expect(enforced().telemetryEnforced).toBe(true); + + vi.resetModules(); + vi.stubEnv("VITE_TELEMETRY_ENFORCED", "true"); + const { getBuildFeatureState: nonOptIn } = await import("./buildProfile"); + expect(nonOptIn().telemetryEnforced).toBe(false); + }); + it("keeps managed connections off for a non-opt-in value", async () => { vi.resetModules(); vi.stubEnv("VITE_MANAGED_CONNECTIONS", "0"); diff --git a/src/shared/profile/buildProfile.ts b/src/shared/profile/buildProfile.ts index cd9fd852d..a6319359a 100644 --- a/src/shared/profile/buildProfile.ts +++ b/src/shared/profile/buildProfile.ts @@ -7,6 +7,7 @@ export type BuildFeature = | "feedback" | "managedConnections" | "telemetry" + | "telemetryEnforced" | "voiceConversation" | "voiceDictation" | "securityMl" @@ -30,6 +31,12 @@ function readBuildFeatures(): Record { feedback: import.meta.env.VITE_FEEDBACK === "1", managedConnections: import.meta.env.VITE_MANAGED_CONNECTIONS === "1", telemetry: import.meta.env.VITE_TELEMETRY !== "0", + // Managed internal distributions force telemetry consent ON: the user + // setting is skipped and the settings toggle is hidden. A positive opt-in + // like the Block-service gates; public builds leave it unset. Paired with + // the `block-telemetry-enforced` Cargo feature (see + // scripts/block-feature-gates.sh) so the native export gate agrees. + telemetryEnforced: import.meta.env.VITE_TELEMETRY_ENFORCED === "1", // Native Voice Conversation is public functionality and deliberately does // not share dictation's KGoose-backed build gate. voiceConversation: true, diff --git a/src/shared/profile/capabilities.test.ts b/src/shared/profile/capabilities.test.ts index fcad20ea2..67b5ca50d 100644 --- a/src/shared/profile/capabilities.test.ts +++ b/src/shared/profile/capabilities.test.ts @@ -28,6 +28,7 @@ const enabledBuildFeatures: Record = { feedback: true, managedConnections: true, telemetry: true, + telemetryEnforced: false, voiceConversation: true, voiceDictation: true, securityMl: true, diff --git a/src/shared/telemetry/client.inert.test.ts b/src/shared/telemetry/client.inert.test.ts index eed473650..18e06982b 100644 --- a/src/shared/telemetry/client.inert.test.ts +++ b/src/shared/telemetry/client.inert.test.ts @@ -1,12 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const invoke = vi.hoisted(() => vi.fn()); -const submitFeedbackIssue = vi.hoisted(() => vi.fn()); -vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn(() => "1.0.0") })); vi.mock("@tauri-apps/api/core", () => ({ invoke })); -vi.mock("@/shared/api/feedback", () => ({ submitFeedbackIssue })); -vi.mock("@/shared/lib/platform", () => ({ getPlatform: () => "mac" })); /** The public seam must never create telemetry network or native-command work. */ describe("public telemetry seam", () => { @@ -15,9 +11,6 @@ describe("public telemetry seam", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - submitFeedbackIssue.mockResolvedValue({ - issueUrl: "https://example.test/1", - }); }); afterEach(() => { @@ -25,25 +18,31 @@ describe("public telemetry seam", () => { vi.restoreAllMocks(); }); - it("is inert for app startup and successful feedback submission", async () => { + it("is inert for app startup and a tracked event", async () => { const fetch = vi.fn(); globalThis.fetch = fetch as typeof globalThis.fetch; + // The close-flush hooks are installed only once the pipeline is up, so an + // inert build must leave the teardown paths untouched as well. + const addWindowListener = vi.spyOn(window, "addEventListener"); + const addDocumentListener = vi.spyOn(document, "addEventListener"); const telemetry = await import("./client"); - const { submitFeedbackReport } = await import( - "@/features/feedback/submitFeedbackReport" - ); + const { berdHomePinPinned } = await import("./events"); telemetry.initTelemetry(); telemetry.trackAppLaunched(); - await expect( - submitFeedbackReport({ - title: "Feedback", - description: "Details", - includeLogs: false, + telemetry.track( + berdHomePinPinned({ + item_type: "HOME_ITEM_TYPE_CHAT", }), - ).resolves.toEqual({ issueUrl: "https://example.test/1" }); + ); expect(fetch).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled(); + expect( + addWindowListener.mock.calls.map(([type]) => String(type)), + ).not.toContain("pagehide"); + expect( + addDocumentListener.mock.calls.map(([type]) => String(type)), + ).not.toContain("visibilitychange"); }); }); diff --git a/src/shared/telemetry/client.test.ts b/src/shared/telemetry/client.test.ts new file mode 100644 index 000000000..46a7356d5 --- /dev/null +++ b/src/shared/telemetry/client.test.ts @@ -0,0 +1,1342 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { berdHomePinPinned } from "./events"; + +// Observe emitted OTel log records by spying on the logger's `emit`. Events +// carry only their own params — no user identity is stamped anywhere, so there +// is no `identify`/CDP envelope to model. The native OTLP exporter is mocked +// out — its wire shape is covered by `exporter.test.ts`. +const emit = vi.fn(); +// Captures each LoggerProvider construction so the resource attributes +// (service identity, installation.id, distribution.channel) and the record +// limits can be asserted per-test even after `vi.resetModules()` re-runs the +// mock factory. +const loggerProviderConfigs: Array<{ + resource: { attributes: Record }; + logRecordLimits?: Record; +}> = []; +// Same, for the batch processor's queue/batch sizing. +const batchProcessorConfigs: Array> = []; +// Same, for the instrumentation scope name each logger is requested under. +const loggerScopeNames: string[] = []; +// Lets a test make the `LoggerProvider` constructor throw, standing in for any +// failure inside telemetry's asynchronous init. +const loggerProviderFailure = vi.hoisted(() => ({ + error: null as Error | null, +})); +// Each construction's `forceFlush`, so the close-flush hooks can be asserted +// against the provider the test itself built (listeners from earlier tests in +// this file stay registered on the shared jsdom window and flush their own, +// long-dead providers). +const loggerProviderFlushes: Array> = []; +// Lets a test make that flush fail, both ways an unload-path call could: a +// rejected promise and a synchronous throw. +const forceFlushFailure = vi.hoisted(() => ({ + rejection: null as Error | null, + thrown: null as Error | null, +})); +// Events the pipeline cannot emit are reported through the module's diagnostic +// channel, so the tests read that rather than a counter exported only for them. +const perfLog = vi.hoisted(() => vi.fn()); +vi.mock("@/shared/lib/perfLog", () => ({ perfLog })); + +/** The drop reports `perfLog` has seen, ignoring the module's other logging. */ +function dropReports(): string[] { + return perfLog.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes("dropped")); +} +// The distro fan-out seam (see `./distributionSink`), replaced with a spy so +// tests can pin exactly which events cross it — and, per test, with a throwing +// implementation standing in for a misbehaving overlay replacement. +const distributionSink = vi.hoisted(() => vi.fn()); +vi.mock("./distributionSink", () => ({ distributionSink })); +// The telemetry gate now reads the resolved `telemetry` capability (build +// feature AND `featureToggles.telemetry`), so the test drives that snapshot +// directly rather than the raw build-feature state. +const telemetryCapability = vi.hoisted(() => ({ enabled: true })); + +vi.mock("@opentelemetry/sdk-logs", () => ({ + LoggerProvider: vi.fn(function LoggerProviderMock( + config: (typeof loggerProviderConfigs)[number], + ) { + if (loggerProviderFailure.error) throw loggerProviderFailure.error; + loggerProviderConfigs.push(config); + // Captured at construction rather than read at call time: every pipeline + // this file starts leaves its close-flush listeners on the shared jsdom + // window, so a later test's dispatch reaches earlier tests' providers too + // — and those must keep behaving the way their own test configured them. + const failure = { ...forceFlushFailure }; + const forceFlush = vi.fn(() => { + if (failure.thrown) throw failure.thrown; + return failure.rejection + ? Promise.reject(failure.rejection) + : Promise.resolve(); + }); + loggerProviderFlushes.push(forceFlush); + return { + getLogger: (name: string) => { + loggerScopeNames.push(name); + return { emit, enabled: () => true }; + }, + forceFlush, + shutdown: vi.fn(), + }; + }), + BatchLogRecordProcessor: vi.fn(function BatchLogRecordProcessorMock( + options: Record, + ) { + batchProcessorConfigs.push(options); + return {}; + }), +})); + +vi.mock("./exporter", () => ({ + createTelemetryLogExporter: () => ({}), +})); + +// Mock the Tauri command layer, dispatching per command: `get_telemetry_resource` +// supplies the native resource half the logger's `Resource` awaits (the +// persistent anonymous install id plus the distribution channel), and +// `get_telemetry_settings` / `set_telemetry_enabled` back the consent gate +// (the default answers "enabled", so tests not about consent behave as +// before). +const invoke = vi.fn(); +const telemetryResourceCommand = vi.fn(); +const telemetrySettingsCommand = vi.fn(); +const setTelemetryEnabledCommand = vi.fn(); +// The dev-time viewer's only side effect (see `./devLog`). +const rendererLogCommand = vi.fn(); +const INSTALLATION_ID = "11111111-2222-4333-8444-555555555555"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +vi.mock("@/shared/profile/capabilities", () => ({ + getProfileCapabilitySnapshot: (id: string) => + id === "telemetry" ? telemetryCapability.enabled : true, +})); + +// Environment is mocked per-test so we can flip production/staging/development. +const getEnvironment = vi.fn(); +const isProduction = vi.fn(); +const isStaging = vi.fn(); + +vi.mock("@/shared/utils/environment", () => ({ + getEnvironment: () => getEnvironment(), + isProduction: () => isProduction(), + isStaging: () => isStaging(), +})); + +async function loadTelemetry() { + // Re-import so the module-level logger singleton and buffer state are fresh. + vi.resetModules(); + return await import("./client"); +} + +type Telemetry = Awaited>; + +const PINNED_ATTRIBUTES = { + item_type: "HOME_ITEM_TYPE_CHAT", +} as const; + +/** + * A second, non-launch event for the tests that need one — a real catalog + * event through the public `track` seam, which is how all four feature helpers + * emit. (`trackAppLaunched` is the module's only remaining event-specific + * wrapper: the feedback one went with `berd_app_feedback_submitted`.) + */ +function trackPinned(t: Telemetry): void { + t.track(berdHomePinPinned(PINNED_ATTRIBUTES)); +} + +function setEnv(env: "production" | "staging" | "development") { + getEnvironment.mockReturnValue(env); + isProduction.mockReturnValue(env === "production"); + isStaging.mockReturnValue(env === "staging"); +} + +beforeEach(() => { + telemetryCapability.enabled = true; + emit.mockClear(); + perfLog.mockReset(); + distributionSink.mockReset(); + loggerProviderFailure.error = null; + forceFlushFailure.rejection = null; + forceFlushFailure.thrown = null; + loggerProviderFlushes.length = 0; + loggerProviderConfigs.length = 0; + batchProcessorConfigs.length = 0; + loggerScopeNames.length = 0; + invoke.mockReset(); + telemetryResourceCommand.mockReset().mockResolvedValue({ + installationId: INSTALLATION_ID, + channel: "public", + }); + telemetrySettingsCommand.mockReset().mockResolvedValue({ enabled: true }); + setTelemetryEnabledCommand + .mockReset() + .mockImplementation((args: { enabled: boolean }) => + Promise.resolve({ enabled: args.enabled }), + ); + rendererLogCommand.mockReset().mockResolvedValue(undefined); + invoke.mockImplementation((command: unknown, args?: unknown) => { + switch (command) { + case "get_telemetry_resource": + return telemetryResourceCommand(); + case "get_telemetry_settings": + return telemetrySettingsCommand(); + case "set_telemetry_enabled": + return setTelemetryEnabledCommand(args as { enabled: boolean }); + case "log_renderer_event": + return rendererLogCommand(args as { level: string; message: string }); + default: + // The client invokes nothing else (the whoami identity round-trip is + // gone from telemetry entirely), so an unexpected command is a bug. + return Promise.reject(new Error(`unexpected command: ${command}`)); + } + }); +}); + +/** + * The minimum Tauri internals the dev-time viewer's log bridge needs. Absent by + * default, which is what keeps every other test's `invoke` assertions about + * telemetry's own commands. + */ +function stubTauriWindow(label: string) { + window.__TAURI_INTERNALS__ = { metadata: { currentWindow: { label } } }; +} + +/** Lines the dev-time viewer forwarded to the app log. */ +function devLogLines(): string[] { + return rendererLogCommand.mock.calls.map(([args]) => String(args.message)); +} + +afterEach(() => { + delete window.__TAURI_INTERNALS__; + localStorage.clear(); + // The launch guard is scoped to the window session, not to module state, so + // clearing it is what makes each test a fresh app start rather than a reload + // of the previous one. + sessionStorage.clear(); + vi.unstubAllEnvs(); + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe("telemetry", () => { + it("emits an event's params as its attributes, with no identity stamp", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + // Let the pipeline come up before tracking so the event emits immediately. + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + expect(record.eventName).toBe("berd_app_lifecycle_launched"); + expect(record.attributes).toEqual({ + app_version: expect.any(String), + environment: "production", + }); + // `user_id` is gone from the wire contract entirely — `berd-otlp-logs-v1` + // rejects its presence — so nothing may reintroduce it. + expect(record.attributes).not.toHaveProperty("user_id"); + // Emitted immediately (not backdated), so no explicit timestamp. + expect(record.timestamp).toBeUndefined(); + }); + + // `berd_app_lifecycle_launched.environment` is typed as the two values the + // build gate lets emit, so `development` is unrepresentable rather than + // merely unreachable. The gate itself is unchanged — `telemetryBuildEnabled` + // is still the only thing deciding whether anything emits — so what these + // pin is the attribute, not a second gate: development still *fires* the + // event (the dev viewer reports it), just without a value it has no member + // for, and never coerced into "production". + describe("launch event environment", () => { + it("reports staging as staging", async () => { + setEnv("staging"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0].attributes).toEqual({ + app_version: expect.any(String), + environment: "staging", + }); + }); + + it("omits the attribute in development rather than inventing one", async () => { + setEnv("development"); + stubTauriWindow("main"); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // The build gate suppresses the emission, as before... + expect(emit).not.toHaveBeenCalled(); + // ...and the event that fired carries no environment at all — in + // particular not "production", which the gateway would happily accept. + expect(devLogLines()).toEqual([ + expect.stringContaining("berd_app_lifecycle_launched"), + ]); + expect(devLogLines()[0]).not.toContain("environment"); + }); + }); + + it("buffers events until the logger exists, then flushes them backdated", async () => { + setEnv("production"); + let resolveResource: (value: unknown) => void = () => {}; + telemetryResourceCommand.mockReturnValue( + new Promise((resolve) => { + resolveResource = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The logger's resource is still awaiting the native answer: the event is + // buffered, not emitted. + expect(emit).not.toHaveBeenCalled(); + + resolveResource({ installationId: INSTALLATION_ID, channel: "public" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + expect(record.eventName).toBe("berd_app_lifecycle_launched"); + // Backdated to when the launch actually happened, not when it flushed. + expect(record.timestamp).toBeInstanceOf(Date); + }); + + it("stamps the installation id and distribution channel as resource attributes", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(telemetryResourceCommand).toHaveBeenCalledTimes(1); + expect(loggerProviderConfigs).toHaveLength(1); + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "installation.id": INSTALLATION_ID, + "distribution.channel": "public", + }); + }); + + it("stamps the internal channel when the staged distro config declares it", async () => { + setEnv("staging"); + telemetryResourceCommand.mockResolvedValue({ + installationId: INSTALLATION_ID, + channel: "internal", + }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "deployment.environment": "staging", + "distribution.channel": "internal", + }); + }); + + it("falls back to the public channel when the native answer is outside the closed set", async () => { + setEnv("production"); + // The gateway allowlists exactly {public, internal}; anything else on the + // wire is a terminal 400 for the whole batch, so an unrecognized native + // answer must read as public rather than pass through. + telemetryResourceCommand.mockResolvedValue({ + installationId: INSTALLATION_ID, + channel: "beta", + }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "distribution.channel": "public", + }); + }); + + it("names the service and instrumentation scope with the gateway's literals", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Pinned as literals, not as the module's own constants: the gateway's + // `berd-otlp-logs-v1` schema accepts exactly these values — both renamed + // from `goose-internal` before any client shipped — so a revert is a + // terminal 400 on every upload, and the dropped batch is never retried. + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "service.name": "berd", + }); + expect(loggerScopeNames).toEqual(["berd.telemetry"]); + }); + + it("sizes batches and attribute values for the gateway's body limit", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Pinned as literals, not as the module's own constants: these values are + // deliberately not the SDK defaults (512-record batches, unbounded + // attribute values), because an oversized batch is 413'd and then dropped + // rather than retried. `exporter.test.ts` pins the byte math behind them. + expect(batchProcessorConfigs).toEqual([ + { + exporter: expect.anything(), + maxQueueSize: 2048, + maxExportBatchSize: 128, + }, + ]); + expect(loggerProviderConfigs[0].logRecordLimits).toEqual({ + attributeValueLengthLimit: 256, + }); + }); + + it("omits installation.id and defaults the channel when the native resource is unavailable", async () => { + setEnv("production"); + // A genuine failure, deliberately not a "state not managed" one: the + // startup retry treats that message as transient and would answer it with + // backoff rather than this immediate fallback (pinned separately below). + telemetryResourceCommand.mockRejectedValue( + new Error("telemetry state unavailable"), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + // Telemetry stays best-effort: the resource simply lacks the id attribute + // (the native side still keys uploads on the id it bootstrapped with) and + // the channel takes its universal fallback. + expect(emit).toHaveBeenCalledTimes(1); + expect(telemetryResourceCommand).toHaveBeenCalledTimes(1); + expect(loggerProviderConfigs).toHaveLength(1); + expect(loggerProviderConfigs[0].resource.attributes).not.toHaveProperty( + "installation.id", + ); + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "distribution.channel": "public", + }); + }); + + // The renderer can run ahead of Tauri's `setup()`, and this invoke is the + // one an enforced build reaches at renderer boot with no earlier native + // round-trip to absorb that window. Losing it there is not a lost event but + // a mislabelled session: the provider's `Resource` is fixed at construction, + // so the fallback channel would ride every event the renderer ever sends. + describe("startup state-not-managed window", () => { + it("keeps the real installation id and channel across a transient rejection", async () => { + setEnv("production"); + vi.useFakeTimers(); + telemetryResourceCommand + .mockRejectedValueOnce(new Error("state not managed")) + .mockResolvedValue({ + installationId: INSTALLATION_ID, + channel: "internal", + }); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // The first attempt rejected, so the provider does not exist yet and the + // launch event is still buffered. + await vi.advanceTimersByTimeAsync(0); + expect(loggerProviderConfigs).toHaveLength(0); + + // Past the first backoff the retry answers, and the session is both + // attributed and labelled with the channel it actually shipped on — + // rather than silently counted in the public segment. + await vi.advanceTimersByTimeAsync(100); + + expect(telemetryResourceCommand).toHaveBeenCalledTimes(2); + expect(loggerProviderConfigs).toHaveLength(1); + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "installation.id": INSTALLATION_ID, + "distribution.channel": "internal", + }); + expect(emit).toHaveBeenCalledTimes(1); + expect(dropReports()).toEqual([]); + }); + + it("falls back once the retries are exhausted, inside the logger gate", async () => { + setEnv("production"); + vi.useFakeTimers(); + telemetryResourceCommand.mockRejectedValue( + new Error("state not managed"), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // Six attempts across 100+200+400+800+1600ms of backoff, all inside + // `TELEMETRY_RESOURCE_TIMEOUT_MS`: the retry path terminates on its own + // rather than leaving the logger gate to time out. + await vi.advanceTimersByTimeAsync(3_100); + + expect(telemetryResourceCommand).toHaveBeenCalledTimes(6); + expect(loggerProviderConfigs).toHaveLength(1); + expect(loggerProviderConfigs[0].resource.attributes).not.toHaveProperty( + "installation.id", + ); + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "distribution.channel": "public", + }); + // And the pipeline still came up: the buffered launch event flushed and + // later events emit immediately. + expect(emit).toHaveBeenCalledTimes(1); + trackPinned(t); + expect(emit).toHaveBeenCalledTimes(2); + expect(dropReports()).toEqual([]); + }); + }); + + // `berd_app_lifecycle_launched` means "the app started". A renderer reload + // (a WebKit reap under memory pressure, the crash screen's Reload button) + // re-runs the whole bundle in the same window, so the guard has to outlive + // module state — `loadTelemetry()` here is exactly that reload, since it + // gives a fresh module instance against the same window session store. + describe("launch event once per app start", () => { + it("does not fire again when the renderer reloads", async () => { + setEnv("production"); + + const first = await loadTelemetry(); + first.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + first.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0].eventName).toBe( + "berd_app_lifecycle_launched", + ); + + const reloaded = await loadTelemetry(); + reloaded.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + reloaded.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + + // Only the launch event is suppressed: the reloaded renderer still built + // its own pipeline and every other event still emits through it. + expect(loggerProviderConfigs).toHaveLength(2); + trackPinned(reloaded); + expect(emit).toHaveBeenCalledTimes(2); + expect(emit.mock.calls[1][0].eventName).toBe("berd_home_pin_pinned"); + }); + + it("fires again in the fresh window a real app start creates", async () => { + setEnv("production"); + + const first = await loadTelemetry(); + first.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + first.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + + // A real app start builds a new webview, whose session store is empty — + // nothing persists it across the process. + sessionStorage.clear(); + + const restarted = await loadTelemetry(); + restarted.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + restarted.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(2); + expect(emit.mock.calls[1][0].eventName).toBe( + "berd_app_lifecycle_launched", + ); + }); + + it("still fires when the window session store is unavailable", async () => { + setEnv("production"); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("storage disabled"); + }); + + const first = await loadTelemetry(); + first.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + first.trackAppLaunched(); + + const reloaded = await loadTelemetry(); + reloaded.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + reloaded.trackAppLaunched(); + + // Fails open: an unreadable store over-counts launches rather than + // silently losing the event. + expect(emit).toHaveBeenCalledTimes(2); + }); + }); + + // The chokepoint's own edges: the pipeline has to come up — or give up — + // even when the native side never answers, and whatever it cannot emit has + // to be countable. This matters more now that detached session windows + // initialize telemetry too: they run the instrumented chat send paths, so a + // wedged pipeline there loses real user events with nothing to show for it. + describe("startup edges", () => { + it("builds the pipeline anyway when the native resource never answers", async () => { + setEnv("production"); + vi.useFakeTimers(); + // The invoke hangs rather than rejecting — the case the client cannot + // tell apart from a slow answer. + telemetryResourceCommand.mockReturnValue(new Promise(() => {})); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // Nothing emits while the logger waits on the native answer. + await vi.advanceTimersByTimeAsync(0); + expect(emit).not.toHaveBeenCalled(); + + // The logger gate is bounded like the consent gate: the hang answers as + // "no installation id, public channel", so the provider is built and the + // buffer drains. + await vi.advanceTimersByTimeAsync(5_000); + + expect(loggerProviderConfigs).toHaveLength(1); + expect(loggerProviderConfigs[0].resource.attributes).not.toHaveProperty( + "installation.id", + ); + expect(loggerProviderConfigs[0].resource.attributes).toMatchObject({ + "distribution.channel": "public", + }); + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + expect(record.eventName).toBe("berd_app_lifecycle_launched"); + + // And the pipeline is live afterwards, not merely drained once. + trackPinned(t); + expect(emit).toHaveBeenCalledTimes(2); + expect(dropReports()).toEqual([]); + }); + + it("gives up loudly when the logger cannot be constructed", async () => { + setEnv("production"); + loggerProviderFailure.error = new Error("provider unavailable"); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + // A throw inside init must be caught here, not surface as an unhandled + // rejection, and must not leave the pipeline waiting on a logger that is + // never coming. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emit).not.toHaveBeenCalled(); + expect(perfLog).toHaveBeenCalledWith( + "[telemetry] failed to construct the logger: Error: provider unavailable", + ); + expect(dropReports()).toEqual([ + "[telemetry] dropped 1 event(s): the telemetry logger could not be constructed (1 dropped this session)", + ]); + + // Terminal: later events are counted drops, not a buffer that grows + // until it overflows one event at a time. + trackPinned(t); + expect(emit).not.toHaveBeenCalled(); + expect(dropReports()).toHaveLength(2); + expect(dropReports()[1]).toContain("(2 dropped this session)"); + }); + + it("counts what an overflowing buffer cannot keep before the logger exists", async () => { + setEnv("production"); + let resolveResource: (value: unknown) => void = () => {}; + telemetryResourceCommand.mockReturnValue( + new Promise((resolve) => { + resolveResource = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + + // 50 fit; the next two have nowhere to go — no logger to emit through + // and no room left to wait in. + for (let i = 0; i < 52; i += 1) trackPinned(t); + + expect(emit).not.toHaveBeenCalled(); + expect(dropReports()).toEqual([ + "[telemetry] dropped 1 event(s): buffer full before the logger was ready (1 dropped this session)", + "[telemetry] dropped 1 event(s): buffer full before the logger was ready (2 dropped this session)", + ]); + + resolveResource({ installationId: INSTALLATION_ID, channel: "public" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The 50 that fit still flush once the pipeline comes up. + expect(emit).toHaveBeenCalledTimes(50); + }); + }); + + // The consent gate: the persisted, Rust-owned telemetry setting is read + // asynchronously at startup and fails closed. Enabled installs must not + // lose the launch event to the read window; disabled installs must do no + // telemetry work at all — no telemetry-resource round-trip, no pipeline. + describe("consent gate", () => { + it("buffers events while the setting loads, then flushes them backdated once it enables", async () => { + setEnv("production"); + let resolveConsent: (value: unknown) => void = () => {}; + telemetrySettingsCommand.mockReturnValue( + new Promise((resolve) => { + resolveConsent = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Consent unknown: nothing emits and the pipeline stays down — no + // telemetry-resource round-trip. + expect(emit).not.toHaveBeenCalled(); + expect(telemetryResourceCommand).not.toHaveBeenCalled(); + + resolveConsent({ enabled: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + expect(record.eventName).toBe("berd_app_lifecycle_launched"); + // Backdated to when the launch actually happened, not when consent + // loaded. + expect(record.timestamp).toBeInstanceOf(Date); + expect(dropReports()).toEqual([]); + }); + + it("discards buffered events and stays fully inert when the setting loads disabled", async () => { + setEnv("production"); + telemetrySettingsCommand.mockResolvedValue({ enabled: false }); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emit).not.toHaveBeenCalled(); + expect(loggerProviderConfigs).toHaveLength(0); + expect(telemetryResourceCommand).not.toHaveBeenCalled(); + expect(dropReports()).toEqual([ + "[telemetry] dropped 1 event(s): telemetry is disabled for this installation (1 dropped this session)", + ]); + + // Settled disabled: later events are suppressed outright, not buffered + // (and not counted — production was told not to emit them). + trackPinned(t); + expect(emit).not.toHaveBeenCalled(); + expect(dropReports()).toHaveLength(1); + }); + + it("drops buffered events when the setting never answers, then recovers if it enables late", async () => { + setEnv("production"); + vi.useFakeTimers(); + let resolveConsent: (value: unknown) => void = () => {}; + telemetrySettingsCommand.mockReturnValue( + new Promise((resolve) => { + resolveConsent = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // The consent gate is bounded like the logger gate: a read that never + // answers costs the buffered events (counted), it does not wedge the + // renderer. + await vi.advanceTimersByTimeAsync(5_000); + expect(emit).not.toHaveBeenCalled(); + expect(dropReports()).toEqual([ + "[telemetry] dropped 1 event(s): the telemetry setting did not load in time (1 dropped this session)", + ]); + + // Events after the timeout are suppressed as counted drops, not + // buffered: consent is still unsettled, so a late enabled answer would + // make each of them a real loss on a consented install — the same + // uncertainty the buffer discard above was counted under. + trackPinned(t); + expect(dropReports()).toHaveLength(2); + expect(dropReports()[1]).toBe( + "[telemetry] dropped 1 event(s): the telemetry setting did not load in time (2 dropped this session)", + ); + + // A late enabled answer still brings the pipeline up for what follows. + resolveConsent({ enabled: true }); + await vi.advanceTimersByTimeAsync(0); + trackPinned(t); + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0].eventName).toBe("berd_home_pin_pinned"); + // Emission resumed, so the counting stops with it. + expect(dropReports()).toHaveLength(2); + }); + + it("stops counting once a late answer settles the setting disabled", async () => { + setEnv("production"); + vi.useFakeTimers(); + let resolveConsent: (value: unknown) => void = () => {}; + telemetrySettingsCommand.mockReturnValue( + new Promise((resolve) => { + resolveConsent = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + await vi.advanceTimersByTimeAsync(5_000); + trackPinned(t); + // The discarded buffer plus the post-timeout event, both counted while + // the answer was still unknown. + expect(dropReports()).toHaveLength(2); + + resolveConsent({ enabled: false }); + await vi.advanceTimersByTimeAsync(0); + trackPinned(t); + + // Consent has an answer now, and it is no: suppression is the product + // working rather than a loss, so it goes back to uncounted. + expect(emit).not.toHaveBeenCalled(); + expect(dropReports()).toHaveLength(2); + }); + + it("treats an enforced build as consented without reading the setting", async () => { + setEnv("production"); + vi.stubEnv("VITE_TELEMETRY_ENFORCED", "1"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0].eventName).toBe( + "berd_app_lifecycle_launched", + ); + // Enforced consent never consults the persisted setting. + expect(telemetrySettingsCommand).not.toHaveBeenCalled(); + }); + + it("starts the pipeline mid-session when the user turns telemetry on", async () => { + setEnv("production"); + telemetrySettingsCommand.mockResolvedValue({ enabled: false }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(telemetryResourceCommand).not.toHaveBeenCalled(); + + // Same module registry as the client, i.e. the store instance the + // settings toggle writes through. + const consent = await import("./consent"); + await consent.updateTelemetryEnabled(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + trackPinned(t); + expect(emit).toHaveBeenCalledTimes(1); + expect(emit.mock.calls[0][0].eventName).toBe("berd_home_pin_pinned"); + expect(setTelemetryEnabledCommand).toHaveBeenCalledWith({ + enabled: true, + }); + }); + }); + + // The dev-time viewer (see `./devLog`) taps the chokepoint itself, ahead of + // every gate, so `just dev` reports what *fired* rather than what survived. + // These pin that it hangs off `trackEvent` — not off one entry point or one + // gating outcome — and that it costs production builds nothing. + describe("dev event viewer", () => { + it("reports every entry point's event at track time", async () => { + setEnv("production"); + stubTauriWindow("main"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + trackPinned(t); + + expect(devLogLines()).toEqual([ + expect.stringContaining("main berd_app_lifecycle_launched"), + expect.stringContaining("main berd_home_pin_pinned"), + ]); + // The same params the record carries as its attributes. + expect(devLogLines()[0]).toContain('"environment":"production"'); + }); + + it("reports events the build gate suppresses", async () => { + setEnv("development"); + stubTauriWindow("main"); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + // Nothing is emitted in development — which is exactly the case a + // terminal viewer exists for. + expect(emit).not.toHaveBeenCalled(); + expect(devLogLines()).toEqual([ + expect.stringContaining("berd_app_lifecycle_launched"), + ]); + }); + + it("reports events consent then discards, labelled by window", async () => { + setEnv("production"); + stubTauriWindow("session:abc123"); + telemetrySettingsCommand.mockResolvedValue({ enabled: false }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + trackPinned(t); + + expect(emit).not.toHaveBeenCalled(); + expect(devLogLines()).toEqual([ + expect.stringContaining("session:abc123 berd_home_pin_pinned"), + ]); + }); + + it("reports a buffered event when it fires, not when it flushes", async () => { + setEnv("production"); + stubTauriWindow("main"); + // The native resource never answers, so the logger never appears and + // the event stays buffered. + telemetryResourceCommand.mockReturnValue(new Promise(() => {})); + + const t = await loadTelemetry(); + t.initTelemetry(); + trackPinned(t); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emit).not.toHaveBeenCalled(); + expect(devLogLines()).toHaveLength(1); + }); + + it("is absent outside the Vite dev server, leaving emission untouched", async () => { + setEnv("production"); + vi.stubEnv("DEV", false); + stubTauriWindow("main"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(rendererLogCommand).not.toHaveBeenCalled(); + }); + }); + + // The distro fan-out seam (see `./distributionSink`): a distribution + // overlay replaces the stock no-op module to receive every emitted event + // without forking the client. These pin the seam's contract — it fires + // exactly for events that reach the logger (post build gate, consent gate, + // and startup buffer), carries the original fire time, and a misbehaving + // replacement cannot disturb emission. The stock module's own inertness is + // pinned in `distributionSink.test.ts`. + describe("distribution sink seam", () => { + it("hands each emitted event to the sink with its name, attributes, and fire time", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + trackPinned(t); + + expect(distributionSink).toHaveBeenCalledTimes(2); + expect(distributionSink.mock.calls[0][0]).toEqual({ + name: "berd_app_lifecycle_launched", + attributes: { + app_version: expect.any(String), + environment: "production", + }, + firedAt: expect.any(String), + }); + expect(distributionSink.mock.calls[1][0]).toEqual({ + name: "berd_home_pin_pinned", + attributes: PINNED_ATTRIBUTES, + firedAt: expect.any(String), + }); + // An immediately-emitted event's fire time is "now", as a parseable + // ISO timestamp. + expect( + new Date(distributionSink.mock.calls[0][0].firedAt).getTime(), + ).not.toBeNaN(); + }); + + it("hands a buffered event over backdated to when it fired, not when it flushed", async () => { + setEnv("production"); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-14T00:00:00.000Z")); + let resolveResource: (value: unknown) => void = () => {}; + telemetryResourceCommand.mockReturnValue( + new Promise((resolve) => { + resolveResource = resolve; + }), + ); + + const t = await loadTelemetry(); + t.initTelemetry(); + trackPinned(t); + await vi.advanceTimersByTimeAsync(0); + + // Buffered: nothing has reached the logger, so nothing crosses the + // seam yet. + expect(emit).not.toHaveBeenCalled(); + expect(distributionSink).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(3_000); + resolveResource({ installationId: INSTALLATION_ID, channel: "public" }); + await vi.advanceTimersByTimeAsync(0); + + expect(emit).toHaveBeenCalledTimes(1); + expect(distributionSink).toHaveBeenCalledTimes(1); + expect(distributionSink.mock.calls[0][0].firedAt).toBe( + "2026-08-14T00:00:00.000Z", + ); + }); + + it("does not cross the seam for events the build gate suppresses", async () => { + setEnv("development"); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + expect(emit).not.toHaveBeenCalled(); + expect(distributionSink).not.toHaveBeenCalled(); + }); + + it("does not cross the seam for events consent discards or suppresses", async () => { + setEnv("production"); + telemetrySettingsCommand.mockResolvedValue({ enabled: false }); + + const t = await loadTelemetry(); + t.initTelemetry(); + // Buffered while the setting loads, then discarded when it answers + // disabled... + t.trackAppLaunched(); + await new Promise((resolve) => setTimeout(resolve, 0)); + // ...and suppressed outright once consent has settled. + trackPinned(t); + + expect(emit).not.toHaveBeenCalled(); + expect(distributionSink).not.toHaveBeenCalled(); + }); + + it("keeps emitting when a replacement sink throws", async () => { + setEnv("production"); + distributionSink.mockImplementation(() => { + throw new Error("overlay sink is broken"); + }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + trackPinned(t); + + // Every record still reached the logger: the throw is contained on the + // client's side of the seam and reported through the diagnostic + // channel, not escalated into a tracking failure or a counted drop. + expect(emit).toHaveBeenCalledTimes(2); + expect(perfLog).toHaveBeenCalledWith( + "[telemetry] distribution sink failed: Error: overlay sink is broken", + ); + expect(perfLog.mock.calls.map(([m]) => String(m))).not.toContainEqual( + expect.stringContaining("failed to track event"), + ); + expect(dropReports()).toEqual([]); + }); + }); + + // The close-flush hooks: the batch processor holds emitted records until its + // scheduled delay elapses, so whatever is still queued when a webview is + // torn down is lost with no counter to show for it. These pin the drain at + // both teardown signals — `pagehide` on `window` for a real unload (a + // detached session window closing, the last window closing, quit) and + // `visibilitychange` to hidden for the main window's close-as-hide — that + // an inert pipeline registers nothing, and that a failing flush stays a + // diagnostic rather than an exception into teardown. + describe("close flush", () => { + /** The teardown signal the main window's close-as-hide produces. */ + function dispatchVisibility(state: DocumentVisibilityState): void { + vi.spyOn(document, "visibilityState", "get").mockReturnValue(state); + document.dispatchEvent(new Event("visibilitychange")); + } + + /** The `perfLog` reports the flush path is allowed to produce. */ + function flushFailureReports(): string[] { + return perfLog.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes("flush")); + } + + /** The event names an `addEventListener` spy was asked to listen for. */ + function listenedEvents(spy: { mock: { calls: unknown[][] } }): string[] { + return spy.mock.calls.map(([type]) => String(type)); + } + + async function startedPipeline(): Promise { + setEnv("production"); + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + return t; + } + + it("drains the queue on a real unload", async () => { + const t = await startedPipeline(); + trackPinned(t); + + expect(loggerProviderFlushes).toHaveLength(1); + expect(loggerProviderFlushes[0]).not.toHaveBeenCalled(); + + // Registered on `window`, where `pagehide` actually fires — the SDK's + // own fallback listens on `document`, which this event never reaches. + window.dispatchEvent(new Event("pagehide")); + + expect(loggerProviderFlushes[0]).toHaveBeenCalledTimes(1); + expect(flushFailureReports()).toEqual([]); + }); + + it("drains the queue when the window is hidden, and only then", async () => { + await startedPipeline(); + + // The main window's close is intercepted into `hide()` whenever a + // secondary window exists, so hidden is a teardown signal here... + dispatchVisibility("hidden"); + expect(loggerProviderFlushes[0]).toHaveBeenCalledTimes(1); + + // ...while coming back is not. + dispatchVisibility("visible"); + expect(loggerProviderFlushes[0]).toHaveBeenCalledTimes(1); + }); + + it("registers once no matter how often consent settles", async () => { + setEnv("production"); + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // A mid-session toggle settles consent again; the pipeline guard means + // one provider, and with it one pair of listeners. + const consent = await import("./consent"); + await consent.updateTelemetryEnabled(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + trackPinned(t); + + expect(loggerProviderFlushes).toHaveLength(1); + window.dispatchEvent(new Event("pagehide")); + expect(loggerProviderFlushes[0]).toHaveBeenCalledTimes(1); + }); + + it("registers nothing when the build gate keeps the pipeline down", async () => { + setEnv("development"); + const addWindowListener = vi.spyOn(window, "addEventListener"); + const addDocumentListener = vi.spyOn(document, "addEventListener"); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + expect(loggerProviderFlushes).toHaveLength(0); + expect(listenedEvents(addWindowListener)).not.toContain("pagehide"); + expect(listenedEvents(addDocumentListener)).not.toContain( + "visibilitychange", + ); + + // And dispatching the teardown signals anyway is inert. + window.dispatchEvent(new Event("pagehide")); + dispatchVisibility("hidden"); + expect(flushFailureReports()).toEqual([]); + }); + + it("registers nothing when consent denies the pipeline", async () => { + setEnv("production"); + telemetrySettingsCommand.mockResolvedValue({ enabled: false }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + trackPinned(t); + + expect(loggerProviderFlushes).toHaveLength(0); + + window.dispatchEvent(new Event("pagehide")); + dispatchVisibility("hidden"); + expect(flushFailureReports()).toEqual([]); + }); + + it("contains a rejected flush", async () => { + forceFlushFailure.rejection = new Error("exporter is gone"); + await startedPipeline(); + + // Fire-and-forget by design: the durable step is the IPC message the + // export already posted, and an unload handler cannot await anything. + expect(() => window.dispatchEvent(new Event("pagehide"))).not.toThrow(); + await Promise.resolve(); + + expect(flushFailureReports()).toEqual([ + "[telemetry] close flush failed: Error: exporter is gone", + ]); + expect(dropReports()).toEqual([]); + }); + + it("contains a flush that throws synchronously", async () => { + forceFlushFailure.thrown = new Error("provider is broken"); + await startedPipeline(); + + expect(() => dispatchVisibility("hidden")).not.toThrow(); + + expect(flushFailureReports()).toEqual([ + "[telemetry] close flush failed: Error: provider is broken", + ]); + expect(dropReports()).toEqual([]); + }); + }); + + // `track` is the seam all four feature helpers emit through, so it goes + // through the same gates and the same buffer as the launch wrapper above it. + it("routes the generic track seam through the same path", async () => { + setEnv("production"); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + trackPinned(t); + + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + expect(record.eventName).toBe("berd_home_pin_pinned"); + // The event's params, verbatim — nothing stamps anything else on. + expect(record.attributes).toEqual(PINNED_ATTRIBUTES); + }); + + it("does not leak renderer page context or local URLs into the emitted record", async () => { + setEnv("production"); + window.history.replaceState(null, "", "/renderer?debug=true"); + Object.defineProperty(document, "referrer", { + configurable: true, + value: "http://localhost:1520/previous", + }); + + const t = await loadTelemetry(); + t.initTelemetry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + t.trackAppLaunched(); + + expect(emit).toHaveBeenCalledTimes(1); + const [record] = emit.mock.calls[0]; + const serialized = JSON.stringify(record); + expect(serialized).not.toContain("localhost"); + expect(serialized).not.toContain("/renderer"); + expect(serialized).not.toContain("referrer"); + + window.history.replaceState(null, "", "/"); + delete (document as unknown as Record).referrer; + }); + + it("is a no-op in development by default and performs no native work", async () => { + setEnv("development"); + const consoleInfo = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + expect(invoke).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + expect(consoleInfo).not.toHaveBeenCalled(); + }); + + it("is a no-op in production when the telemetry capability is disabled", async () => { + setEnv("production"); + // A disabled capability covers both the build-feature off switch and a + // future `featureToggles.telemetry: false`; the client only sees the + // resolved snapshot. + telemetryCapability.enabled = false; + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + expect(invoke).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it("logs development events when the env debug toggle is enabled without sending", async () => { + setEnv("development"); + vi.stubEnv("VITE_TELEMETRY_DEBUG", "1"); + const consoleInfo = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + + const t = await loadTelemetry(); + t.initTelemetry(); + t.trackAppLaunched(); + + expect(invoke).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + expect(consoleInfo).toHaveBeenCalledWith( + "[telemetry:debug] event suppressed", + { + eventName: "berd_app_lifecycle_launched", + // No `environment`: `BerdAppEnvironment` models only the two values + // that can reach the wire, so a development build reports none rather + // than one it would be misfiled under. + attributes: { app_version: expect.any(String) }, + }, + ); + }); + + it("logs development events when the localStorage debug toggle is enabled without sending", async () => { + setEnv("development"); + localStorage.setItem("berd.telemetry.debug", "1"); + const consoleInfo = vi + .spyOn(console, "info") + .mockImplementation(() => undefined); + + const t = await loadTelemetry(); + t.initTelemetry(); + trackPinned(t); + + expect(invoke).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + expect(consoleInfo).toHaveBeenCalledWith( + "[telemetry:debug] event suppressed", + { + eventName: "berd_home_pin_pinned", + attributes: PINNED_ATTRIBUTES, + }, + ); + }); +}); diff --git a/src/shared/telemetry/client.ts b/src/shared/telemetry/client.ts index fb72ca646..12a5f270a 100644 --- a/src/shared/telemetry/client.ts +++ b/src/shared/telemetry/client.ts @@ -1,16 +1,772 @@ /** - * Public telemetry seam. + * Telemetry client for Berd. * - * Berd's open-source build retains the call sites used by application startup - * and successful feedback submission. Every entry point is inert; the internal - * distribution overlays the real implementation at build time. + * This is the product-analytics path: typed events — vendored as ordinary + * source under `./events`, originally generated from + * `squareup/message-schemas` — emitted as OpenTelemetry **log records** over + * OTLP. Both private packages are gone: the vendored types replace + * `@squareup/message-schemas-web`, and OTel logs + a native OTLP exporter + * (`./exporter`) replace `@squareup/cdp`. A future Block-side collector maps the + * OTel log records back onto Unified Eventing, keyed on the event name. + * + * Owns the telemetry client and a single `track` chokepoint that every event + * flows through. No user identity rides the wire: events carry only their own + * params (booleans, closed enums, provider/model/app-version strings, and the + * chat events' opaque `session_id` — no names, paths, or other user-derived + * ids), and the resource identifies the install — never the person — via the + * anonymous `installation.id`. The OTel + * `BatchLogRecordProcessor` owns batching and delivery. Delivery is + * fire-and-forget *between* flushes: the processor holds records for its + * scheduled delay and then drops a failed batch rather than retrying it — the + * only retry anywhere in the pipeline is the native single re-auth retry on + * 401 inside `export_otel_logs` — so this module owns gating plus a + * best-effort drain when the window hides or closes (see + * `installCloseFlushHooks`). Because a dropped batch is unrecoverable, batch + * size and attribute-value length are capped below the gateway's request-body + * limit (see `MAX_LOG_EXPORT_BATCH_SIZE`). + * + * Emission requires consent (see `./consent`): the build enforces telemetry + * ON, or the user's persisted setting — Rust-owned, default OFF — has loaded + * as enabled. Consent is fail-closed, so its startup read costs enabled + * installs nothing (events buffer through the bounded consent gate and flush + * once it answers) while a disabled install never sends a byte; the native + * gate in `export_otel_logs` guarantees the latter regardless of renderer + * timing. + * + * Startup is bounded on every side: the consent read and the logger's own + * construction each answer within a deadline, and a construction failure is + * terminal rather than an unbounded wait, so the startup buffer always drains. + * The states that can still cost an event — overflow before the logger exists, + * a consent read that answers late (both the buffer its timeout discards and + * everything fired between that timeout and the late answer) or disabled, and + * that terminal failure — are counted and logged rather than dropped silently + * (see `noteDroppedEvents`). + * + * Event params (including the chat events' `session_id`, the one per-entity + * id left on the wire) become OTLP log-record **attributes**; + * `service.name`/`service.version`/`deployment.environment`, the persistent + * anonymous `installation.id`, and the build's `distribution.channel` become + * the OTel `Resource`. + * + * New events are thin wrappers that build their schema event and call `track`, + * inheriting environment gating, consent gating, the startup buffer, and + * crash-safety for free. + * + * A distribution with its own analytics pipeline replaces `./distributionSink` + * (stock: an inert no-op) instead of forking this module: `emit` hands every + * post-gate event to the sink, so an overlay inherits the build, consent, and + * buffer gating unchanged. + * + * Dev-only logging can be enabled with `VITE_TELEMETRY_DEBUG=1` or + * `localStorage.setItem("berd.telemetry.debug", "1")`. In development this + * logs the event that would have been emitted, per window, to that window's + * devtools console, while keeping real dispatch disabled. Complementing it, + * `./devLog` taps the top of `trackEvent` unconditionally under `just dev` and + * forwards every fired event to the terminal, where all windows converge. + */ + +import type { LogAttributes, Logger } from "@opentelemetry/api-logs"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + BatchLogRecordProcessor, + LoggerProvider, +} from "@opentelemetry/sdk-logs"; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from "@opentelemetry/semantic-conventions"; +import { + type BerdAppEnvironment, + type Event, + berdAppLifecycleLaunched, +} from "@/shared/telemetry/events"; + +import { invokeWithStartupRetry } from "@/shared/api/invokeWithStartupRetry"; +import { perfLog } from "@/shared/lib/perfLog"; +import { + getEnvironment, + isProduction, + isStaging, +} from "@/shared/utils/environment"; +import { getProfileCapabilitySnapshot } from "@/shared/profile/capabilities"; +import { + ensureTelemetryConsentLoaded, + telemetryConsentGranted, + telemetryConsentSettled, + useTelemetryConsentStore, +} from "./consent"; +import { devLogEvent } from "./devLog"; +import { distributionSink } from "./distributionSink"; +import { createTelemetryLogExporter } from "./exporter"; + +// Injected by vite.config.ts from VITE_APP_VERSION, falling back to package.json. +const appVersion = import.meta.env.VITE_APP_VERSION ?? "0.0.0"; +const TELEMETRY_DEBUG_STORAGE_KEY = "berd.telemetry.debug"; + +// OTel instrumentation scope for every emitted log record. The gateway's +// `berd-otlp-logs-v1` schema pins this exact literal (renamed from +// `goose-internal.telemetry` before any client shipped), so it moves in +// lockstep with the gateway schema, not with local naming. +const TELEMETRY_SCOPE_NAME = "berd.telemetry"; +// `deployment.environment` is an incubating semantic convention; inline the key +// to avoid importing the large `/incubating` module for a single constant. +const ATTR_DEPLOYMENT_ENVIRONMENT = "deployment.environment"; +// Persistent anonymous per-install identity (not an OTel semantic convention; +// the ingestion gateway keys on this exact name). The primary analytics key — +// installs are anonymous, and no user identity rides the wire. +const ATTR_INSTALLATION_ID = "installation.id"; +// Which build artifact this install came from (not an OTel semantic +// convention; the gateway allowlists this exact name with a closed value +// set). It labels the build channel, never the human — a Block employee +// running the public GitHub release is an internal person on a public channel +// — and it is dashboard segmentation, not a trust boundary: ingestion is +// anonymous, so the value is spoofable by design. +const ATTR_DISTRIBUTION_CHANNEL = "distribution.channel"; + +/** + * The closed `distribution.channel` value set the gateway accepts. Sourced + * natively from the staged distro config (`telemetry.channel` in distro.json); + * `"public"` is the fallback for every other state — no distro bundle, no + * `telemetry` section, an unrecognized value, or a native answer that never + * arrives. + */ +type TelemetryChannel = "public" | "internal"; + +// OTel pipeline sizing. These are deliberately NOT the SDK defaults (512-record +// batches, unbounded attribute values), because a batch that exceeds the +// ingestion gateway's 256 KiB request-body limit is rejected with a 413 and +// then *permanently lost*: `BatchLogRecordProcessor` drops a failed export +// instead of re-queueing it, and the pipeline's only retry is the native 401 +// re-auth (see `./exporter`). So the batch has to be small enough that a full +// one cannot reach the limit — the case that matters most, the recovery flush +// after an outage, is exactly the case that fills a batch. +// +// Measured through this repo's own serializer (`JsonLogsSerializer`, the exact +// path `TauriOtlpLogExporter` runs), a realistic worst-case record is ~819 B: a +// full 512-record batch is ~410 KiB, 1.6x over the limit, while a 128-record +// batch is ~103 KiB, 2.5x under it. +// +// A record-count cap bounds nothing on its own, though — one pasted 10k-char +// BYO-key model id (that field is user-typed free text) would push a 128-record +// batch past 1 MiB. `MAX_LOG_ATTRIBUTE_VALUE_LENGTH` closes that hole by +// truncating each attribute value at emit time, which makes the ceiling +// enforced rather than assumed: a 128-record batch with *every* string +// attribute maxed at the limit still serializes to ~224 KiB, under the body +// limit (pinned in `exporter.test.ts`). That enforced ceiling is the tight one +// — raising either constant needs the math redone, lowering them is free. 256 +// characters leaves every real value untruncated — UUIDs (36), the +// `source_surface` enum (<=35), model ids (<80) — so only garbage is ever cut. +// +// Do not restore the SDK defaults without redoing this math against the +// gateway's current body limit. The three constants are exported so +// `exporter.test.ts` can pin the byte ceiling they encode. +export const MAX_LOG_EXPORT_BATCH_SIZE = 128; +export const MAX_LOG_ATTRIBUTE_VALUE_LENGTH = 256; +export const GATEWAY_BODY_LIMIT_BYTES = 256 * 1024; +// Memory bound rather than a wire bound: caps how many records a stalled +// exporter can hold (batch size <= queue size must hold). Left at the SDK +// default — a full queue now drains as ceil(2048 / 128) = 16 consecutive POSTs +// (exports are strictly sequential), well inside the gateway's per-installation +// upload rate limit. +const MAX_LOG_QUEUE_SIZE = 2048; + +/** + * The build/environment half of the telemetry gate: the `telemetry` + * capability in production/staging. The capability AND-gates the build + * feature (the immediate, no-flicker off switch) with + * `featureToggles.telemetry` (the future endpoint toggle), so a restricted + * build disables telemetry now via `VITE_TELEMETRY=0` and the bundled runtime + * config / endpoint can disable it later with no code change. + * + * Caveat: `initTelemetry()` + `trackAppLaunched()` fire at startup before + * runtime config loads, so a runtime/endpoint disable cannot suppress the launch + * event — only the build feature can. + */ +function telemetryBuildEnabled(): boolean { + return ( + getProfileCapabilitySnapshot("telemetry") && (isProduction() || isStaging()) + ); +} + +function telemetryDebugLoggingEnabled(): boolean { + if (getEnvironment() !== "development") return false; + if (import.meta.env.VITE_TELEMETRY_DEBUG === "1") return true; + + try { + return ( + typeof localStorage !== "undefined" && + localStorage.getItem(TELEMETRY_DEBUG_STORAGE_KEY) === "1" + ); + } catch { + return false; + } +} + +// The OTel logger, created once telemetry is initialized in production/staging. +// Null in development / disabled builds so the path stays fully inert, and +// briefly null at startup while its `Resource` awaits the native telemetry +// resource (see `initTelemetry`). +let logger: Logger | null = null; + +// Set when the logger can never appear: its construction threw. Nothing retries +// it, so the state is terminal for this renderer and every later event is a +// counted drop rather than a buffered wait for something that is not coming. +let loggerUnavailable = false; + +// Cumulative count of events this window session could not emit. Telemetry +// cannot report its own loss over the wire (the wire is the thing that is +// broken in both loss states), so the count rides the module's existing +// diagnostic channel — `perfLog`, live under Vite dev or `goose.perf=1` in +// localStorage — which is what makes a drop observable rather than silent. +let droppedEventCount = 0; + +/** + * Records events the pipeline could not emit. Every loss path in this module + * routes through here, so "dropped" is always a number someone can read rather + * than an early `return`. + */ +function noteDroppedEvents(count: number, reason: string): void { + if (count <= 0) return; + droppedEventCount += count; + perfLog( + `[telemetry] dropped ${count} event(s): ${reason} (${droppedEventCount} dropped this session)`, + ); +} + +// Startup buffer: holds events tracked before the pipeline can emit them — +// while consent is still loading, and while the logger's construction awaits +// the native telemetry-resource round-trip. Bounded in size and time so it can +// neither leak nor delay forever; events are flushed (backdated) once the +// logger exists, or discarded as counted drops when consent settles disabled +// or a gate times out. +// +// Both gates are bounded — consent by `CONSENT_LOAD_TIMEOUT_MS`, the logger by +// `TELEMETRY_RESOURCE_TIMEOUT_MS` plus the terminal `loggerUnavailable` state +// — so the buffer always drains. It can still cost events in one state: more +// than `MAX_BUFFERED_EVENTS` tracked before the logger exists overflows, and +// an overflowing event with no logger to emit through cannot be kept. That is +// a counted drop (`noteDroppedEvents`), not a silent one. +const MAX_BUFFERED_EVENTS = 50; +// Bounds the logger gate the way `CONSENT_LOAD_TIMEOUT_MS` bounds the consent +// gate. `get_telemetry_resource` is synchronous native work behind an IPC +// round-trip, so a hang needs main-thread contention or blocking filesystem IO +// in the app-data dir — unlikely, but nothing else bounds it, and the renderer +// cannot tell a hang from a slow answer. Without a deadline a hung invoke would +// wedge the pipeline permanently: the logger stays null, so every event buffers +// and then overflows one at a time. Timing out costs one resource attribute (a +// late answer is not adopted; the gateway still keys uploads on the id it +// authenticated natively) plus the channel falling back to "public", and keeps +// the pipeline live. +const TELEMETRY_RESOURCE_TIMEOUT_MS = 5_000; +// Bounds the consent gate the way the logger gate is bounded: the persisted +// telemetry setting is read asynchronously at startup, and until it answers, +// consent is unknown — events buffer rather than emit (fail closed). If the +// read has not settled within this deadline the buffer is released as counted +// drops; consent itself stays pending, so a late enabled answer still brings +// the pipeline up for everything tracked afterwards. +const CONSENT_LOAD_TIMEOUT_MS = 5_000; + +interface BufferedEvent { + createEvent: () => Event; + timestamp: string; +} + +let buffer: BufferedEvent[] = []; + +/** + * Emits an event as an OTel log record. The event's params are the record's + * attributes, verbatim. `timestamp` backdates a buffered event to when it + * actually occurred rather than when it was flushed. + */ +function emit(createEvent: () => Event, timestamp?: string): void { + if (logger === null) { + // Backstop: every caller already gates on the logger existing, so this is + // unreachable. Counted rather than silently returned so a future caller + // that forgets cannot reintroduce the silent drop this used to be. + noteDroppedEvents(1, "no logger at emit"); + return; + } + const ev = createEvent(); + logger.emit({ + eventName: ev.name, + attributes: ev.parameters as LogAttributes, + timestamp: timestamp ? new Date(timestamp) : undefined, + }); + // The distro fan-out seam (see `./distributionSink`): fires only for events + // that reached the logger above, so a replacement sink inherits every + // gating decision — build, consent, buffer — for free. Guarded here, on + // this side of the seam, so a throwing replacement cannot disturb the + // emission that already happened or the caller above it. + try { + distributionSink({ + name: ev.name, + attributes: ev.parameters, + firedAt: timestamp ?? new Date().toISOString(), + }); + } catch (error) { + perfLog(`[telemetry] distribution sink failed: ${String(error)}`); + } +} + +function logDebugEvent(createEvent: () => Event): void { + if (!telemetryDebugLoggingEnabled()) return; + + try { + const ev = createEvent(); + console.info("[telemetry:debug] event suppressed", { + eventName: ev.name, + attributes: ev.parameters as LogAttributes, + }); + } catch { + // Debug logging must never affect app behavior. + } +} + +/** Drains the startup buffer once the logger exists. Idempotent. */ +function maybeFlushBuffer(): void { + if (logger === null) return; + + const pending = buffer; + buffer = []; + for (const { createEvent, timestamp } of pending) { + try { + emit(createEvent, timestamp); + } catch (error) { + perfLog(`[telemetry] failed to flush event: ${String(error)}`); + } + } +} + +/** Abandons the buffer when its events can never emit, counting the loss. */ +function discardBuffer(reason: string): void { + const pending = buffer; + buffer = []; + noteDroppedEvents(pending.length, reason); +} + +/** + * Rejects if `promise` has not settled within `ms`, so a caller that already + * treats a rejection as a fallback answer treats a hang the same way. The timer + * is cleared on settle, so a prompt answer leaves nothing pending. + */ +function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} did not answer within ${ms}ms`)); + }, ms); + promise.then(resolve, reject).finally(() => { + clearTimeout(timer); + }); + }); +} + +interface TelemetryResource { + installationId: string; + channel: TelemetryChannel; +} + +/** + * Resolves the native half of the OTel `Resource`: the persistent anonymous + * installation id plus the distribution channel the staged distro config + * declares. Both halves have safe fallbacks — an empty `installationId` omits + * the attribute (the ingestion gateway still keys uploads on the id it + * authenticated via the bootstrap token, which lives entirely in the Rust + * layer), and anything but the exact `"internal"` literal reads as `"public"`, + * so a stale or malformed native answer cannot put a value outside the + * gateway's closed set on the wire. + * + * A hang is answered exactly like a rejection, because the logger — and with it + * the whole pipeline — waits on this call (see `TELEMETRY_RESOURCE_TIMEOUT_MS`). + * + * The invoke goes through `invokeWithStartupRetry` for the same reason + * `getTelemetrySettings` does, only more sharply: an enforced build reads its + * consent from a build-time constant, so `initTelemetry()` reaches this call at + * renderer boot with no prior native round-trip to absorb the window where the + * hidden main window's webview runs ahead of Tauri's `setup()` and commands + * reject with "state not managed". Without the retry that transient rejection + * would take the fallback below, and because the provider's `Resource` is fixed + * at construction and nothing rebuilds it, an internal build would spend the + * whole session reporting `distribution.channel: "public"` — every event of it + * silently counted in the public segment. A non-enforced install cannot hit the + * window (it only starts the pipeline after the consent read has already proved + * the state is managed), and genuine errors still fall through immediately: + * the helper retries only the transient state messages. + */ +async function fetchTelemetryResource(): Promise { + try { + // The retry's worst case (100+200+400+800+1600ms of backoff plus six IPC + // round-trips) fits inside the deadline below, so the pipeline's startup + // bound is unchanged; if the deadline still fires first, the fallback + // applies exactly as it did before the retry existed. + const resource = await withTimeout( + invokeWithStartupRetry("get_telemetry_resource"), + TELEMETRY_RESOURCE_TIMEOUT_MS, + "get_telemetry_resource", + ); + return { + installationId: + typeof resource?.installationId === "string" + ? resource.installationId + : "", + channel: resource?.channel === "internal" ? "internal" : "public", + }; + } catch (error) { + perfLog( + `[telemetry] failed to resolve the telemetry resource: ${String(error)}`, + ); + return { installationId: "", channel: "public" }; + } +} + +/** + * Initializes telemetry once at app start. No-op outside production/staging, so + * the OTel `LoggerProvider`/exporter (and its native OTLP send path) are never + * even constructed in dev or external clones. Must be called before any + * `track`. + * + * With the build/environment gate open, what happens next depends on consent: + * granted (enforced builds, or a mid-session re-init after the setting + * loaded) starts the pipeline immediately; unknown starts the bounded consent + * gate — events buffer while the persisted setting loads, then either the + * pipeline comes up and flushes them (enabled) or the buffer is discarded + * (disabled/timeout). The telemetry-resource round-trip is deferred until + * consent is granted, so a disabled install does no telemetry work at all + * beyond the one settings read. + */ +let initialized = false; +export function initTelemetry(): void { + if (initialized) return; + initialized = true; + if (!telemetryBuildEnabled()) return; + + if (telemetryConsentSettled()) { + if (telemetryConsentGranted()) startPipeline(); + return; + } + + consentTimer = setTimeout(() => { + consentTimer = null; + consentTimedOut = true; + discardBuffer("the telemetry setting did not load in time"); + }, CONSENT_LOAD_TIMEOUT_MS); + // The subscription outlives the startup gate on purpose: it is also what + // brings the pipeline up when the user enables telemetry mid-session from + // the settings toggle (and after a late-arriving startup read). + useTelemetryConsentStore.subscribe(onConsentChanged); + ensureTelemetryConsentLoaded(); +} + +/** Applies a settled consent answer: pipeline up, or buffer released. */ +function onConsentChanged(): void { + if (!telemetryConsentSettled()) return; + if (consentTimer !== null) { + clearTimeout(consentTimer); + consentTimer = null; + } + if (telemetryConsentGranted()) { + consentTimedOut = false; + startPipeline(); + } else { + discardBuffer("telemetry is disabled for this installation"); + } +} + +// Consent gate state. `consentTimedOut` closes the gate for buffering (events +// stop accumulating and are suppressed like a denial, but counted as drops +// while consent stays unsettled) without settling consent itself — a late +// enabled answer re-opens emission via the store subscription above. +let consentTimer: ReturnType | null = null; +let consentTimedOut = false; + +/** + * Whether consent has settled as (or timed out into) "not granted" — the + * suppression state, as opposed to the still-loading state events buffer + * through. + * + * The two sub-states it merges suppress identically but differ in drop + * accounting: a settled denial is deliberate silence, while a timeout that + * consent has not yet answered may still turn out to have been a loss. The + * caller owns that distinction (see `trackEvent`). + */ +function telemetryConsentDenied(): boolean { + return ( + (telemetryConsentSettled() || consentTimedOut) && !telemetryConsentGranted() + ); +} + +/** + * Drains the batch processor when the window hides or closes, so a session's + * last events do not die with its webview. + * + * `BatchLogRecordProcessor` holds emitted records until its scheduled delay + * elapses (1s by default in the pinned SDK; left implicit, and shortening it + * is the wrong lever — it narrows the window without closing it and multiplies + * POSTs against the gateway's per-install rate limit). Whatever is still + * queued when the webview is torn down is lost *invisibly*: that queue is + * opaque to `noteDroppedEvents`. The window is short but sits under exactly + * the gestures the catalog cares about — closing a detached session window + * right after a send, quitting shortly after launch, and the tail of every + * session as a standing tax. + * + * A flush has real teeth here, unlike in a browser: `export()` serializes + * synchronously and hands the body to `invoke("export_otel_logs")`, and once + * that IPC message crosses into the Rust process the POST runs on the native + * runtime, which outlives the webview. So the durable step is posting the + * message, not awaiting the answer — hence fire-and-forget, which is also all + * an unload handler could manage. `forceFlush()` reaches `export()` within a + * few microtasks (no timers, and this `Resource` has no async attributes to + * wait on), inside the same task as the event dispatch. + * + * Two listeners, because the teardown paths signal differently (see + * `attach_main_window_lifecycle` in `src-tauri/src/lib.rs`): the main window's + * close is intercepted and turned into `hide()` while any secondary window + * exists, which is a `visibilitychange` to hidden with the page surviving, + * while a last-window close, a detached `session:*` window close, and app quit + * are real webview destructions, which is `pagehide`. + * + * `pagehide` is registered on `window`, where the event actually fires. The + * SDK's browser-variant processor registers its own pair on `document`, so its + * `visibilitychange` half works while its `pagehide` fallback — the one added + * for WebKit, which is what this app runs on — never fires at all: a + * window-targeted event's propagation path does not include `document`. + * Overlapping with the half that does work costs nothing (`forceFlush` on an + * empty queue snapshots an empty array and exports nothing, and a `_flushing` + * guard makes a concurrent call return early), and owning both listeners here + * means the defense no longer depends on which platform variant the bundler + * resolves. + * + * Nothing in an unload path may throw, so the registration and each flush are + * both contained — a failure is a diagnostic, never an exception into + * teardown. The registration carries its own `try` rather than leaning on the + * one it is called inside: a throw caught there would declare the terminal + * `loggerUnavailable` state for a logger that exists and works. + */ +function installCloseFlushHooks(provider: LoggerProvider): void { + const flush = () => { + try { + void provider.forceFlush().catch((error) => { + perfLog(`[telemetry] close flush failed: ${String(error)}`); + }); + } catch (error) { + perfLog(`[telemetry] close flush failed: ${String(error)}`); + } + }; + + try { + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }); + } catch (error) { + perfLog( + `[telemetry] failed to install the close flush hooks: ${String(error)}`, + ); + } +} + +/** + * Brings up the OTel pipeline. The logger appears asynchronously — its + * `Resource` awaits the native installation id and distribution channel — so + * events buffered before it exists are flushed once it does. Idempotent — + * consent can settle more than once (a mid-session toggle), but the pipeline + * is built at most once per renderer, which is also what makes the close-flush + * hooks below register exactly once. + */ +let pipelineStarted = false; +function startPipeline(): void { + if (pipelineStarted) return; + pipelineStarted = true; + + void (async () => { + try { + const { installationId, channel } = await fetchTelemetryResource(); + const provider = new LoggerProvider({ + resource: resourceFromAttributes({ + // Wire literal the gateway's schema pins exactly (renamed from + // "goose-internal"), like TELEMETRY_SCOPE_NAME. + [ATTR_SERVICE_NAME]: "berd", + [ATTR_SERVICE_VERSION]: appVersion, + [ATTR_DEPLOYMENT_ENVIRONMENT]: getEnvironment(), + [ATTR_DISTRIBUTION_CHANNEL]: channel, + ...(installationId ? { [ATTR_INSTALLATION_ID]: installationId } : {}), + }), + // Truncates each log-record attribute value at emit time (resource + // attributes are fixed-length and unaffected), so batch size alone is + // enough to bound the serialized body — see MAX_LOG_EXPORT_BATCH_SIZE. + logRecordLimits: { + attributeValueLengthLimit: MAX_LOG_ATTRIBUTE_VALUE_LENGTH, + }, + processors: [ + new BatchLogRecordProcessor({ + exporter: createTelemetryLogExporter(), + maxQueueSize: MAX_LOG_QUEUE_SIZE, + maxExportBatchSize: MAX_LOG_EXPORT_BATCH_SIZE, + }), + ], + }); + logger = provider.getLogger(TELEMETRY_SCOPE_NAME); + maybeFlushBuffer(); + installCloseFlushHooks(provider); + } catch (error) { + // Construction is what throws here — `fetchTelemetryResource` answers + // with its fallbacks for both a rejection and a hang. Nothing retries + // the provider, so the failure is terminal for this renderer: without + // the catch the rejection would be unhandled and the pipeline would + // wedge exactly as a hung invoke used to, holding every event until it + // overflowed one at a time. Give up loudly instead — release the buffer + // and count what it cost. + loggerUnavailable = true; + perfLog(`[telemetry] failed to construct the logger: ${String(error)}`); + discardBuffer("the telemetry logger could not be constructed"); + } + })(); +} + +/** + * The single entry point all events flow through. No-op outside + * production/staging and when consent has settled disabled, and crash-safe. + * Emits immediately once the logger exists; until then — while consent is + * still loading and while the logger's construction is in flight — events + * land in the startup buffer, which the consent gate then flushes or + * discards. */ +function trackEvent(createEvent: () => Event): void { + // The dev-only viewer taps the chokepoint itself, ahead of every gate, so + // the `just dev` terminal reports what fired rather than what survived. It + // is dead code outside the Vite dev server and never reaches the exporter. + devLogEvent(createEvent); -/** Initializes telemetry in private distributions; inert in public Berd. */ -export function initTelemetry(): void {} + // The gate checks sit inside the try too: track() runs at feature commit + // points (some inside dispatch paths), so nothing in here may escape. + try { + // Split from the consent gate below so the build gate stays silent: a dev + // build (or `VITE_TELEMETRY=0`) suppressing an event is not a loss, and + // `telemetryBuildEnabled()` can flip off mid-session, which would + // otherwise start counting drops for a build that never emits. + if (!telemetryBuildEnabled()) { + logDebugEvent(createEvent); + return; + } + if (telemetryConsentDenied()) { + // A settled denial is deliberate silence, not a loss. The timed-out but + // still-unsettled state is: the setting may yet answer enabled, so this + // event is a real loss for a consented install — counted, like the + // buffer the same timeout already discarded under the same uncertainty, + // and under the same accepted over-count if consent later settles + // disabled. (`!telemetryConsentSettled()` alone already implies the + // timeout here; the conjunction says so out loud.) + if (consentTimedOut && !telemetryConsentSettled()) { + noteDroppedEvents(1, "the telemetry setting did not load in time"); + } + logDebugEvent(createEvent); + return; + } -/** Records app launch in private distributions; inert in public Berd. */ -export function trackAppLaunched(): void {} + if (loggerUnavailable) { + // Terminal: buffering for a logger that will never exist would only + // defer the same loss, so take it now and keep it counted. + noteDroppedEvents(1, "the telemetry logger could not be constructed"); + return; + } + if (logger !== null) { + emit(createEvent); + return; + } + if (buffer.length >= MAX_BUFFERED_EVENTS) { + // Buffer full before the logger exists: the event can neither be kept + // (the bound is what stops the buffer growing without end) nor emitted. + // The window is short — both startup gates are bounded — so this needs + // MAX_BUFFERED_EVENTS events inside it, but it is a real loss, so + // count it instead of dropping it silently. + noteDroppedEvents(1, "buffer full before the logger was ready"); + return; + } + buffer.push({ createEvent, timestamp: new Date().toISOString() }); + } catch (error) { + perfLog(`[telemetry] failed to track event: ${String(error)}`); + } +} + +export function track(event: Event): void { + trackEvent(() => event); +} + +/** + * Marks that this window session has already reported the app launch. + * + * A renderer reload is not an app start, but it re-runs the whole renderer + * bundle, so module scope resets with it and a module-level flag cannot tell + * the two apart. Production has two real reload paths — a WebKit reap of the + * webview under memory pressure (see `src-tauri/src/services/renderer_monitor.rs` + * and `RendererTelemetry`'s rapid-reload heuristic) and the crash screen's + * Reload button (`RendererErrorBoundary`) — and both would otherwise re-fire + * the launch event. + * + * `sessionStorage` is the guard because its scope is exactly the invariant: it + * belongs to the window's browsing-context session, so it survives a reload of + * that window, and a real app start builds a new webview whose store is empty + * (nothing persists it to disk, and while the app runs the main window is + * hidden on close rather than recreated). Detached session windows get their + * own store, which is moot — they deliberately never fire this event. + */ +const LAUNCH_TRACKED_SESSION_KEY = "berd.telemetry.launchTracked"; + +/** + * Claims the one launch report this window session gets. Returns false once it + * has already been claimed. + * + * Fails open: if the store cannot be read or written the launch is reported + * anyway, because over-counting on a hostile storage environment beats losing + * the metric. + */ +function claimAppLaunch(): boolean { + try { + if (typeof sessionStorage === "undefined") return true; + if (sessionStorage.getItem(LAUNCH_TRACKED_SESSION_KEY) !== null) { + return false; + } + sessionStorage.setItem(LAUNCH_TRACKED_SESSION_KEY, "1"); + return true; + } catch { + return true; + } +} + +/** + * The `environment` the launch event reports, narrowed to the two values that + * can actually reach the wire — `development` has no representation in + * `BerdAppEnvironment`, so the attribute is simply absent there. + * + * Not a second gate: `telemetryBuildEnabled()` stays the only thing deciding + * whether an event emits, and it already closes in development. This decides + * only what the attribute *says*, and answers "nothing" rather than inventing a + * value, so a dev build can never be reported as production. The duplicate + * `deployment.environment` resource attribute is deliberately left alone — it + * still carries `getEnvironment()` verbatim. + */ +function launchEventEnvironment(): BerdAppEnvironment | undefined { + const environment = getEnvironment(); + return environment === "development" ? undefined : environment; +} + +/** + * Tracks the `berd_app_lifecycle_launched` event once per app start — a + * renderer reload re-runs the caller but reports nothing (see + * `LAUNCH_TRACKED_SESSION_KEY`). The claim is taken ahead of the environment + * gate so a reload is silent in every build, dev debug logging included: what + * that logs should be what production would emit. + */ +export function trackAppLaunched(): void { + if (!claimAppLaunch()) return; -/** Records successful feedback in private distributions; inert in public Berd. */ -export function trackFeedbackSubmitted(): void {} + trackEvent(() => + berdAppLifecycleLaunched({ + app_version: appVersion, + environment: launchEventEnvironment(), + }), + ); +} diff --git a/src/shared/telemetry/consent.test.ts b/src/shared/telemetry/consent.test.ts new file mode 100644 index 000000000..6ce608765 --- /dev/null +++ b/src/shared/telemetry/consent.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getTelemetrySettings = vi.hoisted(() => vi.fn()); +const setTelemetryEnabled = vi.hoisted(() => vi.fn()); +const perfLog = vi.hoisted(() => vi.fn()); + +vi.mock("@/shared/api/telemetrySettings", () => ({ + getTelemetrySettings, + setTelemetryEnabled, +})); +vi.mock("@/shared/lib/perfLog", () => ({ perfLog })); + +// Re-import per test so the store singleton and the one-shot load guard are +// fresh — each test is its own renderer session. +async function loadConsent() { + vi.resetModules(); + return await import("./consent"); +} + +beforeEach(() => { + getTelemetrySettings.mockReset().mockResolvedValue({ enabled: true }); + setTelemetryEnabled.mockReset(); + perfLog.mockReset(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("telemetry consent", () => { + it("fails closed until the persisted setting has affirmatively loaded", async () => { + const consent = await loadConsent(); + + expect(consent.telemetryConsentSettled()).toBe(false); + expect(consent.telemetryConsentGranted()).toBe(false); + + consent.ensureTelemetryConsentLoaded(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consent.telemetryConsentSettled()).toBe(true); + expect(consent.telemetryConsentGranted()).toBe(true); + }); + + it("loads the setting once per renderer, not once per caller", async () => { + const consent = await loadConsent(); + + consent.ensureTelemetryConsentLoaded(); + consent.ensureTelemetryConsentLoaded(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getTelemetrySettings).toHaveBeenCalledTimes(1); + }); + + it("settles a failed read as disabled rather than leaving consent undecided", async () => { + getTelemetrySettings.mockRejectedValue(new Error("state went away")); + const consent = await loadConsent(); + + consent.ensureTelemetryConsentLoaded(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(consent.telemetryConsentSettled()).toBe(true); + expect(consent.telemetryConsentGranted()).toBe(false); + expect(perfLog).toHaveBeenCalledWith( + "[telemetry] failed to load the telemetry setting: Error: state went away", + ); + }); + + it("grants consent in enforced builds without touching the persisted setting", async () => { + vi.stubEnv("VITE_TELEMETRY_ENFORCED", "1"); + const consent = await loadConsent(); + + expect(consent.telemetryConsentEnforced()).toBe(true); + expect(consent.telemetryConsentSettled()).toBe(true); + expect(consent.telemetryConsentGranted()).toBe(true); + + consent.ensureTelemetryConsentLoaded(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(getTelemetrySettings).not.toHaveBeenCalled(); + }); + + it("reflects the value the native side actually stored on update", async () => { + setTelemetryEnabled.mockResolvedValue({ enabled: true }); + const consent = await loadConsent(); + + await consent.updateTelemetryEnabled(true); + + expect(setTelemetryEnabled).toHaveBeenCalledWith(true); + expect(consent.telemetryConsentGranted()).toBe(true); + expect(consent.useTelemetryConsentStore.getState()).toEqual({ + loaded: true, + enabled: true, + }); + }); + + it("propagates a failed write so the toggle never shows a state that was not persisted", async () => { + setTelemetryEnabled.mockRejectedValue(new Error("read-only disk")); + const consent = await loadConsent(); + + await expect(consent.updateTelemetryEnabled(true)).rejects.toThrow( + "read-only disk", + ); + expect(consent.telemetryConsentGranted()).toBe(false); + }); +}); diff --git a/src/shared/telemetry/consent.ts b/src/shared/telemetry/consent.ts new file mode 100644 index 000000000..982432e64 --- /dev/null +++ b/src/shared/telemetry/consent.ts @@ -0,0 +1,99 @@ +/** + * Renderer half of the telemetry consent setting. + * + * The source of truth is the Rust-owned `telemetry-settings.json` in the + * app-data dir (see `src-tauri/src/commands/telemetry.rs`), which the native + * export gate enforces independently of anything here. This module mirrors + * that value into a small store so the client's per-event `telemetryEnabled()` + * check can read it synchronously, and so the settings toggle can render it. + * + * Fail-closed by construction: consent is granted only when the build + * enforces telemetry ON or the persisted setting has affirmatively loaded as + * enabled. Before the load answers — and if it fails — consent reads as not + * granted, so the failure mode is always dropped events, never leaked ones. + */ + +import { create } from "zustand"; +import { + getTelemetrySettings, + setTelemetryEnabled, +} from "@/shared/api/telemetrySettings"; +import { perfLog } from "@/shared/lib/perfLog"; +import { getBuildFeatureState } from "@/shared/profile/buildProfile"; + +interface TelemetryConsentState { + /** + * True once the persisted setting has answered — including a failed read, + * which settles as disabled rather than leaving consent undecided forever. + */ + loaded: boolean; + /** The persisted user setting; false (the opt-in default) until loaded. */ + enabled: boolean; +} + +export const useTelemetryConsentStore = create(() => ({ + loaded: false, + enabled: false, +})); + +/** + * Build-enforced consent: managed internal distributions force telemetry ON + * and never render the toggle, so the persisted setting is skipped entirely. + */ +export function telemetryConsentEnforced(): boolean { + return getBuildFeatureState().telemetryEnforced; +} + +/** True once consent has a definitive answer (never while it is loading). */ +export function telemetryConsentSettled(): boolean { + return ( + telemetryConsentEnforced() || useTelemetryConsentStore.getState().loaded + ); +} + +/** + * The effective consent, fail-closed: enforced builds are always granted; + * otherwise only an affirmatively loaded enabled setting grants it. + */ +export function telemetryConsentGranted(): boolean { + if (telemetryConsentEnforced()) return true; + const { loaded, enabled } = useTelemetryConsentStore.getState(); + return loaded && enabled; +} + +let loadStarted = false; + +/** + * Kicks off the one read of the persisted setting for this renderer. + * Idempotent; a no-op in enforced builds, where the file is never consulted. + * A failed read settles the store as disabled — the fail-closed answer — and + * is logged rather than retried: the value re-loads with the next renderer, + * and the settings toggle writes repair it immediately. + */ +export function ensureTelemetryConsentLoaded(): void { + if (loadStarted || telemetryConsentEnforced()) return; + loadStarted = true; + void getTelemetrySettings().then( + ({ enabled }) => + useTelemetryConsentStore.setState({ loaded: true, enabled }), + (error) => { + perfLog( + `[telemetry] failed to load the telemetry setting: ${String(error)}`, + ); + useTelemetryConsentStore.setState({ loaded: true, enabled: false }); + }, + ); +} + +/** + * Persists the user's choice natively, then reflects the value the Rust side + * actually stored. Rejections propagate so the settings toggle can surface + * the failure instead of showing a state that was never persisted. + */ +export async function updateTelemetryEnabled(enabled: boolean): Promise { + const settings = await setTelemetryEnabled(enabled); + useTelemetryConsentStore.setState({ + loaded: true, + enabled: settings.enabled, + }); +} diff --git a/src/shared/telemetry/devLog.test.ts b/src/shared/telemetry/devLog.test.ts new file mode 100644 index 000000000..40dca8ed6 --- /dev/null +++ b/src/shared/telemetry/devLog.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Event } from "@/shared/telemetry/events"; + +// The tap's only side effect is the `log_renderer_event` command, so the Tauri +// command layer is the whole observable surface. +const invoke = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +function messageSent(): Event { + return { + name: "berd_chat_message_sent", + parameters: { session_id: "session-1", is_first_message: true }, + }; +} + +async function loadDevLog() { + // Re-import so the memoized window label is resolved fresh per test. + vi.resetModules(); + return await import("./devLog"); +} + +/** The minimum Tauri internals `getCurrentWindow()` and `invoke` need. */ +function stubTauriWindow(label: string): { label: string } { + const currentWindow = { label }; + window.__TAURI_INTERNALS__ = { metadata: { currentWindow } }; + return currentWindow; +} + +function loggedLines(): string[] { + return invoke.mock.calls + .filter(([command]) => command === "log_renderer_event") + .map(([, args]) => String((args as { message: string }).message)); +} + +beforeEach(() => { + invoke.mockReset().mockResolvedValue(undefined); + // Vitest runs with `import.meta.env.DEV` true; stub it explicitly so each + // test states the gate it is exercising. + vi.stubEnv("DEV", true); +}); + +afterEach(() => { + delete window.__TAURI_INTERNALS__; + vi.unstubAllEnvs(); +}); + +describe("dev telemetry event viewer", () => { + it("forwards a fired event to the app log with its window label and params", async () => { + stubTauriWindow("main"); + + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + + // One line, at info, over the existing renderer log bridge — the Rust side + // stamps the timestamp, the level, its own `[renderer]` prefix, and the + // `[telemetry]` target slot. The params are the record's attributes + // verbatim; nothing else is stamped on. + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("log_renderer_event", { + level: "info", + message: + 'main berd_chat_message_sent {"session_id":"session-1","is_first_message":true}', + target: "telemetry", + }); + }); + + it("tags the forward with the telemetry log target, with no inline styling", async () => { + stubTauriWindow("main"); + + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + + // The grey terminal rendering keys off the record's log target in the + // Rust Stdout formatter. The message itself must carry no ANSI escapes: + // the same message reaches the `berd.log` file target, which the Stdout- + // only styling exists to keep clean. + const [, args] = invoke.mock.calls[0] as [ + string, + { message: string; target?: string }, + ]; + expect(args.target).toBe("telemetry"); + expect(args.message).not.toContain("\u001b"); + }); + + it("labels the fire with the window it came from", async () => { + // Detached session windows are separate webviews that fire the same + // events; the label is what tells their lines apart in the one terminal. + stubTauriWindow("session:abc123"); + + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + + expect(loggedLines()).toEqual([ + expect.stringContaining("session:abc123 berd_chat_message_sent"), + ]); + }); + + it("resolves the window label once per renderer", async () => { + const currentWindow = stubTauriWindow("main"); + + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + currentWindow.label = "changed"; + devLogEvent(messageSent); + + // A window's label is fixed for its lifetime, so the tap reads it once + // rather than on every event. + expect(loggedLines()).toEqual([ + expect.stringMatching(/^main /), + expect.stringMatching(/^main /), + ]); + }); + + it("writes nothing outside the Vite dev server", async () => { + // `import.meta.env.DEV` is statically false in every `vite build` output, + // so the tap is dead code there — it never writes event payloads (which + // carry session/project/agent ids) into a packaged build's log. + vi.stubEnv("DEV", false); + stubTauriWindow("main"); + + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + + expect(invoke).not.toHaveBeenCalled(); + }); + + it("performs no native work without Tauri internals", async () => { + const { devLogEvent } = await loadDevLog(); + devLogEvent(messageSent); + + // Routing through `logRendererEvent` rather than a raw `invoke` is what + // keeps the public seam in `client.inert.test.ts` honest. + expect(invoke).not.toHaveBeenCalled(); + }); + + it("survives a throwing event thunk", async () => { + stubTauriWindow("main"); + + const { devLogEvent } = await loadDevLog(); + expect(() => + devLogEvent(() => { + throw new Error("event construction failed"); + }), + ).not.toThrow(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("survives a rejecting invoke without an unhandled rejection", async () => { + stubTauriWindow("main"); + invoke.mockRejectedValue(new Error("no such command")); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + + try { + const { devLogEvent } = await loadDevLog(); + expect(() => devLogEvent(messageSent)).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + process.off("unhandledRejection", unhandled); + } + + // The forward is fire-and-forget: a failed log must not surface anywhere + // the app can see it. + expect(unhandled).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/telemetry/devLog.ts b/src/shared/telemetry/devLog.ts new file mode 100644 index 000000000..52527097b --- /dev/null +++ b/src/shared/telemetry/devLog.ts @@ -0,0 +1,95 @@ +/** + * Dev-time telemetry event viewer. + * + * Prints every event that fires to the terminal running `just dev`, so a + * developer can watch the catalog live. This is a tap, not a transport: it + * hangs off the top of `trackEvent` — the single chokepoint every event funnels + * through — so it reports what *fired*, independent of what the build gate, the + * consent gate, or the startup buffer does with the event next. A tap further + * down (the exporter, the native command) would show nothing at all in dev, + * where the build gate closes before any of that is even constructed. + * + * The forward rides the existing renderer log bridge — `logRendererEvent` → the + * `log_renderer_event` command → `log::info!` → `tauri-plugin-log`'s Stdout + * target — which is the one surface every window shares. The main window and + * each detached `session:*` window are separate webviews with separate devtools + * consoles, and all of them fire events; they converge only in the single Rust + * process, which is why the terminal beats a console. The log line lands in + * dev's `berd.log` for free. + * + * The forward is tagged with the `"telemetry"` log target rather than a + * message prefix: the default log format prints it as `[telemetry]` in both + * the terminal and `berd.log`, and the Rust Stdout formatter keys off it to + * render these lines grey so they read apart from ordinary log output. The + * message itself must stay free of ANSI escapes — the same message reaches the + * file target, where color codes would be pollution; styling belongs to the + * Stdout formatter alone. + * + * Gated on `import.meta.env.DEV`, deliberately not `getEnvironment()`: the + * latter defaults *any* build without `VITE_ENVIRONMENT` to "development", + * generic packaged builds included, which would write event payloads — they + * carry chat session ids and provider/model usage — into a user's `berd.log` + * without consent. + * `import.meta.env.DEV` is true only under the Vite dev server and is + * statically replaced with `false` in every `vite build` output, so the tap is + * eliminated as dead code outside dev and cannot become a consent bypass. Even + * in dev it only ever reaches the local log, never the exporter. + * + * The complementary `VITE_TELEMETRY_DEBUG` / `berd.telemetry.debug` console + * hook in `./client` is unchanged; it stays the per-window, object-level view. + */ + +import { getCurrentWindow } from "@tauri-apps/api/window"; + +import { logRendererEvent } from "@/shared/api/rendererTelemetry"; +import type { Event } from "@/shared/telemetry/events"; + +// Memoized: the label is fixed for the lifetime of the webview, and every +// window in the app writes into the same terminal, so it is what tells a `main` +// fire apart from a `session:` one. Resolved through the window API rather +// than the webview one because that is what the rest of the app uses; for +// Berd's windows (one webview each) the two report the same label. +let cachedWindowLabel: string | null = null; + +function currentWindowLabel(): string { + if (cachedWindowLabel === null) { + try { + cachedWindowLabel = getCurrentWindow().label; + } catch { + // No Tauri internals (tests, a browser preview): the forward below is a + // no-op anyway, so a placeholder keeps formatting total. + cachedWindowLabel = "unknown"; + } + } + return cachedWindowLabel; +} + +/** + * Logs one fired event to the `just dev` terminal. No-op outside the Vite dev + * server. + * + * The event's params are exactly the attributes the record would carry — with + * one caveat worth knowing: they are logged *pre-truncation*. The OTel + * `attributeValueLengthLimit` applies at the `LoggerProvider`, which does not + * exist in dev, so a long value prints in full here and would be cut on the + * wire. Read the line as "what fired", not as the exact wire payload. + */ +export function devLogEvent(createEvent: () => Event): void { + if (!import.meta.env.DEV) return; + + try { + const event = createEvent(); + const line = `${currentWindowLabel()} ${event.name} ${JSON.stringify( + event.parameters, + )}`; + // Fire-and-forget. `logRendererEvent` already swallows invoke failures and + // no-ops without Tauri internals; the catch keeps a future change there + // from turning a diagnostic into an unhandled rejection in `track()`. + void logRendererEvent("info", line, "telemetry").catch(() => { + // A diagnostic must never affect app behavior. + }); + } catch { + // Same: a throwing event thunk is the caller's problem to surface, not + // something the viewer should escalate. + } +} diff --git a/src/shared/telemetry/distributionSink.test.ts b/src/shared/telemetry/distributionSink.test.ts new file mode 100644 index 000000000..751a6781a --- /dev/null +++ b/src/shared/telemetry/distributionSink.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + type DistributionSinkEvent, + distributionSink, +} from "./distributionSink"; + +// The seam's stock half: a distro overlay replaces the module with a real +// implementation, so what these pin is that the *stock* build carries it as +// pure dead weight. The client-side half of the contract — which events cross +// the seam, with what shape, and that a throwing replacement cannot disturb +// emission — is pinned in `client.test.ts`. +describe("stock distribution sink", () => { + it("is a pure no-op that touches nothing", () => { + const event: DistributionSinkEvent = { + name: "berd_chat_message_sent", + attributes: { session_id: "abc123" }, + firedAt: "2026-08-14T00:00:00.000Z", + }; + const snapshot = structuredClone(event); + + expect(distributionSink(event)).toBeUndefined(); + + // Inert includes the input: a stock build must behave exactly as if the + // call were not there. + expect(event).toEqual(snapshot); + }); + + it("imports nothing, so the stock module cannot reach transport or invoke machinery", () => { + // Zero import statements is the strongest static form of the module's + // promise — no transport, no invoke, no side effects in stock builds. A + // change that adds one should have to defend itself here. + // Resolved from the repo root (vitest's cwd): under jsdom, + // `import.meta.url` is an http URL that node:fs cannot read from. + const source = readFileSync( + join(process.cwd(), "src/shared/telemetry/distributionSink.ts"), + "utf8", + ); + expect(source).not.toMatch(/^\s*import\b/m); + }); +}); diff --git a/src/shared/telemetry/distributionSink.ts b/src/shared/telemetry/distributionSink.ts new file mode 100644 index 000000000..ed391d217 --- /dev/null +++ b/src/shared/telemetry/distributionSink.ts @@ -0,0 +1,45 @@ +/** + * Distribution fan-out sink — the seam a distro overlay replaces. + * + * A distribution (e.g. an internal build with its own analytics pipeline) can + * swap this one file for a real implementation and receive every event the + * telemetry client emits, without forking `./client`. The client calls the + * sink from `emit` — after the build/environment gate, the consent gate, and + * the startup buffer — so a replacement fires exactly for the events that + * actually reach the OTel logger and inherits every gating decision, consent + * included, for free. Events the gates suppress or the buffer discards never + * arrive here. + * + * The stock implementation is a deliberate no-op with **no imports**: no + * transport, no `invoke`, no side effects — dead weight in every stock build, + * which is what makes replacing the file safe. Crash-safety lives on the + * client's side of the seam (the call is guarded there, in code the overlay + * does not replace), so a throwing replacement cannot disturb emission — but a + * replacement should still do its own work fire-and-forget and never block. + */ + +/** One emitted event, as handed to the sink. */ +export interface DistributionSinkEvent { + /** snake_case event name (e.g. "berd_app_lifecycle_launched"). */ + name: string; + /** + * The event's params — the same values the OTel record carries as its + * attributes, but *pre-truncation*: the OTel `attributeValueLengthLimit` + * applies inside the `LoggerProvider`, not here. + */ + attributes: Record; + /** + * ISO-8601 timestamp of when the event originally fired — for an event that + * sat in the startup buffer, that is the fire time, not the flush time, so + * a sink preserves real timing. + */ + firedAt: string; +} + +/** + * Receives one emitted (post-gate) event. Stock: a pure no-op. + */ +export function distributionSink(_event: DistributionSinkEvent): void { + // Deliberately empty — see the module doc. A distro overlay replaces this + // file to fan events out to its own pipeline. +} diff --git a/src/shared/telemetry/events/berd_agent.ts b/src/shared/telemetry/events/berd_agent.ts new file mode 100644 index 000000000..b04db738c --- /dev/null +++ b/src/shared/telemetry/events/berd_agent.ts @@ -0,0 +1,87 @@ +// Vendored typed telemetry event factories. Originally generated from +// squareup/message-schemas (cdp_events/berd_agent/berd_agent.yaml); the +// generator is not part of this repo, so this is ordinary source now — edit by +// hand and keep event/param names aligned with the schema repo. + +import type { Event } from "./event"; + +export interface BerdAgentCreateCompletedParams { + /** Configured provider for the completed agent/persona, when present. */ + provider?: string; + /** Configured model for the completed agent/persona, when present. */ + model?: string; +} + +/** + * BerdAgent · Create · Completed + * + * Tracks when the agent/persona creation flow completes. + * + * Feature: Events related to user agent/persona management in the Berd desktop app + * Action: Events related to creating agents or personas + */ +export function berdAgentCreateCompleted( + params: BerdAgentCreateCompletedParams, +): Event { + const parameters: Event["parameters"] = {}; + // Absent optional params are omitted entirely, never serialized as the OTLP + // empty `value: {}` encoding, so the ingestion gateway's allowlist only ever + // sees these keys carrying a value. + if (params.provider !== undefined) parameters.provider = params.provider; + if (params.model !== undefined) parameters.model = params.model; + return { + name: "berd_agent_create_completed", + parameters, + }; +} + +export interface BerdAgentEditCompletedParams { + /** Configured provider after the agent/persona edit completes, when present. */ + provider?: string; + /** Configured model after the agent/persona edit completes, when present. */ + model?: string; +} + +/** + * BerdAgent · Edit · Completed + * + * Tracks when the agent/persona edit flow completes. + * + * Feature: Events related to user agent/persona management in the Berd desktop app + * Action: Events related to editing agents or personas + */ +export function berdAgentEditCompleted( + params: BerdAgentEditCompletedParams, +): Event { + const parameters: Event["parameters"] = {}; + // Absent optional params are omitted entirely, never serialized as the OTLP + // empty `value: {}` encoding, so the ingestion gateway's allowlist only ever + // sees these keys carrying a value. + if (params.provider !== undefined) parameters.provider = params.provider; + if (params.model !== undefined) parameters.model = params.model; + return { + name: "berd_agent_edit_completed", + parameters, + }; +} + +/** + * BerdAgent · Delete · Completed + * + * Tracks when the agent/persona deletion flow completes. + * + * A deliberately attribute-less bare counter: dropping `agent_id` (the + * persona's on-disk path) left it carrying nothing else, and unlike the + * retired feedback event its count *is* the signal — deletion rate against + * creations is net agent adoption per install, derivable from nothing else + * (see the policy comment in ./index.ts). + * + * Feature: Events related to user agent/persona management in the Berd desktop app + * Action: Events related to deleting agents or personas + */ +export function berdAgentDeleteCompleted(): Event { + return { + name: "berd_agent_delete_completed", + parameters: {}, + }; +} diff --git a/src/shared/telemetry/events/berd_app.ts b/src/shared/telemetry/events/berd_app.ts new file mode 100644 index 000000000..954e30070 --- /dev/null +++ b/src/shared/telemetry/events/berd_app.ts @@ -0,0 +1,59 @@ +// Vendored typed telemetry event factories. Originally generated from +// squareup/message-schemas (cdp_events/goose_internal_app/goose_internal_app.yaml) +// and renamed here from GooseInternalApp to BerdApp — GooseInternal is the old +// product name. The generator is not part of this repo, so this is ordinary +// source now — edit by hand and keep event/param names aligned with the schema +// repo. + +import type { Event } from "./event"; + +/** + * The runtime environments this event can report. + * + * Deliberately narrower than `Environment` in `@/shared/utils/environment`: + * `telemetryBuildEnabled()` gates emission on production/staging, so + * `"development"` is already unreachable at runtime — this makes it + * unrepresentable in the type as well, so the gate is not the only thing + * standing between a dev build and a value the ingestion gateway rejects on + * the `deployment.environment` resource attribute this one duplicates. + */ +export type BerdAppEnvironment = "production" | "staging"; + +export interface BerdAppLifecycleLaunchedParams { + /** App version from package.json (injected via VITE_APP_VERSION) */ + app_version: string; + /** + * Runtime environment — production | staging. Optional only to express the + * one state with no value in that closed set: a development build, where the + * event still fires (the dev viewer reports it) but never reaches the wire. + * Absent rather than defaulted, so nothing can report a dev build as + * production. + */ + environment?: BerdAppEnvironment; +} + +/** + * BerdApp · Lifecycle · Launched + * + * Tracks each time the Berd desktop app starts, emitted once from the frontend after React mounts. + * + * Feature: Feature for tracking events related to the Berd Tauri desktop app + * Action: Events related to the Berd desktop app lifecycle + */ +export function berdAppLifecycleLaunched( + params: BerdAppLifecycleLaunchedParams, +): Event { + const parameters: Event["parameters"] = { + app_version: params.app_version, + }; + // Absent optional params are omitted entirely, never serialized as the OTLP + // empty `value: {}` encoding, so the ingestion gateway's allowlist only ever + // sees this key carrying a value. + if (params.environment !== undefined) { + parameters.environment = params.environment; + } + return { + name: "berd_app_lifecycle_launched", + parameters, + }; +} diff --git a/src/shared/telemetry/events/berd_chat.ts b/src/shared/telemetry/events/berd_chat.ts new file mode 100644 index 000000000..0a7578369 --- /dev/null +++ b/src/shared/telemetry/events/berd_chat.ts @@ -0,0 +1,98 @@ +// Vendored typed telemetry event factories. Originally generated from +// squareup/message-schemas (cdp_events/berd_chat/berd_chat.yaml); the +// generator is not part of this repo, so this is ordinary source now — edit by +// hand and keep event/param names aligned with the schema repo. + +import type { Event } from "./event"; + +// SESSION_WINDOW and SEARCH were dropped from this set: no flow ever produced +// them (detached session windows report MAIN_CHAT), so keeping them implied a +// distinction the data does not carry. +export type BerdChatChatSourceSurface = + | "CHAT_SOURCE_SURFACE_MAIN_CHAT" + | "CHAT_SOURCE_SURFACE_GLOBAL_COMPOSER" + | "CHAT_SOURCE_SURFACE_AGENT_BUILDER"; + +export interface BerdChatSessionStartedParams { + /** ID of the chat session. */ + session_id: string; + /** Entry point for how the user started the chat session. */ + source_surface: BerdChatChatSourceSurface; + /** Whether the session start was associated with a project. */ + has_project: boolean; + /** Whether the session start used a persona/agent */ + has_persona: boolean; + /** AI provider for the first message. */ + provider?: string; + /** AI model for the first message. */ + model?: string; +} + +/** + * BerdChat · Session · Started + * + * Tracks when the user starts a chat session just before submitting the first user message, after a session id exists. + * + * Feature: Events related to chat sessions and direct chat interactions in the Berd desktop app + * Action: Events related to starting and opening chat sessions + */ +export function berdChatSessionStarted( + params: BerdChatSessionStartedParams, +): Event { + const parameters: Event["parameters"] = { + session_id: params.session_id, + source_surface: params.source_surface, + has_project: params.has_project, + has_persona: params.has_persona, + }; + // Absent optional params are omitted entirely, never serialized as the OTLP + // empty `value: {}` encoding, so the ingestion gateway's allowlist only ever + // sees these keys carrying a value. + if (params.provider !== undefined) parameters.provider = params.provider; + if (params.model !== undefined) parameters.model = params.model; + return { + name: "berd_chat_session_started", + parameters, + }; +} + +export interface BerdChatMessageSentParams { + /** ID of the chat session. */ + session_id: string; + /** Whether this submitted message is the first user message in the session. */ + is_first_message: boolean; + /** Whether the submitted message included attachments. */ + has_attachments: boolean; + /** Whether the message used a persona/agent. */ + has_persona: boolean; + /** AI provider for the message. */ + provider?: string; + /** AI model for the message. */ + model?: string; +} + +/** + * BerdChat · Message · Sent + * + * Tracks when the user sends a chat message. + * + * Feature: Events related to chat sessions and direct chat interactions in the Berd desktop app + * Action: Events related to sending chat messages + */ +export function berdChatMessageSent(params: BerdChatMessageSentParams): Event { + const parameters: Event["parameters"] = { + session_id: params.session_id, + is_first_message: params.is_first_message, + has_attachments: params.has_attachments, + has_persona: params.has_persona, + }; + // Absent optional params are omitted entirely, never serialized as the OTLP + // empty `value: {}` encoding, so the ingestion gateway's allowlist only ever + // sees these keys carrying a value. + if (params.provider !== undefined) parameters.provider = params.provider; + if (params.model !== undefined) parameters.model = params.model; + return { + name: "berd_chat_message_sent", + parameters, + }; +} diff --git a/src/shared/telemetry/events/berd_home.ts b/src/shared/telemetry/events/berd_home.ts new file mode 100644 index 000000000..4b918de39 --- /dev/null +++ b/src/shared/telemetry/events/berd_home.ts @@ -0,0 +1,59 @@ +// Vendored typed telemetry event factories. Originally generated from +// squareup/message-schemas (cdp_events/berd_home/berd_home.yaml); the +// generator is not part of this repo, so this is ordinary source now — edit by +// hand and keep event/param names aligned with the schema repo. + +import type { Event } from "./event"; + +export type BerdHomeHomeItemType = + | "HOME_ITEM_TYPE_AGENT" + | "HOME_ITEM_TYPE_CHAT" + | "HOME_ITEM_TYPE_PROJECT" + | "HOME_ITEM_TYPE_AUTOMATION" + | "HOME_ITEM_TYPE_SKILL"; + +export interface BerdHomePinPinnedParams { + /** Kind of entity pinned to the Home page. */ + item_type: BerdHomeHomeItemType; +} + +/** + * BerdHome · Pin · Pinned + * + * Tracks when the user pins an item to the Home page. + * + * Feature: Events related to pinning items on the Home page, a free-form widget canvas, in the Berd desktop app + * Action: Events related to pinning items to the Home page + */ +export function berdHomePinPinned(params: BerdHomePinPinnedParams): Event { + return { + name: "berd_home_pin_pinned", + parameters: { + item_type: params.item_type, + }, + }; +} + +export interface BerdHomeUnpinUnpinnedParams { + /** Kind of entity unpinned from the Home page. */ + item_type: BerdHomeHomeItemType; +} + +/** + * BerdHome · Unpin · Unpinned + * + * Tracks when the user unpins an item from the Home page. + * + * Feature: Events related to pinning items on the Home page, a free-form widget canvas, in the Berd desktop app + * Action: Events related to unpinning items from the Home page + */ +export function berdHomeUnpinUnpinned( + params: BerdHomeUnpinUnpinnedParams, +): Event { + return { + name: "berd_home_unpin_unpinned", + parameters: { + item_type: params.item_type, + }, + }; +} diff --git a/src/shared/telemetry/events/berd_project.ts b/src/shared/telemetry/events/berd_project.ts new file mode 100644 index 000000000..65586620f --- /dev/null +++ b/src/shared/telemetry/events/berd_project.ts @@ -0,0 +1,87 @@ +// Vendored typed telemetry event factories. Originally generated from +// squareup/message-schemas (cdp_events/berd_project/berd_project.yaml); the +// generator is not part of this repo, so this is ordinary source now — edit by +// hand and keep event/param names aligned with the schema repo. + +import type { Event } from "./event"; + +export interface BerdProjectCreateCompletedParams { + /** Whether the completed project included a working directory. */ + has_working_dir: boolean; + /** Whether the completed project included configured instructions or prompt text. */ + has_prompt: boolean; +} + +/** + * BerdProject · Create · Completed + * + * Tracks when the project creation flow completes. + * + * Feature: Events related to user project management in the Berd desktop app + * Action: Events related to creating projects + */ +export function berdProjectCreateCompleted( + params: BerdProjectCreateCompletedParams, +): Event { + return { + name: "berd_project_create_completed", + parameters: { + has_working_dir: params.has_working_dir, + has_prompt: params.has_prompt, + }, + }; +} + +export interface BerdProjectEditCompletedParams { + /** Whether the completed project included a working directory. */ + has_working_dir: boolean; + /** Whether the completed project included configured instructions or prompt text. */ + has_prompt: boolean; +} + +/** + * BerdProject · Edit · Completed + * + * Tracks when the project edit flow completes. + * + * Feature: Events related to user project management in the Berd desktop app + * Action: Events related to editing projects + */ +export function berdProjectEditCompleted( + params: BerdProjectEditCompletedParams, +): Event { + return { + name: "berd_project_edit_completed", + parameters: { + has_working_dir: params.has_working_dir, + has_prompt: params.has_prompt, + }, + }; +} + +export interface BerdProjectDeleteCompletedParams { + /** Whether the deleted project had a working directory configured. */ + had_working_dir: boolean; + /** Whether the deleted project had an associated generated artifact. */ + had_artifact: boolean; +} + +/** + * BerdProject · Delete · Completed + * + * Tracks when the project deletion flow completes. + * + * Feature: Events related to user project management in the Berd desktop app + * Action: Events related to deleting projects + */ +export function berdProjectDeleteCompleted( + params: BerdProjectDeleteCompletedParams, +): Event { + return { + name: "berd_project_delete_completed", + parameters: { + had_working_dir: params.had_working_dir, + had_artifact: params.had_artifact, + }, + }; +} diff --git a/src/shared/telemetry/events/event.ts b/src/shared/telemetry/events/event.ts new file mode 100644 index 000000000..41564a334 --- /dev/null +++ b/src/shared/telemetry/events/event.ts @@ -0,0 +1,18 @@ +// Vendored typed telemetry event modules. Originally generated from +// squareup/message-schemas; the generator is not part of this repo, so these +// are ordinary source now — edit by hand and keep event/param names aligned +// with the schema repo. + +/** + * Neutral telemetry event envelope returned by the event factories. + * + * Mirrors the minimal { name, parameters } shape the telemetry client hands to + * its transport, so this repo carries no dependency on + * `@squareup/message-schemas-web`. + */ +export interface Event { + /** snake_case Unified Eventing event name (e.g. "berd_app_lifecycle_launched"). */ + name: string; + /** Event parameters keyed by their snake_case schema names. */ + parameters: Record; +} diff --git a/src/shared/telemetry/events/events.test.ts b/src/shared/telemetry/events/events.test.ts new file mode 100644 index 000000000..806faf2c2 --- /dev/null +++ b/src/shared/telemetry/events/events.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import * as events from "."; +import { + berdAgentCreateCompleted, + berdAgentDeleteCompleted, + berdAgentEditCompleted, +} from "./berd_agent"; +import { berdAppLifecycleLaunched } from "./berd_app"; +import { berdChatMessageSent, berdChatSessionStarted } from "./berd_chat"; +import { berdHomePinPinned, berdHomeUnpinUnpinned } from "./berd_home"; +import { + berdProjectCreateCompleted, + berdProjectDeleteCompleted, + berdProjectEditCompleted, +} from "./berd_project"; + +// The vendored set is a curated subset of the schema repo (see ./index.ts): the +// port excluded every *Initiated* variant, so a factory for one has no call +// site by construction. `berdAppFeedbackInitiated` survived the port as the +// lone exception and sat dead until it was removed; this pins that re-vendoring +// one is a deliberate decision, not a silent drift back toward the schema repo. +describe("vendored event surface", () => { + it("exposes no Initiated factories", () => { + expect(Object.keys(events).filter((n) => n.endsWith("Initiated"))).toEqual( + [], + ); + }); + + // Its Submitted counterpart was retired for a different reason: dropping + // `user_id` from the wire left it carrying nothing, and a bare counter is + // already implied by the resource-level install identity. Same pin, so + // re-vendoring it is a decision someone makes on purpose. + it("exposes no factory for the retired feedback event", () => { + expect(Object.keys(events)).not.toContain("berdAppFeedbackSubmitted"); + }); +}); + +// The entity-id attributes left the wire the same way `user_id` did: the +// gateway's strict schema models no `agent_id` (the persona's on-disk path — +// the agent's name plus the OS username), no `project_id` (a slug of the +// project's name), and no `item_id` (a path or slug for three of its five +// kinds), so a factory that reintroduces one produces a 400, not an extra +// column. The chat events' `session_id` is the deliberate exception — an +// opaque backend/draft token, kept as the one per-entity join key. +describe("removed entity-id params", () => { + it("puts no id on the agent events, leaving delete a bare counter", () => { + const created = berdAgentCreateCompleted({ + provider: "goose", + model: "goose-claude-4-5-sonnet", + }); + const edited = berdAgentEditCompleted({ provider: "goose" }); + + expect(created.parameters).not.toHaveProperty("agent_id"); + expect(edited.parameters).not.toHaveProperty("agent_id"); + expect(berdAgentDeleteCompleted().parameters).toEqual({}); + }); + + it("puts no id on the project events", () => { + const created = berdProjectCreateCompleted({ + has_working_dir: true, + has_prompt: false, + }); + const edited = berdProjectEditCompleted({ + has_working_dir: false, + has_prompt: true, + }); + const deleted = berdProjectDeleteCompleted({ + had_working_dir: true, + had_artifact: true, + }); + + for (const ev of [created, edited, deleted]) { + expect(ev.parameters).not.toHaveProperty("project_id"); + } + }); + + it("puts no id on the pin events", () => { + const pinned = berdHomePinPinned({ item_type: "HOME_ITEM_TYPE_AGENT" }); + const unpinned = berdHomeUnpinUnpinned({ + item_type: "HOME_ITEM_TYPE_CHAT", + }); + + expect(pinned.parameters).toEqual({ item_type: "HOME_ITEM_TYPE_AGENT" }); + expect(unpinned.parameters).toEqual({ item_type: "HOME_ITEM_TYPE_CHAT" }); + }); +}); + +// Absent optional params must be omitted from the parameters object entirely. +// sdk-logs accepts `undefined` attribute values and the OTLP transformer +// serializes them as the empty `{"value": {}}` encoding, so an always-set +// `provider: params.provider` would put keys without values on the wire — the +// ingestion gateway's allowlist expects the keys only when they carry one. +describe("optional event params", () => { + // `environment` is optional for one reason only: `BerdAppEnvironment` has no + // member for a development build, so the client hands over nothing there + // rather than coercing one of the two wire values (see + // `launchEventEnvironment` in ../client.ts). + it("carries or omits environment on berd_app_lifecycle_launched", () => { + const staging = berdAppLifecycleLaunched({ + app_version: "1.2.3", + environment: "staging", + }); + const development = berdAppLifecycleLaunched({ + app_version: "1.2.3", + environment: undefined, + }); + + expect(staging.parameters).toEqual({ + app_version: "1.2.3", + environment: "staging", + }); + expect("environment" in development.parameters).toBe(false); + }); + + it("omits absent provider/model from berd_chat_session_started", () => { + const ev = berdChatSessionStarted({ + session_id: "session-1", + source_surface: "CHAT_SOURCE_SURFACE_MAIN_CHAT", + has_project: false, + has_persona: false, + }); + + expect("provider" in ev.parameters).toBe(false); + expect("model" in ev.parameters).toBe(false); + }); + + it("omits absent provider/model from berd_chat_message_sent", () => { + const ev = berdChatMessageSent({ + session_id: "session-1", + is_first_message: true, + has_attachments: false, + has_persona: false, + }); + + expect("provider" in ev.parameters).toBe(false); + expect("model" in ev.parameters).toBe(false); + }); + + it("omits absent provider/model from berd_agent create/edit", () => { + const created = berdAgentCreateCompleted({}); + const edited = berdAgentEditCompleted({ + model: undefined, + }); + + expect("provider" in created.parameters).toBe(false); + expect("model" in created.parameters).toBe(false); + expect("provider" in edited.parameters).toBe(false); + expect("model" in edited.parameters).toBe(false); + }); + + it("keeps provider/model when they are present", () => { + const ev = berdChatMessageSent({ + session_id: "session-1", + is_first_message: false, + has_attachments: false, + has_persona: true, + provider: "goose", + model: "goose-claude-4-5-sonnet", + }); + + expect(ev.parameters.provider).toBe("goose"); + expect(ev.parameters.model).toBe("goose-claude-4-5-sonnet"); + }); +}); diff --git a/src/shared/telemetry/events/index.ts b/src/shared/telemetry/events/index.ts new file mode 100644 index 000000000..90c4619b4 --- /dev/null +++ b/src/shared/telemetry/events/index.ts @@ -0,0 +1,35 @@ +// Vendored typed telemetry event modules. Originally generated from +// squareup/message-schemas; the generator is not part of this repo, so these +// are ordinary source now — edit by hand and keep event/param names aligned +// with the schema repo. +// +// This is a curated subset of the schema repo's events, not a mirror of it: +// every *Initiated* variant is deliberately not vendored (Berd tracks the +// completed action, not the intent to start one), along with the other events +// the port excluded and `berd_app_feedback_submitted`, retired once dropping +// `user_id` from the wire left it with no attributes at all — a bare counter +// the resource-level install identity already implies. Alignment means the +// events that are here match the schema repo's names and params — not that +// every event the schema repo defines gets a factory. Vendor an event when a +// call site for it lands, not before. +// +// Events carry no user-generated content and no user-derived identifiers: +// `agent_id` (the persona's on-disk path — the agent's name plus the OS +// username), `project_id` (a slug of the project's name), and the pin events' +// `item_id` (paths/slugs for three of its five kinds) were all removed from +// the wire. What remains is booleans, closed enums, provider/model/app-version +// strings, and the chat events' `session_id` — an opaque backend/draft token +// kept deliberately as the one per-entity join key; the only other identity on +// the wire is the resource-level anonymous `installation.id`. +// +// Losing its id left `berd_agent_delete_completed` with no attributes at all. +// Unlike the feedback event it stays, as the precedent that an attribute-less +// event survives when its count is the signal: deletions against creations are +// net agent adoption per install, derivable from nothing else. + +export type { Event } from "./event"; +export * from "./berd_agent"; +export * from "./berd_app"; +export * from "./berd_chat"; +export * from "./berd_home"; +export * from "./berd_project"; diff --git a/src/shared/telemetry/exporter.test.ts b/src/shared/telemetry/exporter.test.ts new file mode 100644 index 000000000..91b8ea1b5 --- /dev/null +++ b/src/shared/telemetry/exporter.test.ts @@ -0,0 +1,479 @@ +import { ExportResultCode, type ExportResult } from "@opentelemetry/core"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + LoggerProvider, + type ReadableLogRecord, + SimpleLogRecordProcessor, +} from "@opentelemetry/sdk-logs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +// The sizing the batch/attribute ceiling is built from lives with the pipeline +// config in `./client`; importing it here keeps this file measuring the real +// numbers instead of a copy that could drift away from them. +import { + GATEWAY_BODY_LIMIT_BYTES, + MAX_LOG_ATTRIBUTE_VALUE_LENGTH, + MAX_LOG_EXPORT_BATCH_SIZE, +} from "./client"; + +const invoke = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +// Default endpoint baked into the exporter — the full gateway-shaped +// `https:///v1/logs` URL on the DUMMY placeholder host. Real hosts +// (staging today, production once decided) are injected by vite.config.ts +// via VITE_OTLP_LOGS_ENDPOINT, which is unset in tests so the fallback is +// exercised. This pin is one of the four sites that change together on a +// real-host swap. +const DUMMY_OTLP_ENDPOINT = + "https://otlp.invalid.goose-internal.example/v1/logs"; + +interface OtlpAttribute { + key: string; + value: { stringValue?: string }; +} + +interface OtlpLogsBody { + resourceLogs: Array<{ + resource: { attributes: OtlpAttribute[] }; + scopeLogs: Array<{ + scope: { name: string }; + logRecords: Array<{ + eventName: string; + timeUnixNano?: string; + attributes: OtlpAttribute[]; + }>; + }>; + }>; +} + +function attrValue( + attributes: OtlpAttribute[], + key: string, +): string | undefined { + return attributes.find((attr) => attr.key === key)?.value.stringValue; +} + +interface CaptureOptions { + eventName?: string; + attributes?: Record; + count?: number; + /** + * Further emissions from the *same* provider. The serializer groups records + * by their resource, so records that must land in one `resourceLogs` entry + * have to come from one provider. + */ + also?: Array<{ + eventName: string; + attributes: Record; + }>; +} + +/** + * Emits log records through a provider configured exactly like `./client` — + * same resource shape, same `logRecordLimits` — and captures the resulting + * `ReadableLogRecord`s, so the exporter can be exercised against genuine SDK + * output rather than a hand-built record. + */ +async function captureRecords({ + eventName = "berd_app_lifecycle_launched", + attributes = { + app_version: "1.2.3", + environment: "production", + }, + count = 1, + also = [], +}: CaptureOptions = {}): Promise { + const captured: ReadableLogRecord[] = []; + const capture = { + export(logs: ReadableLogRecord[], cb: (result: ExportResult) => void) { + captured.push(...logs); + cb({ code: ExportResultCode.SUCCESS }); + }, + shutdown: () => Promise.resolve(), + forceFlush: () => Promise.resolve(), + }; + const provider = new LoggerProvider({ + resource: resourceFromAttributes({ + "service.name": "berd", + "service.version": "1.2.3", + "deployment.environment": "production", + "installation.id": "11111111-2222-4333-8444-555555555555", + "distribution.channel": "public", + }), + logRecordLimits: { + attributeValueLengthLimit: MAX_LOG_ATTRIBUTE_VALUE_LENGTH, + }, + processors: [new SimpleLogRecordProcessor({ exporter: capture })], + }); + const logger = provider.getLogger("berd.telemetry"); + for (let i = 0; i < count; i += 1) { + logger.emit({ + eventName, + attributes, + timestamp: new Date("2026-06-29T00:00:00.000Z"), + }); + } + for (const emission of also) { + // No `timestamp`: the SDK stamps one, which is what the live path does for + // every event that is not flushed from the pre-identity buffer. + logger.emit(emission); + } + await provider.forceFlush(); + return captured; +} + +function exportOnce( + exporter: { + export: (logs: ReadableLogRecord[], cb: (r: ExportResult) => void) => void; + }, + logs: ReadableLogRecord[], +): Promise { + return new Promise((resolve) => exporter.export(logs, resolve)); +} + +async function loadExporter() { + vi.resetModules(); + return await import("./exporter"); +} + +beforeEach(() => { + invoke.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("TauriOtlpLogExporter", () => { + it("serializes records to OTLP/HTTP JSON and POSTs them via export_otel_logs", async () => { + invoke.mockResolvedValue({ status: 200, statusText: "OK", body: "{}" }); + const records = await captureRecords(); + + const { TauriOtlpLogExporter } = await loadExporter(); + const result = await exportOnce(new TauriOtlpLogExporter(), records); + + expect(result.code).toBe(ExportResultCode.SUCCESS); + expect(invoke).toHaveBeenCalledTimes(1); + const [command, args] = invoke.mock.calls[0]; + expect(command).toBe("export_otel_logs"); + expect((args as { endpoint: string }).endpoint).toBe(DUMMY_OTLP_ENDPOINT); + + const body = JSON.parse((args as { body: string }).body) as OtlpLogsBody; + const resourceLog = body.resourceLogs[0]; + expect(attrValue(resourceLog.resource.attributes, "service.name")).toBe( + "berd", + ); + expect(attrValue(resourceLog.resource.attributes, "service.version")).toBe( + "1.2.3", + ); + expect( + attrValue(resourceLog.resource.attributes, "deployment.environment"), + ).toBe("production"); + expect( + attrValue(resourceLog.resource.attributes, "distribution.channel"), + ).toBe("public"); + + const scopeLog = resourceLog.scopeLogs[0]; + expect(scopeLog.scope.name).toBe("berd.telemetry"); + + const logRecord = scopeLog.logRecords[0]; + expect(logRecord.eventName).toBe("berd_app_lifecycle_launched"); + // Backdated timestamp survives serialization (2026-06-29T00:00:00Z in ns). + expect(logRecord.timeUnixNano).toBe("1782691200000000000"); + expect(attrValue(logRecord.attributes, "app_version")).toBe("1.2.3"); + expect(attrValue(logRecord.attributes, "environment")).toBe("production"); + }); + + it("does not include renderer page context or local URLs in the OTLP body", async () => { + invoke.mockResolvedValue({ status: 200, statusText: "OK", body: "{}" }); + const records = await captureRecords(); + + const { TauriOtlpLogExporter } = await loadExporter(); + await exportOnce(new TauriOtlpLogExporter(), records); + + const { body } = invoke.mock.calls[0][1] as { body: string }; + expect(body).not.toContain("localhost"); + expect(body).not.toContain("/renderer"); + expect(body).not.toContain("referrer"); + }); + + it("reports FAILED on a non-2xx response (the processor drops the batch)", async () => { + invoke.mockResolvedValue({ + status: 503, + statusText: "Service Unavailable", + body: "", + }); + const records = await captureRecords(); + + const { TauriOtlpLogExporter } = await loadExporter(); + const result = await exportOnce(new TauriOtlpLogExporter(), records); + + expect(result.code).toBe(ExportResultCode.FAILED); + expect(result.error?.message).toContain("503"); + }); + + it("reports FAILED when the native command throws", async () => { + invoke.mockRejectedValue(new Error("native transport down")); + const records = await captureRecords(); + + const { TauriOtlpLogExporter } = await loadExporter(); + const result = await exportOnce(new TauriOtlpLogExporter(), records); + + expect(result.code).toBe(ExportResultCode.FAILED); + expect(result.error?.message).toContain("native transport down"); + }); +}); + +/** + * The ingestion gateway validates every upload against the `berd-otlp-logs-v1` + * body schema, which is strict/closed at every level: exactly one + * `resourceLogs` entry, exactly one `scopeLogs` entry, an exact key set on + * every object, a closed set of resource attributes, and only string/bool + * attribute values. Any unmodeled key anywhere is a 400 — nothing is stripped — + * and a rejected batch is dropped by the processor rather than retried. The + * schema models no `user_id` and no entity ids (`agent_id`, `project_id`, + * `item_id`) anywhere — strictness makes a re-introduced one a rejection, not + * a stray extra column. The chat events' `session_id` is the one per-entity id + * the schema still models. + * + * What the serializer emits is therefore pinned structurally rather than + * spot-checked: the realistic ways this breaks are silent ones an SDK upgrade + * or a config edit introduces — default resource attributes (`telemetry.sdk.*`, + * `process.*`, `host.*`) merged into the resource, a `schemaUrl` or scope + * `version` appearing, a `severityNumber`/`flags` key on the record, or + * `timeUnixNano` switching from a JSON string to a number. + */ +describe("berd-otlp-logs-v1 body contract", () => { + // The gateway's whole accepted resource-attribute set. `service.name` must be + // the literal `berd` (it, and the scope, were renamed from `goose-internal` + // before any client shipped), and `distribution.channel` must carry one of + // the closed values the schema allowlists. + const RESOURCE_ATTRIBUTE_KEYS = [ + "service.name", + "service.version", + "deployment.environment", + "installation.id", + "distribution.channel", + ]; + const LOG_RECORD_KEYS = [ + "timeUnixNano", + "observedTimeUnixNano", + "body", + "eventName", + "attributes", + "droppedAttributesCount", + ]; + const MAX_LOG_RECORDS_PER_REQUEST = 128; + + function expectExactKeys(value: unknown, keys: string[]): void { + expect(Object.keys(value as object).sort()).toEqual([...keys].sort()); + } + + /** Asserts one `{key, value}` entry uses an encoding the gateway models. */ + function expectAttributeEntry(entry: unknown): void { + expectExactKeys(entry, ["key", "value"]); + const { value } = entry as { value: Record }; + // `intValue`, `doubleValue`, `arrayValue` and the empty `{}` encoding OTLP + // uses for an absent value are all rejected. + const encodings = Object.keys(value); + expect(encodings).toHaveLength(1); + expect(["stringValue", "boolValue"]).toContain(encodings[0]); + if (encodings[0] === "stringValue") { + expect(typeof value.stringValue).toBe("string"); + expect(String(value.stringValue).length).toBeLessThanOrEqual(256); + } else { + expect(typeof value.boolValue).toBe("boolean"); + } + } + + function expectAttributeList(attributes: unknown): void { + const entries = attributes as Array<{ key: string }>; + for (const entry of entries) expectAttributeEntry(entry); + const keys = entries.map((entry) => entry.key); + expect(new Set(keys).size).toBe(keys.length); + } + + function expectConformingBody(raw: string): void { + const body = JSON.parse(raw) as Record; + expectExactKeys(body, ["resourceLogs"]); + const resourceLogs = body.resourceLogs as unknown[]; + expect(resourceLogs).toHaveLength(1); + + // No `schemaUrl` at either level — the SDK omits it only while no schema + // url is configured on the resource or the logger. + expectExactKeys(resourceLogs[0], ["resource", "scopeLogs"]); + const { resource, scopeLogs } = resourceLogs[0] as { + resource: Record; + scopeLogs: unknown[]; + }; + + expectExactKeys(resource, ["attributes", "droppedAttributesCount"]); + expect(resource.droppedAttributesCount).toBe(0); + const resourceAttributes = resource.attributes as OtlpAttribute[]; + expectAttributeList(resourceAttributes); + for (const { key } of resourceAttributes) { + expect(RESOURCE_ATTRIBUTE_KEYS).toContain(key); + } + expect(attrValue(resourceAttributes, "service.name")).toBe("berd"); + // Required by the schema, and only from its closed value set. + expect(["public", "internal"]).toContain( + attrValue(resourceAttributes, "distribution.channel"), + ); + + expect(scopeLogs).toHaveLength(1); + expectExactKeys(scopeLogs[0], ["scope", "logRecords"]); + const { scope, logRecords } = scopeLogs[0] as { + scope: unknown; + logRecords: unknown[]; + }; + // A `version` key here is a 400, so the scope is pinned whole rather than + // by its name alone. + expect(scope).toEqual({ name: "berd.telemetry" }); + + expect(logRecords.length).toBeGreaterThanOrEqual(1); + expect(logRecords.length).toBeLessThanOrEqual(MAX_LOG_RECORDS_PER_REQUEST); + for (const record of logRecords) { + // Exact key set, so `severityNumber`, `severityText`, `traceId`, + // `spanId` and `flags` are pinned absent by construction. + expectExactKeys(record, LOG_RECORD_KEYS); + const log = record as Record; + for (const key of ["timeUnixNano", "observedTimeUnixNano"]) { + // JSON strings, not numbers. + expect(typeof log[key]).toBe("string"); + expect(log[key]).toMatch(/^\d{1,20}$/); + } + expect(log.body).toEqual({}); + expect(typeof log.eventName).toBe("string"); + expect(log.droppedAttributesCount).toBe(0); + expectAttributeList(log.attributes); + } + } + + async function exportedBody(records: ReadableLogRecord[]): Promise { + invoke.mockResolvedValue({ status: 200, statusText: "OK", body: "{}" }); + const { TauriOtlpLogExporter } = await loadExporter(); + await exportOnce(new TauriOtlpLogExporter(), records); + return (invoke.mock.calls[0][1] as { body: string }).body; + } + + it("serializes a mixed batch into a body the gateway's schema accepts", async () => { + const records = await captureRecords({ + // A backdated buffer flush, string and bool params, and an over-long + // value that truncation has to bring under the 256-character ceiling. + eventName: "berd_chat_message_sent", + attributes: { + session_id: "11111111-2222-4333-8444-555555555555", + is_first_message: true, + has_attachments: false, + has_persona: true, + model: "m".repeat(10_000), + }, + also: [ + { + eventName: "berd_home_pin_pinned", + attributes: { + item_type: "HOME_ITEM_TYPE_CHAT", + }, + }, + { + eventName: "berd_project_delete_completed", + attributes: { + had_working_dir: true, + had_artifact: false, + }, + }, + ], + }); + + expectConformingBody(await exportedBody(records)); + }); + + it("keeps a full batch conforming, including its record count", async () => { + const records = await captureRecords({ + count: MAX_LOG_EXPORT_BATCH_SIZE, + attributes: { + app_version: "1.2.3", + environment: "production", + }, + }); + + // The schema caps a request at 128 records, so the processor's batch size + // is what keeps a full export inside it. + expect(MAX_LOG_EXPORT_BATCH_SIZE).toBeLessThanOrEqual( + MAX_LOG_RECORDS_PER_REQUEST, + ); + expectConformingBody(await exportedBody(records)); + }); + + it("truncates to a length the schema accepts", () => { + // Truncation is what keeps an over-long value inside the schema's + // per-value ceiling as well as under the body limit. + expect(MAX_LOG_ATTRIBUTE_VALUE_LENGTH).toBeLessThanOrEqual(256); + }); +}); + +/** + * The gateway 413s an oversized body and the processor drops the rejected + * batch, so the serialized body has to stay under the limit by construction. + * These pin the two halves of that: the per-value truncation `./client` + * configures, and the resulting size of a worst-case full batch. + */ +describe("OTLP body size ceiling", () => { + const maxed = "x".repeat(MAX_LOG_ATTRIBUTE_VALUE_LENGTH); + + async function exportBody(records: ReadableLogRecord[]): Promise { + invoke.mockResolvedValue({ status: 200, statusText: "OK", body: "{}" }); + const { TauriOtlpLogExporter } = await loadExporter(); + await exportOnce(new TauriOtlpLogExporter(), records); + return (invoke.mock.calls[0][1] as { body: string }).body; + } + + it("truncates an over-long attribute value and leaves real values intact", async () => { + // A user-typed BYO-key model id is the realistic way one record blows past + // the limit; without truncation a single paste is worth ~10 KB per record. + const records = await captureRecords({ + eventName: "berd_chat_message_sent", + attributes: { + session_id: "11111111-2222-4333-8444-555555555555", + model: "m".repeat(10_000), + }, + }); + + const body = JSON.parse(await exportBody(records)) as OtlpLogsBody; + const attributes = + body.resourceLogs[0].scopeLogs[0].logRecords[0].attributes; + expect(attrValue(attributes, "model")).toHaveLength( + MAX_LOG_ATTRIBUTE_VALUE_LENGTH, + ); + expect(attrValue(attributes, "session_id")).toBe( + "11111111-2222-4333-8444-555555555555", + ); + }); + + it("keeps a full batch of maxed-out records under the gateway body limit", async () => { + // The widest event we send, with every string attribute at the truncation + // limit: the enforced ceiling, not a realistic payload (a real batch of + // these records is ~103 KiB). + const records = await captureRecords({ + count: MAX_LOG_EXPORT_BATCH_SIZE, + eventName: "berd_chat_session_started", + attributes: { + session_id: maxed, + source_surface: maxed, + provider: maxed, + model: maxed, + has_project: true, + has_persona: true, + }, + }); + expect(records).toHaveLength(MAX_LOG_EXPORT_BATCH_SIZE); + + const body = await exportBody(records); + expect(new TextEncoder().encode(body).byteLength).toBeLessThan( + GATEWAY_BODY_LIMIT_BYTES, + ); + }); +}); diff --git a/src/shared/telemetry/exporter.ts b/src/shared/telemetry/exporter.ts new file mode 100644 index 000000000..d0517715e --- /dev/null +++ b/src/shared/telemetry/exporter.ts @@ -0,0 +1,109 @@ +/** + * Native OTLP log-record exporter for Berd telemetry. + * + * Replaces the former `@squareup/cdp` dispatch + `globalThis.fetch` monkeypatch. + * Serializes OTel `ReadableLogRecord`s to OTLP/HTTP JSON and POSTs them through + * the native `export_otel_logs` Tauri command, so OTLP delivery dodges WebView + * CORS without intercepting `fetch`. The `BatchLogRecordProcessor` that wraps + * this exporter owns batching only (see `./client`): a FAILED export drops the + * batch — the processor never re-queues it. The one retry in the pipeline is + * native: `export_otel_logs` retries the same body exactly once after + * re-bootstrapping its upload token on a 401. That drop-on-failure semantic is + * why `./client` caps batch size and attribute-value length: a body over the + * gateway's limit comes back 413 and is lost outright, so a full batch is sized + * to stay under it (`MAX_LOG_EXPORT_BATCH_SIZE`). + */ + +import { invoke } from "@tauri-apps/api/core"; +import { ExportResultCode, type ExportResult } from "@opentelemetry/core"; +import type { + LogRecordExporter, + ReadableLogRecord, +} from "@opentelemetry/sdk-logs"; +import { JsonLogsSerializer } from "@opentelemetry/otlp-transformer"; + +// Injected by vite.config.ts from VITE_OTLP_LOGS_ENDPOINT: the full telemetry +// gateway `https:///v1/logs` URL. Production builds get the production +// gateway (otel.berd.xyz), staging builds the staging gateway +// (otel.test.blockstaging.build); development gets an obviously-fake DUMMY +// host, so the prod/staging gate (see `./client`) plus that placeholder keep +// the path inert in dev and external clones. The native side derives the +// anonymous `/v1/bootstrap` URL from this same endpoint, so no bootstrap URL +// is plumbed here. The fallback below is only reachable where the vite define +// is absent (vitest). +const OTLP_LOGS_ENDPOINT = + import.meta.env.VITE_OTLP_LOGS_ENDPOINT ?? + "https://otlp.invalid.goose-internal.example/v1/logs"; + +interface NativeOtelLogsExportResponse { + status: number; + statusText: string; + body: string; +} + +/** + * An OTel `LogRecordExporter` that serializes records to OTLP/HTTP JSON and + * hands them to the native `export_otel_logs` command. Non-2xx responses and + * transport failures resolve to `FAILED`, which the processor treats as a + * dropped batch — there is no renderer-side retry. Auth happens natively: + * `export_otel_logs` bootstraps and caches a short-lived upload token keyed on + * the anonymous installation id, and on a 401 re-bootstraps and retries the + * same body exactly once. + */ +export class TauriOtlpLogExporter implements LogRecordExporter { + constructor(private readonly endpoint: string = OTLP_LOGS_ENDPOINT) {} + + export( + logs: ReadableLogRecord[], + resultCallback: (result: ExportResult) => void, + ): void { + let body: string; + try { + const serialized = JsonLogsSerializer.serializeRequest(logs); + if (!serialized) { + resultCallback({ code: ExportResultCode.SUCCESS }); + return; + } + body = new TextDecoder().decode(serialized); + } catch (error) { + resultCallback({ code: ExportResultCode.FAILED, error: error as Error }); + return; + } + + void invoke("export_otel_logs", { + endpoint: this.endpoint, + body, + }) + .then((response) => { + if (response.status >= 200 && response.status < 300) { + resultCallback({ code: ExportResultCode.SUCCESS }); + return; + } + resultCallback({ + code: ExportResultCode.FAILED, + error: new Error( + `OTLP logs export failed: ${response.status} ${response.statusText}`, + ), + }); + }) + .catch((error) => + resultCallback({ + code: ExportResultCode.FAILED, + error: error as Error, + }), + ); + } + + shutdown(): Promise { + return Promise.resolve(); + } + + forceFlush(): Promise { + return Promise.resolve(); + } +} + +/** Constructs the telemetry exporter using the build-injected OTLP endpoint. */ +export function createTelemetryLogExporter(): TauriOtlpLogExporter { + return new TauriOtlpLogExporter(); +} diff --git a/vite.config.ts b/vite.config.ts index 9997cb523..9db1a7fc8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,9 +33,50 @@ function resolveAppVersion(): string { return process.env.VITE_APP_VERSION?.trim() || packageJson.version; } +// Telemetry-gateway endpoints, in the shape the pipeline expects: the full +// `https:///v1/logs` URL. The native layer derives the anonymous +// `/v1/bootstrap` URL from this same endpoint, so only the logs URL is ever +// injected. An endpoint host lives at four sites that change together, with +// no code-path changes: this default (or VITE_OTLP_LOGS_ENDPOINT in the build +// env), ALLOWED_OTEL_LOGS_HOSTS in src-tauri/src/commands/telemetry.rs, +// tauri.conf.json's CSP connect-src, and the pinned test values. +// +// The production gateway (squareup/berd-monitoring's production deployment) — +// injected whenever VITE_ENVIRONMENT=production. +const PRODUCTION_OTLP_LOGS_ENDPOINT = "https://otel.berd.xyz/v1/logs"; + +// The staging gateway (squareup/berd-monitoring's staging deployment) — +// injected whenever VITE_ENVIRONMENT=staging. +const STAGING_OTLP_LOGS_ENDPOINT = + "https://otel.test.blockstaging.build/v1/logs"; + +// DUMMY placeholder for development. The telemetry send path (OTel logs over +// OTLP) is gated to production/staging, so this fake host is never contacted +// in dev or external clones. +const DUMMY_OTLP_LOGS_ENDPOINT = + "https://otlp.invalid.goose-internal.example/v1/logs"; + +function resolveOtlpLogsEndpoint(): string { + const explicit = process.env.VITE_OTLP_LOGS_ENDPOINT?.trim(); + if (explicit) { + return explicit; + } + switch (resolveBuildEnvironment()) { + case "production": + return PRODUCTION_OTLP_LOGS_ENDPOINT; + case "staging": + return STAGING_OTLP_LOGS_ENDPOINT; + case "development": + return DUMMY_OTLP_LOGS_ENDPOINT; + } +} + export default defineConfig(async ({ command }) => { const define: Record = { "import.meta.env.VITE_APP_VERSION": JSON.stringify(resolveAppVersion()), + "import.meta.env.VITE_OTLP_LOGS_ENDPOINT": JSON.stringify( + resolveOtlpLogsEndpoint(), + ), }; // Generic builds must stay telemetry-inert unless a release/staging path From 78274b60bf23ecae0567714b725d55734910aaad Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 18 Aug 2026 21:15:44 +1000 Subject: [PATCH 2/7] feat(onboarding): ship the first-run landing page wired to native telemetry consent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the first-run landing page from d6f97db28 onto the telemetry branch, replacing that commit's localStorage consent stub with this branch's Rust-owned consent setting so the onboarding flow and the Settings toggle drive the same opt-in pipeline. - The landing page asks for anonymous usage-data consent up front with the checkbox defaulted ON — the one consent surface that defaults to sharing — while every other path leaves the persisted setting at its opt-in default (OFF). Advancing persists the choice through updateTelemetryEnabled(); the write is fire-and-forget and fails closed, so a failed write can neither block onboarding nor enable sharing. - The source commit's consentPreference.ts and startup.ts are dropped: telemetry still initializes unconditionally at boot (events buffer through the consent gate and drop unless the setting loads as enabled), and the consent-store subscription brings the pipeline up the moment the welcome page or the Settings toggle grants consent. - The welcome checkbox follows the Settings row's visibility rules: hidden in enforced-telemetry builds and without the telemetry capability, and hiding writes nothing. - The "learn more" details become a shared UsageDataDialog (what we collect / what we don't), now also opened from a link on the Settings privacy row so both consent surfaces present the same story; its strings move to privacy.telemetry.usageDialog in the settings namespace. - The source commit's consent gating around trackFeedbackSubmitted is moot here — this branch already removed that event. - main.tsx keeps the per-window telemetry wiring pins but adopts the deferred boot: a loading view renders while get_installation_cohort classifies the install, established installs graduate straight past onboarding, and a cohort failure falls back to "unknown" without blocking boot. Carried over unchanged from the source commit: the get_installation_cohort command and versioned marker persistence, onboarding graduation and persisted-state validation, graduating the first-run-onboarding experiment to default behavior, localized dialog close labels, and the artifact camera distance scale. Validated with just check, just test, just tauri-check, just clippy, and the app crate's installation_cohort tests. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/installation.rs | 21 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 26 ++ src-tauri/src/services/app_data_migration.rs | 10 + src-tauri/src/services/installation_cohort.rs | 235 ++++++++++++++++ src-tauri/src/services/mod.rs | 1 + src/app/AppShell.berdctl.test.tsx | 2 + src/app/AppShell.navigation.test.tsx | 2 + src/app/AppShell.startupDiagnostics.test.tsx | 2 + src/app/AppShell.tsx | 9 +- .../experiments/ExperimentsSettings.tsx | 4 +- .../__tests__/ExperimentsSettings.test.tsx | 23 -- .../experiments/experimentDefinitions.ts | 14 - .../onboarding/api/installationCohort.test.ts | 25 ++ .../onboarding/api/installationCohort.ts | 23 ++ .../onboarding/model/onboardingStore.test.ts | 78 ++++++ .../onboarding/model/onboardingStore.ts | 126 +++++++-- .../onboarding/ui/OnboardingShell.tsx | 10 +- .../onboarding/ui/WelcomeStep.test.tsx | 177 ++++++++++++ src/features/onboarding/ui/WelcomeStep.tsx | 252 +++++++++++++----- .../artifact/ProjectArtifactPreview.tsx | 3 + .../artifact/ProjectArtifactRenderer.tsx | 14 +- src/features/projects/artifact/types.ts | 2 + .../settings/ui/TelemetryConsentRow.tsx | 18 +- src/features/settings/ui/UsageDataDialog.tsx | 59 ++++ .../ui/__tests__/TelemetryConsentRow.test.tsx | 24 ++ src/main.test.tsx | 29 +- src/main.tsx | 63 +++-- src/shared/i18n/locales/en/onboarding.json | 11 +- src/shared/i18n/locales/en/settings.json | 16 +- src/shared/i18n/locales/es/onboarding.json | 11 +- src/shared/i18n/locales/es/settings.json | 16 +- src/shared/ui/dialog.tsx | 19 +- 34 files changed, 1136 insertions(+), 192 deletions(-) create mode 100644 src-tauri/src/commands/installation.rs create mode 100644 src-tauri/src/services/installation_cohort.rs create mode 100644 src/features/onboarding/api/installationCohort.test.ts create mode 100644 src/features/onboarding/api/installationCohort.ts create mode 100644 src/features/onboarding/ui/WelcomeStep.test.tsx create mode 100644 src/features/settings/ui/UsageDataDialog.tsx diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 040d61aa6..3629546ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,7 @@ dunce = "1" doctor = { git = "https://github.com/block/builderbot", rev = "73ff9a0521dcc784c9514911a655187e5dd3b6ca" } etcetera = "0.11.0" flate2 = "1" +tempfile = "3" hex = "0.4" ignore = "0.4.25" fern = "0.7" @@ -162,7 +163,6 @@ block-voice-dictation = [] admin-runtime-config = [] [dev-dependencies] -tempfile = "3" # Mirrors goose's bare-name command resolver (crates/goose Cargo.toml): the # native Windows gate resolves the bridge launcher through the exact # `which_in_global` path goosed uses, so PATHEXT resolution is under test. diff --git a/src-tauri/src/commands/installation.rs b/src-tauri/src/commands/installation.rs new file mode 100644 index 000000000..13bd1f55b --- /dev/null +++ b/src-tauri/src/commands/installation.rs @@ -0,0 +1,21 @@ +use tauri::State; + +use crate::services::installation_cohort::{ + InstallationCohort, InstallationCohortReadiness, InstallationCohortState, +}; + +#[tauri::command] +pub async fn get_installation_cohort( + state: State<'_, InstallationCohortState>, +) -> Result { + let mut receiver = state.0.clone(); + loop { + let readiness = *receiver.borrow_and_update(); + if let InstallationCohortReadiness::Ready(cohort) = readiness { + return Ok(cohort); + } + if receiver.changed().await.is_err() { + return Ok(InstallationCohort::Unknown); + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 24f21ac40..a806cb4f4 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -25,6 +25,7 @@ pub mod git; pub mod git_changes; pub mod global_shortcut; pub mod home_widget_media; +pub mod installation; pub mod layout; pub mod local_mcp_inventory; pub mod message_queues; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23dcf66fa..bcb5ffee2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,31 @@ pub fn run() { app.manage(commands::telemetry::TelemetryAuthState::new( app_data_dir.clone(), )); + let (installation_cohort_sender, installation_cohort_state) = + services::installation_cohort::installation_cohort_channel(); + app.manage(installation_cohort_state); + let current_layout_exists = + services::installation_cohort::layout_database_exists(&app_data_dir); + let legacy_layout_exists = if app.try_state::().is_some() { + Ok(false) + } else { + services::app_data_migration::legacy_layout_database_exists(app.handle()) + }; + let installation_cohort = + services::installation_cohort::initialize_installation_cohort( + &app_data_dir, + current_layout_exists, + legacy_layout_exists, + ) + .unwrap_or_else(|error| { + log::warn!("Failed to initialize installation cohort: {error}"); + services::installation_cohort::InstallationCohort::Unknown + }); + installation_cohort_sender.send_replace( + services::installation_cohort::InstallationCohortReadiness::Ready( + installation_cohort, + ), + ); let release_channel_state = commands::updates::ReleaseChannelState::load(app.handle())?; app.manage(release_channel_state); @@ -521,6 +546,7 @@ pub fn run() { commands::git::git_create_worktree, commands::git::git_remove_worktree, commands::home_widget_media::import_home_widget_photo, + commands::installation::get_installation_cohort, commands::layout::get_layout, commands::layout::save_layout_items, commands::layout::save_layout_camera, diff --git a/src-tauri/src/services/app_data_migration.rs b/src-tauri/src/services/app_data_migration.rs index e19a354b2..863112319 100644 --- a/src-tauri/src/services/app_data_migration.rs +++ b/src-tauri/src/services/app_data_migration.rs @@ -74,6 +74,16 @@ struct AppDataMigrationSummary { /// Copy old app-owned local data before Berd services open files in the new /// location. Failures are logged and non-fatal so a single locked/cache file /// does not prevent app startup. +pub(crate) fn legacy_layout_database_exists( + app: &AppHandle, +) -> Result { + let pair = legacy_directory_pairs(app)? + .into_iter() + .find(|pair| pair.kind == AppDirectoryKind::Data) + .ok_or_else(|| "Legacy app data directory is unavailable".to_string())?; + crate::services::installation_cohort::file_exists(&pair.old.join(OLD_LAYOUT_DATABASE)) +} + pub(crate) fn migrate_legacy_app_data(app: &AppHandle) { if app .try_state::() diff --git a/src-tauri/src/services/installation_cohort.rs b/src-tauri/src/services/installation_cohort.rs new file mode 100644 index 000000000..68d84fa1a --- /dev/null +++ b/src-tauri/src/services/installation_cohort.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::Path; +use tokio::sync::watch; + +const MARKER_FILE_NAME: &str = "installation-cohort-v1.json"; +const MARKER_VERSION: u32 = 1; +const CURRENT_LAYOUT_DATABASE: &str = "berd.sqlite"; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstallationCohort { + FreshWithLandingV1, + EstablishedBeforeLandingV1, + Unknown, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InstallationCohortReadiness { + Initializing, + Ready(InstallationCohort), +} + +#[derive(Clone, Debug)] +pub struct InstallationCohortState(pub watch::Receiver); + +#[derive(Deserialize, Serialize)] +struct InstallationCohortRecord { + version: u32, + cohort: InstallationCohort, +} + +pub fn layout_database_exists(app_data_dir: &Path) -> Result { + file_exists(&app_data_dir.join(CURRENT_LAYOUT_DATABASE)) +} + +pub(crate) fn file_exists(path: &Path) -> Result { + match fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_file()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("Failed to inspect {}: {error}", path.display())), + } +} + +pub fn installation_cohort_channel() -> ( + watch::Sender, + InstallationCohortState, +) { + let (sender, receiver) = watch::channel(InstallationCohortReadiness::Initializing); + (sender, InstallationCohortState(receiver)) +} + +pub fn initialize_installation_cohort( + app_data_dir: &Path, + current_layout_exists: Result, + legacy_layout_exists: Result, +) -> Result { + fs::create_dir_all(app_data_dir) + .map_err(|error| format!("Failed to create app data directory: {error}"))?; + let marker_path = app_data_dir.join(MARKER_FILE_NAME); + + if marker_path.exists() { + let bytes = fs::read(&marker_path) + .map_err(|error| format!("Failed to read installation cohort marker: {error}"))?; + let Ok(record) = serde_json::from_slice::(&bytes) else { + return Ok(InstallationCohort::Unknown); + }; + if record.version != MARKER_VERSION || record.cohort == InstallationCohort::Unknown { + return Ok(InstallationCohort::Unknown); + } + return Ok(record.cohort); + } + + let current_layout_exists = current_layout_exists?; + let legacy_layout_exists = legacy_layout_exists?; + let cohort = if current_layout_exists || legacy_layout_exists { + InstallationCohort::EstablishedBeforeLandingV1 + } else { + InstallationCohort::FreshWithLandingV1 + }; + persist_marker(&marker_path, cohort) +} + +fn persist_marker(path: &Path, cohort: InstallationCohort) -> Result { + let record = InstallationCohortRecord { + version: MARKER_VERSION, + cohort, + }; + let parent = path + .parent() + .ok_or_else(|| "Installation cohort marker has no parent directory".to_string())?; + let bytes = serde_json::to_vec(&record) + .map_err(|error| format!("Failed to serialize installation cohort marker: {error}"))?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|error| format!("Failed to create installation cohort marker: {error}"))?; + temporary + .write_all(&bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|error| format!("Failed to write installation cohort marker: {error}"))?; + + match temporary.persist_noclobber(path) { + Ok(_) => { + if let Err(error) = sync_parent_directory(path) { + log::warn!( + "Installation cohort marker was published but directory sync failed: {error}" + ); + } + Ok(cohort) + } + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + read_published_cohort(path) + } + Err(error) => Err(format!( + "Failed to publish installation cohort marker: {}", + error.error + )), + } +} + +fn read_published_cohort(path: &Path) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("Failed to read published installation cohort marker: {error}"))?; + let record: InstallationCohortRecord = serde_json::from_slice(&bytes) + .map_err(|error| format!("Invalid published installation cohort marker: {error}"))?; + if record.version != MARKER_VERSION || record.cohort == InstallationCohort::Unknown { + return Err("Published installation cohort marker is unsupported".to_string()); + } + Ok(record.cohort) +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Installation cohort marker has no parent directory".to_string())?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("Failed to sync installation cohort directory: {error}")) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn classifies_and_persists_a_fresh_installation() { + let root = tempdir().unwrap(); + let first = initialize_installation_cohort(root.path(), Ok(false), Ok(false)).unwrap(); + assert_eq!(first, InstallationCohort::FreshWithLandingV1); + + fs::write(root.path().join(CURRENT_LAYOUT_DATABASE), b"later").unwrap(); + let second = initialize_installation_cohort(root.path(), Ok(false), Ok(false)).unwrap(); + assert_eq!(second, InstallationCohort::FreshWithLandingV1); + } + + #[test] + fn classifies_current_or_legacy_layouts_as_established() { + let current = tempdir().unwrap(); + fs::write(current.path().join(CURRENT_LAYOUT_DATABASE), b"existing").unwrap(); + assert_eq!( + initialize_installation_cohort(current.path(), Ok(true), Ok(false)).unwrap(), + InstallationCohort::EstablishedBeforeLandingV1 + ); + + let legacy = tempdir().unwrap(); + assert_eq!( + initialize_installation_cohort(legacy.path(), Ok(false), Ok(true)).unwrap(), + InstallationCohort::EstablishedBeforeLandingV1 + ); + } + + #[test] + fn concurrent_initializers_use_the_published_winner() { + use std::sync::{Arc, Barrier}; + + let root = tempdir().unwrap(); + let path = Arc::new(root.path().to_path_buf()); + let barrier = Arc::new(Barrier::new(3)); + let handles = [false, false].map(|legacy_exists| { + let path = Arc::clone(&path); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + initialize_installation_cohort(&path, Ok(false), Ok(legacy_exists)).unwrap() + }) + }); + barrier.wait(); + + let cohorts = handles.map(|handle| handle.join().unwrap()); + assert_eq!(cohorts[0], InstallationCohort::FreshWithLandingV1); + assert_eq!(cohorts[1], cohorts[0]); + assert_eq!( + initialize_installation_cohort(&path, Ok(false), Ok(false)).unwrap(), + cohorts[0] + ); + } + + #[test] + fn detection_failure_does_not_publish_a_fresh_marker() { + let root = tempdir().unwrap(); + assert!(initialize_installation_cohort( + root.path(), + Err("metadata unavailable".into()), + Ok(false), + ) + .is_err()); + assert!(!root.path().join(MARKER_FILE_NAME).exists()); + } + + #[test] + fn treats_an_unsupported_marker_as_unknown_and_preserves_it() { + let root = tempdir().unwrap(); + let marker = root.path().join(MARKER_FILE_NAME); + fs::write( + &marker, + br#"{"version":2,"cohort":"fresh-with-landing-v1"}"#, + ) + .unwrap(); + + assert_eq!( + initialize_installation_cohort(root.path(), Ok(false), Ok(false)).unwrap(), + InstallationCohort::Unknown + ); + assert!(String::from_utf8(fs::read(marker).unwrap()) + .unwrap() + .contains("\"version\":2")); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index b012ff115..3af3a2807 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -12,6 +12,7 @@ pub mod distro_bundle; pub(crate) mod e2e_mode; pub(crate) mod env_key; pub(crate) mod goose_config; +pub(crate) mod installation_cohort; #[cfg(target_os = "macos")] pub(crate) mod installer_media; #[cfg_attr( diff --git a/src/app/AppShell.berdctl.test.tsx b/src/app/AppShell.berdctl.test.tsx index bc3ce7782..f85f08521 100644 --- a/src/app/AppShell.berdctl.test.tsx +++ b/src/app/AppShell.berdctl.test.tsx @@ -11,6 +11,7 @@ import { gooseServeSelectionFromExecutionTarget } from "@/features/chat/lib/goos import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { dispatchOnboarding } from "@/features/onboarding/model"; import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { useDefaultProviderReadinessStore } from "@/features/providers/stores/defaultProviderReadinessStore"; @@ -253,6 +254,7 @@ describe("AppShell berdctl integration", () => { vi.stubEnv("VITE_AUTOMATIONS", "1"); window.history.replaceState(null, "", "/"); window.localStorage.clear(); + dispatchOnboarding({ type: "complete" }); useShortcutsDialogStore.setState({ open: false }); mockAcpCreateSession.mockReset(); mockAcpCreateSession.mockResolvedValue({ sessionId: "created-session" }); diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 89b72e591..2f64d8e68 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -29,6 +29,7 @@ import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry"; import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { dispatchOnboarding } from "@/features/onboarding/model"; import { resetHomeWidgetStoreForTests, useHomeWidgetStore, @@ -905,6 +906,7 @@ describe("AppShell global navigation", () => { afterEach(cleanup); beforeEach(() => { + dispatchOnboarding({ type: "complete" }); resetHomeWidgetStoreForTests(); resetStarterWidgetPickerRequestForTests(); mockRepairManagedGooseModelSelection.mockReset(); diff --git a/src/app/AppShell.startupDiagnostics.test.tsx b/src/app/AppShell.startupDiagnostics.test.tsx index 1fcc7e611..82b275dd1 100644 --- a/src/app/AppShell.startupDiagnostics.test.tsx +++ b/src/app/AppShell.startupDiagnostics.test.tsx @@ -6,6 +6,7 @@ import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { dispatchOnboarding } from "@/features/onboarding/model"; import { AppShell } from "./AppShell"; const mocks = vi.hoisted(() => ({ @@ -117,6 +118,7 @@ describe("AppShell startup diagnostics", () => { vi.clearAllMocks(); window.history.replaceState(null, "", "/"); window.localStorage.clear(); + dispatchOnboarding({ type: "complete" }); mocks.startupState.ready = true; mocks.startupState.error = null; mocks.migrationState.status = "ready"; diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 3b1c39db0..44092f832 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -220,7 +220,6 @@ import { isSystemNotification, } from "@/shared/types/messages"; import { isDesignSystemExplorerEnabled } from "@/features/design-system/lib/designSystemEnabled"; -import { FIRST_RUN_ONBOARDING_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { useExperiment } from "@/features/experiments/experimentPreferences"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import { useOnboardingState } from "@/features/onboarding/model"; @@ -772,9 +771,6 @@ export function AppShell({ const [agentsPersonaId, setAgentsPersonaId] = useState(null); const [globalComposerFocusRequest, setGlobalComposerFocusRequest] = useState(0); - const onboardingExperiment = useExperiment( - FIRST_RUN_ONBOARDING_EXPERIMENT_ID, - ); const onboardingState = useOnboardingState(); const omittedStarterTaskIds = useMemo>( () => @@ -4952,10 +4948,7 @@ export function AppShell({ ); } - const shouldShowOnboarding = - onboardingExperiment?.enabled === true && - onboardingState.lifecycle !== "completed"; - if (shouldShowOnboarding) { + if (onboardingState.lifecycle !== "completed") { return ; } diff --git a/src/features/experiments/ExperimentsSettings.tsx b/src/features/experiments/ExperimentsSettings.tsx index aa46eb17c..016791860 100644 --- a/src/features/experiments/ExperimentsSettings.tsx +++ b/src/features/experiments/ExperimentsSettings.tsx @@ -5,7 +5,6 @@ import { toast } from "sonner"; import { BERDY_ONBOARDING_EXPERIMENT_ID, EXPERIMENT_DEFINITIONS, - HIDDEN_EXPERIMENT_IDS, type ExperimentDefinition, } from "./experimentDefinitions"; import { ExperimentConfigControls } from "./ExperimentConfigControls"; @@ -62,8 +61,7 @@ export function ExperimentsSettings({ () => getVisibleExperimentRegistry(registry).filter( (definition) => - !HIDDEN_EXPERIMENT_IDS.has(definition.id) && - (definition.settingsVisibility !== "dev" || import.meta.env.DEV), + definition.settingsVisibility !== "dev" || import.meta.env.DEV, ), [registry], ); diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index faa6e98c4..c61632d66 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -7,7 +7,6 @@ import { BERDY_ONBOARDING_EXPERIMENT_ID, BUILDERBOT_SURFACE_EXPERIMENT_ID, EXPERIMENT_DEFINITIONS, - FIRST_RUN_ONBOARDING_EXPERIMENT_ID, SKILL_DISCOVERY_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, @@ -137,7 +136,6 @@ describe("ExperimentsSettings", () => { VOICE_CONVERSATION_EXPERIMENT_ID, AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, BERDY_ONBOARDING_EXPERIMENT_ID, - FIRST_RUN_ONBOARDING_EXPERIMENT_ID, ]); }); @@ -192,11 +190,6 @@ describe("ExperimentsSettings", () => { i18n.t("experiments.berdyOnboarding.title", { ns: "settings" }), ), ).not.toBeInTheDocument(); - expect( - screen.queryByText( - i18n.t("experiments.firstRunOnboarding.title", { ns: "settings" }), - ), - ).not.toBeInTheDocument(); expect( screen.queryByRole("region", { name: i18n.t("experiments.onboarding.title", { ns: "settings" }), @@ -222,22 +215,6 @@ describe("ExperimentsSettings", () => { ); }); - it("keeps first-run onboarding registered but hidden from settings", () => { - vi.stubEnv("DEV", true); - window.localStorage.setItem( - EXPERIMENT_PREFERENCES_STORAGE_KEY, - JSON.stringify({ - version: 2, - experiments: { - [FIRST_RUN_ONBOARDING_EXPERIMENT_ID]: { enabled: true }, - }, - }), - ); - renderWithProviders(); - - expect(screen.queryByText("First-run onboarding")).not.toBeInTheDocument(); - }); - it("resets Berdy onboarding from its experiment card", async () => { vi.stubEnv("DEV", true); const user = userEvent.setup(); diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index 640eb795c..cc8235dea 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -63,12 +63,6 @@ export const BERDY_ONBOARDING_EXPERIMENT_ID = "berdy-onboarding"; export const SKILL_DISCOVERY_EXPERIMENT_ID = "skill-discovery"; -export const FIRST_RUN_ONBOARDING_EXPERIMENT_ID = "first-run-onboarding"; - -export const HIDDEN_EXPERIMENT_IDS = new Set([ - FIRST_RUN_ONBOARDING_EXPERIMENT_ID, -]); - export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, @@ -117,12 +111,4 @@ export const EXPERIMENT_DEFINITIONS = [ descriptionKey: "experiments.berdyOnboarding.description", settingsVisibility: "dev", }, - { - id: FIRST_RUN_ONBOARDING_EXPERIMENT_ID, - titleKey: "experiments.firstRunOnboarding.title", - descriptionKey: "experiments.firstRunOnboarding.description", - defaultEnabled: false, - manualEnableOnly: true, - settingsVisibility: "dev", - }, ] as const satisfies readonly ExperimentDefinition[]; diff --git a/src/features/onboarding/api/installationCohort.test.ts b/src/features/onboarding/api/installationCohort.test.ts new file mode 100644 index 000000000..64e8e6b4f --- /dev/null +++ b/src/features/onboarding/api/installationCohort.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { + getInstallationCohort, + INSTALLATION_COHORT_TIMEOUT_MS, +} from "./installationCohort"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +describe("getInstallationCohort", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("rejects a stalled lookup after the deadline", async () => { + vi.useFakeTimers(); + vi.mocked(invoke).mockReturnValue(new Promise(() => {})); + const result = expect(getInstallationCohort()).rejects.toThrow("timed out"); + + await vi.advanceTimersByTimeAsync(INSTALLATION_COHORT_TIMEOUT_MS); + + await result; + }); +}); diff --git a/src/features/onboarding/api/installationCohort.ts b/src/features/onboarding/api/installationCohort.ts new file mode 100644 index 000000000..52f75a53d --- /dev/null +++ b/src/features/onboarding/api/installationCohort.ts @@ -0,0 +1,23 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { InstallationCohort } from "@/features/onboarding/model"; + +export const INSTALLATION_COHORT_TIMEOUT_MS = 5_000; + +export function getInstallationCohort(): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error("Installation cohort lookup timed out")), + INSTALLATION_COHORT_TIMEOUT_MS, + ); + void invoke("get_installation_cohort").then( + (cohort) => { + window.clearTimeout(timeout); + resolve(cohort); + }, + (error) => { + window.clearTimeout(timeout); + reject(error); + }, + ); + }); +} diff --git a/src/features/onboarding/model/onboardingStore.test.ts b/src/features/onboarding/model/onboardingStore.test.ts index 7005a4c1d..b6fbaa563 100644 --- a/src/features/onboarding/model/onboardingStore.test.ts +++ b/src/features/onboarding/model/onboardingStore.test.ts @@ -3,20 +3,98 @@ import { INITIAL_ONBOARDING_STATE } from "./onboardingState"; import { dispatchOnboarding, getOnboardingSnapshot, + initializeOnboardingGraduation, ONBOARDING_STORAGE_KEY, ONBOARDING_STORAGE_VERSION, replayOnboarding, resetOnboarding, resetOnboardingStoreForTests, + setOnboardingStorageForTests, subscribeToOnboarding, } from "./onboardingStore"; describe("onboarding persistence", () => { beforeEach(() => { + setOnboardingStorageForTests(undefined); window.localStorage.clear(); resetOnboardingStoreForTests(); }); + it("keeps onboarding pending for a fresh installation", () => { + initializeOnboardingGraduation("fresh-with-landing-v1"); + resetOnboardingStoreForTests(); + + expect(getOnboardingSnapshot()).toEqual(INITIAL_ONBOARDING_STATE); + }); + + it("marks an established installation complete during graduation", () => { + initializeOnboardingGraduation("established-before-landing-v1"); + resetOnboardingStoreForTests(); + + expect(getOnboardingSnapshot()).toMatchObject({ + lifecycle: "completed", + step: "complete", + }); + }); + + it("graduates established installations when storage is unavailable or fails", () => { + setOnboardingStorageForTests(null); + initializeOnboardingGraduation("established-before-landing-v1"); + expect(getOnboardingSnapshot().lifecycle).toBe("completed"); + + setOnboardingStorageForTests({ + getItem: () => { + throw new Error("unavailable"); + }, + } as unknown as Storage); + initializeOnboardingGraduation("established-before-landing-v1"); + expect(getOnboardingSnapshot().lifecycle).toBe("completed"); + + setOnboardingStorageForTests({ + getItem: () => null, + setItem: () => { + throw new Error("full"); + }, + } as unknown as Storage); + initializeOnboardingGraduation("established-before-landing-v1"); + expect(getOnboardingSnapshot().lifecycle).toBe("completed"); + }); + + it("graduates malformed current state but preserves a newer record", () => { + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, "not json"); + initializeOnboardingGraduation("established-before-landing-v1"); + resetOnboardingStoreForTests(); + expect(getOnboardingSnapshot().lifecycle).toBe("completed"); + + const newerRecord = JSON.stringify({ + version: ONBOARDING_STORAGE_VERSION + 1, + state: { future: true }, + }); + window.localStorage.setItem(ONBOARDING_STORAGE_KEY, newerRecord); + initializeOnboardingGraduation("established-before-landing-v1"); + expect(window.localStorage.getItem(ONBOARDING_STORAGE_KEY)).toBe( + newerRecord, + ); + }); + + it("does not graduate when the cohort is unknown", () => { + initializeOnboardingGraduation("unknown"); + resetOnboardingStoreForTests(); + expect(getOnboardingSnapshot()).toEqual(INITIAL_ONBOARDING_STATE); + }); + + it("preserves existing onboarding progress during graduation", () => { + dispatchOnboarding({ type: "start" }); + + initializeOnboardingGraduation("fresh-with-landing-v1"); + resetOnboardingStoreForTests(); + + expect(getOnboardingSnapshot()).toMatchObject({ + lifecycle: "in-progress", + step: "welcome", + }); + }); + it("persists versioned state and hydrates it on a new lifecycle", () => { dispatchOnboarding({ type: "start" }); dispatchOnboarding({ type: "select-agent", agentId: "builder" }); diff --git a/src/features/onboarding/model/onboardingStore.ts b/src/features/onboarding/model/onboardingStore.ts index cb01cb2d5..bd0edc579 100644 --- a/src/features/onboarding/model/onboardingStore.ts +++ b/src/features/onboarding/model/onboardingStore.ts @@ -17,6 +17,11 @@ import { export const ONBOARDING_STORAGE_VERSION = 1; export const ONBOARDING_STORAGE_KEY = "berd:onboarding:v1"; +export type InstallationCohort = + | "fresh-with-landing-v1" + | "established-before-landing-v1" + | "unknown"; + interface PersistedOnboardingState { version: typeof ONBOARDING_STORAGE_VERSION; state: OnboardingState; @@ -29,12 +34,14 @@ let storageListening = false; let snapshot: OnboardingState = { ...INITIAL_ONBOARDING_STATE }; let hydrated = false; let hasUnsupportedNewerRecord = false; +let storageOverrideForTests: Storage | null | undefined; const workTypeIds = new Set(WORK_TYPE_IDS); const agentIds = new Set(RECOMMENDED_AGENTS.map((agent) => agent.id)); const harnessIds = new Set(CURATED_HARNESS_IDS); function storage(): Storage | null { + if (storageOverrideForTests !== undefined) return storageOverrideForTests; try { return typeof window === "undefined" ? null : window.localStorage; } catch { @@ -42,6 +49,67 @@ function storage(): Storage | null { } } +/** Preserve authored onboarding state while graduating established installs. */ +export function initializeOnboardingGraduation( + cohort: InstallationCohort, +): void { + const completedState: OnboardingState = { + ...INITIAL_ONBOARDING_STATE, + lifecycle: "completed", + step: "complete", + }; + const target = storage(); + if (!target) { + if (cohort === "established-before-landing-v1") { + snapshot = completedState; + hydrated = true; + emit(); + } + return; + } + + try { + const raw = target.getItem(ONBOARDING_STORAGE_KEY); + const preserveExisting = (() => { + if (raw === null) return false; + try { + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object") return false; + const record = value as Partial; + return ( + (typeof record.version === "number" && + record.version > ONBOARDING_STORAGE_VERSION) || + (record.version === ONBOARDING_STORAGE_VERSION && + isValidPersistedState( + record.state as Partial | undefined, + )) + ); + } catch { + return false; + } + })(); + if (!preserveExisting && cohort === "established-before-landing-v1") { + snapshot = completedState; + hydrated = true; + emit(); + target.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + version: ONBOARDING_STORAGE_VERSION, + state: completedState, + } satisfies PersistedOnboardingState), + ); + } + } catch { + if (cohort === "established-before-landing-v1") { + snapshot = completedState; + hydrated = true; + emit(); + } + // Onboarding remains usable if localStorage is unavailable or full. + } +} + function isStringArray(value: unknown): value is string[] { return ( Array.isArray(value) && value.every((item) => typeof item === "string") @@ -58,14 +126,38 @@ function isLifecycle(value: unknown): value is OnboardingLifecycle { ); } +function isValidPersistedState( + state: Partial | undefined, +): state is OnboardingState { + return Boolean( + state && + isLifecycle(state.lifecycle) && + isStep(state.step) && + isStringArray(state.selectedWorkTypeIds) && + state.selectedWorkTypeIds.every((id) => workTypeIds.has(id)) && + (state.selectedAgentId === null || + (typeof state.selectedAgentId === "string" && + agentIds.has(state.selectedAgentId))) && + (state.selectedHarnessId === null || + (typeof state.selectedHarnessId === "string" && + harnessIds.has(state.selectedHarnessId))) && + (state.completedHarnessSetupIds === undefined || + (isStringArray(state.completedHarnessSetupIds) && + state.completedHarnessSetupIds.every((id) => harnessIds.has(id)))) && + (state.step === "complete") === (state.lifecycle === "completed") && + (state.step !== "harness-setup" || state.selectedHarnessId !== null), + ); +} + function parsePersisted(raw: string | null): OnboardingState { hasUnsupportedNewerRecord = false; if (raw === null) return { ...INITIAL_ONBOARDING_STATE }; try { const value: unknown = JSON.parse(raw); - if (!value || typeof value !== "object") + if (!value || typeof value !== "object") { return { ...INITIAL_ONBOARDING_STATE }; + } const record = value as Partial; if (record.version !== ONBOARDING_STORAGE_VERSION) { hasUnsupportedNewerRecord = @@ -74,29 +166,7 @@ function parsePersisted(raw: string | null): OnboardingState { return { ...INITIAL_ONBOARDING_STATE }; } const state = record.state as Partial | undefined; - if ( - !state || - !isLifecycle(state.lifecycle) || - !isStep(state.step) || - !isStringArray(state.selectedWorkTypeIds) || - !state.selectedWorkTypeIds.every((id) => workTypeIds.has(id)) || - !( - state.selectedAgentId === null || - (typeof state.selectedAgentId === "string" && - agentIds.has(state.selectedAgentId)) - ) || - !( - state.selectedHarnessId === null || - (typeof state.selectedHarnessId === "string" && - harnessIds.has(state.selectedHarnessId)) - ) || - (state.completedHarnessSetupIds !== undefined && - (!isStringArray(state.completedHarnessSetupIds) || - !state.completedHarnessSetupIds.every((id) => harnessIds.has(id)))) || - (state.step === "complete" && state.lifecycle !== "completed") || - (state.step !== "complete" && state.lifecycle === "completed") || - (state.step === "harness-setup" && state.selectedHarnessId === null) - ) { + if (!isValidPersistedState(state)) { return { ...INITIAL_ONBOARDING_STATE }; } return { @@ -233,3 +303,11 @@ export function resetOnboardingStoreForTests(): void { hydrated = false; hasUnsupportedNewerRecord = false; } + +/** Test-only storage override for restricted WebView behavior. */ +export function setOnboardingStorageForTests( + target: Storage | null | undefined, +): void { + storageOverrideForTests = target; + resetOnboardingStoreForTests(); +} diff --git a/src/features/onboarding/ui/OnboardingShell.tsx b/src/features/onboarding/ui/OnboardingShell.tsx index 8b5303653..c9a1554e5 100644 --- a/src/features/onboarding/ui/OnboardingShell.tsx +++ b/src/features/onboarding/ui/OnboardingShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import { IconChevronLeft } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; interface OnboardingShellProps { title?: ReactNode; @@ -10,6 +11,7 @@ interface OnboardingShellProps { backDisabled?: boolean; children: ReactNode; actions?: ReactNode; + contentClassName?: string; } export function OnboardingShell({ @@ -19,6 +21,7 @@ export function OnboardingShell({ backDisabled = false, children, actions, + contentClassName, }: OnboardingShellProps) { const { t } = useTranslation("onboarding"); const headingRef = useRef(null); @@ -61,7 +64,12 @@ export function OnboardingShell({ ) : null} ) : null} -
+
{children}
{actions ? ( diff --git a/src/features/onboarding/ui/WelcomeStep.test.tsx b/src/features/onboarding/ui/WelcomeStep.test.tsx new file mode 100644 index 000000000..0ee9fb918 --- /dev/null +++ b/src/features/onboarding/ui/WelcomeStep.test.tsx @@ -0,0 +1,177 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WelcomeStep } from "./WelcomeStep"; + +const motionMocks = vi.hoisted(() => ({ reduced: false })); +const consentMocks = vi.hoisted(() => ({ + update: vi.fn(async () => undefined), + enforced: vi.fn(() => false), + available: true, +})); + +// The consent module is mocked at its boundary — the page's contract is which +// consent writes it makes, not the store's own persist behavior +// (consent.test.ts covers that). +vi.mock("@/shared/telemetry/consent", () => ({ + updateTelemetryEnabled: consentMocks.update, + telemetryConsentEnforced: () => consentMocks.enforced(), +})); + +vi.mock("@/shared/profile/capabilities", () => ({ + useProfileCapability: (capability: string) => + capability === "telemetry" ? consentMocks.available : true, +})); + +vi.mock("motion/react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useReducedMotion: () => motionMocks.reduced, + }; +}); + +vi.mock("@/features/projects/artifact/ProjectArtifactPreview", () => ({ + ProjectArtifactPreview: ({ + gestureFreezeActive, + motionImpulse, + }: { + gestureFreezeActive?: boolean; + motionImpulse?: unknown; + }) => ( +
+ ), +})); + +function renderStep(onStart = vi.fn()) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + return onStart; +} + +describe("WelcomeStep", () => { + afterEach(() => { + motionMocks.reduced = false; + consentMocks.update.mockReset().mockResolvedValue(undefined); + consentMocks.enforced.mockReset().mockReturnValue(false); + consentMocks.available = true; + }); + + it("starts onboarding from the landing page with sharing defaulted on", async () => { + const onStart = renderStep(); + + const heading = screen.getByRole("heading", { + name: "Welcome to Berd. Your place for doing.", + }); + expect(heading).toBeInTheDocument(); + expect(heading).toHaveFocus(); + const scrollRegion = heading.closest("[class*='overflow-y-auto']"); + expect(scrollRegion).toHaveClass("max-[760px]:overflow-y-auto"); + expect(scrollRegion).toHaveClass("max-[760px]:overflow-x-hidden"); + expect(screen.getByTestId("project-cube")).toBeInTheDocument(); + expect( + screen.getByRole("checkbox", { name: /share anonymous usage data/i }), + ).toBeChecked(); + + await userEvent.click(screen.getByRole("button", { name: "Let’s go" })); + expect(consentMocks.update).toHaveBeenCalledExactlyOnceWith(true); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it("persists an opt out before advancing", async () => { + const onStart = renderStep(); + await userEvent.click( + screen.getByRole("checkbox", { name: /share anonymous usage data/i }), + ); + await userEvent.click(screen.getByRole("button", { name: "Let’s go" })); + + expect(consentMocks.update).toHaveBeenCalledExactlyOnceWith(false); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it("advances even when the consent write fails", async () => { + const error = new Error("read-only disk"); + consentMocks.update.mockRejectedValue(error); + const consoleWarn = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + const onStart = renderStep(); + + await userEvent.click(screen.getByRole("button", { name: "Let’s go" })); + + expect(consentMocks.update).toHaveBeenCalledOnce(); + expect(onStart).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(consoleWarn).toHaveBeenCalledWith( + "Failed to persist the usage-data choice:", + error, + ); + }); + consoleWarn.mockRestore(); + }); + + // Same rules as the Settings row: consent that cannot decide anything is + // not asked for, and advancing writes nothing. + it.each([ + [ + "telemetry is enforced by the build", + () => { + consentMocks.enforced.mockReturnValue(true); + }, + ], + [ + "the telemetry capability is unavailable", + () => { + consentMocks.available = false; + }, + ], + ])("hides the consent choice when %s", async (_case, arrange) => { + arrange(); + const onStart = renderStep(); + + expect( + screen.queryByRole("checkbox", { name: /share anonymous usage data/i }), + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Let’s go" })); + expect(consentMocks.update).not.toHaveBeenCalled(); + expect(onStart).toHaveBeenCalledOnce(); + }); + + it("freezes decorative cube motion when reduced motion is requested", () => { + motionMocks.reduced = true; + renderStep(); + + expect(screen.getByTestId("project-cube")).toHaveAttribute( + "data-frozen", + "true", + ); + expect(screen.getByTestId("project-cube")).toHaveAttribute( + "data-has-motion", + "false", + ); + }); + + it("opens usage details in a dialog", async () => { + renderStep(); + + await userEvent.click(screen.getByRole("button", { name: "Learn more" })); + + expect( + screen.getByRole("dialog", { name: "Sharing usage data" }), + ).toBeInTheDocument(); + expect(screen.getByText("What we collect")).toBeInTheDocument(); + expect(screen.getByText("What we don’t collect")).toBeInTheDocument(); + }); +}); diff --git a/src/features/onboarding/ui/WelcomeStep.tsx b/src/features/onboarding/ui/WelcomeStep.tsx index 891753bd6..9d6829825 100644 --- a/src/features/onboarding/ui/WelcomeStep.tsx +++ b/src/features/onboarding/ui/WelcomeStep.tsx @@ -1,9 +1,16 @@ +import { useEffect, useId, useRef, useState, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; -import { BerdIcon } from "@/shared/ui/icons/BerdIcon"; -import { useArtifacts } from "@/shared/hooks/useArtifacts"; -import { selectCollectionImageUrl } from "@/shared/api/artifacts"; +import { Checkbox } from "@/shared/ui/checkbox"; +import { ProjectArtifactPreview } from "@/features/projects/artifact/ProjectArtifactPreview"; +import type { ProjectArtifactMotionImpulse } from "@/features/projects/artifact/types"; +import { UsageDataDialog } from "@/features/settings/ui/UsageDataDialog"; +import { useProfileCapability } from "@/shared/profile/capabilities"; +import { + telemetryConsentEnforced, + updateTelemetryEnabled, +} from "@/shared/telemetry/consent"; import { OnboardingShell } from "./OnboardingShell"; interface WelcomeStepProps { @@ -15,79 +22,198 @@ const reveal = { visible: { opacity: 1, y: 0 }, }; +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)); +} + export function WelcomeStep({ onStart }: WelcomeStepProps) { const { t } = useTranslation("onboarding"); - const reduceMotion = useReducedMotion(); - const duration = reduceMotion ? 0 : 0.28; - const { data: artifacts } = useArtifacts(); - const projectThumbnail = selectCollectionImageUrl( - artifacts, - "onboarding", - "project-cube", - ); - const avatarThumbnail = selectCollectionImageUrl( - artifacts, - "onboarding", - "avatar-thumbnail", + const reduceMotion = useReducedMotion() === true; + const checkboxId = useId(); + const headingRef = useRef(null); + // The landing page is the one consent surface that defaults to sharing ON: + // the persisted Rust-owned setting stays at its opt-in default (OFF) until + // the user advances, so declining is just leaving the page. + const [shareUsageData, setShareUsageData] = useState(true); + const [detailsOpen, setDetailsOpen] = useState(false); + // Mirrors TelemetryConsentRow's gate: enforced builds decide consent as + // build policy, and capability-less sessions can never emit an event, so in + // both cases asking would be noise. Hiding also skips the write on start, + // leaving the persisted choice untouched. + const consentAvailable = useProfileCapability("telemetry"); + const consentHidden = telemetryConsentEnforced() || !consentAvailable; + const [cubeMotion, setCubeMotion] = useState(); + const lastPointer = useRef<{ x: number; y: number; time: number } | null>( + null, ); + useEffect(() => { + headingRef.current?.focus(); + }, []); + + const updateCubeMotion = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const current = { + x: event.clientX, + y: event.clientY, + time: event.timeStamp, + }; + const previous = lastPointer.current; + lastPointer.current = current; + const hoverX = clamp( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -1, + 1, + ); + const hoverY = clamp( + -(((event.clientY - rect.top) / rect.height) * 2 - 1), + -1, + 1, + ); + const elapsed = Math.max( + current.time - (previous?.time ?? current.time), + 8, + ); + const deltaX = previous ? (current.x - previous.x) / rect.width : 0; + const deltaY = previous ? (current.y - previous.y) / rect.height : 0; + const velocityBoost = clamp( + 1 + (Math.hypot(deltaX, deltaY) / (elapsed / 1000)) * 0.22, + 0.9, + 3.1, + ); + setCubeMotion((motion) => ({ + sequence: (motion?.sequence ?? 0) + 1, + deltaX: clamp(deltaX * velocityBoost, -0.3, 0.3), + deltaY: clamp(deltaY * velocityBoost, -0.3, 0.3), + hoverX: Math.abs(hoverX) < 0.08 ? 0.22 : hoverX, + hoverY: Math.abs(hoverY) < 0.08 ? 0.16 : hoverY, + })); + }; + + const resetCubeMotion = () => { + lastPointer.current = null; + setCubeMotion((motion) => + motion + ? { + ...motion, + sequence: motion.sequence + 1, + deltaX: 0, + deltaY: 0, + hoverX: 0, + hoverY: 0, + } + : motion, + ); + }; + return ( - + -
-
+ {t("welcome.getStarted")} + + + {consentHidden ? null : ( +
+ + setShareUsageData(checked === true) + } + aria-describedby={`${checkboxId}-description`} + /> +

+ {" "} + +

+
+ )} +
+ +
); } diff --git a/src/features/projects/artifact/ProjectArtifactPreview.tsx b/src/features/projects/artifact/ProjectArtifactPreview.tsx index 7a7111200..ada2757b7 100644 --- a/src/features/projects/artifact/ProjectArtifactPreview.tsx +++ b/src/features/projects/artifact/ProjectArtifactPreview.tsx @@ -33,6 +33,7 @@ interface ProjectArtifactPreviewProps { gestureFreezeActive?: boolean; renderPaused?: boolean; onGlCanvasReady?: (canvas: HTMLCanvasElement) => void; + cameraDistanceScale?: number; } function canUseWebGlRenderer(): boolean { @@ -148,6 +149,7 @@ export function ProjectArtifactPreview({ gestureFreezeActive, renderPaused = false, onGlCanvasReady, + cameraDistanceScale, variant = "preview", }: ProjectArtifactPreviewProps) { const state = useMemo(() => deriveProjectArtifactState(input), [input]); @@ -215,6 +217,7 @@ export function ProjectArtifactPreview({ gestureFreezeActive={gestureFreezeActive} motionImpulse={motionImpulse} onGlCanvasReady={onGlCanvasReady} + cameraDistanceScale={cameraDistanceScale} state={state} variant={variant} /> diff --git a/src/features/projects/artifact/ProjectArtifactRenderer.tsx b/src/features/projects/artifact/ProjectArtifactRenderer.tsx index 7652e08ff..2a8785659 100644 --- a/src/features/projects/artifact/ProjectArtifactRenderer.tsx +++ b/src/features/projects/artifact/ProjectArtifactRenderer.tsx @@ -156,15 +156,16 @@ const CANVAS_CAMERA = { function getCanvasCamera( variant: NonNullable, + distanceScale = 1, ) { const config = variant === "tile" ? CANVAS_CAMERA.tile : CANVAS_CAMERA.preview; return { - position: [config.position[0], config.position[1], config.position[2]] as [ - number, - number, - number, - ], + position: [ + config.position[0] * distanceScale, + config.position[1] * distanceScale, + config.position[2] * distanceScale, + ] as [number, number, number], fov: config.fov, near: 1, far: 100, @@ -1748,6 +1749,7 @@ export function ProjectArtifactRenderer({ environmentUrl, imageUrls, className, + cameraDistanceScale = 1, gestureFreezeActive = false, motionImpulse, onGlCanvasReady, @@ -1934,7 +1936,7 @@ export function ProjectArtifactRenderer({ > void; + /** Pulls the camera back to provide extra framing around oversized embeds. */ + cameraDistanceScale?: number; } export type ProjectArtifactPinState = { projectId: string }; diff --git a/src/features/settings/ui/TelemetryConsentRow.tsx b/src/features/settings/ui/TelemetryConsentRow.tsx index 0bec7a162..ea8be5819 100644 --- a/src/features/settings/ui/TelemetryConsentRow.tsx +++ b/src/features/settings/ui/TelemetryConsentRow.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useProfileCapability } from "@/shared/profile/capabilities"; +import { Button } from "@/shared/ui/button"; import { SettingsRow } from "@/shared/ui/settings-row"; import { SettingsSection } from "@/shared/ui/settings-section"; import { Switch } from "@/shared/ui/switch"; @@ -11,6 +12,7 @@ import { updateTelemetryEnabled, useTelemetryConsentStore, } from "@/shared/telemetry/consent"; +import { UsageDataDialog } from "./UsageDataDialog"; // The telemetry consent toggle: a plain persisted write against the // Rust-owned setting (default OFF), applied immediately by the per-event and @@ -40,6 +42,7 @@ export function TelemetryConsentRow() { const loaded = useTelemetryConsentStore((state) => state.loaded); const enabled = useTelemetryConsentStore((state) => state.enabled); const [saving, setSaving] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); // The consent read normally happens during telemetry init, but that is // skipped in dev and consent-disabled sessions — the toggle still has to @@ -68,7 +71,19 @@ export function TelemetryConsentRow() { + {t("privacy.telemetry.description")}{" "} + + + } > + ); } diff --git a/src/features/settings/ui/UsageDataDialog.tsx b/src/features/settings/ui/UsageDataDialog.tsx new file mode 100644 index 000000000..fc55bddda --- /dev/null +++ b/src/features/settings/ui/UsageDataDialog.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +interface UsageDataDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// The "Sharing usage data" details: what telemetry collects and what it never +// collects. One copy of the story, rendered by every surface that asks for +// consent — the onboarding landing page and the Settings privacy row — so the +// two can never drift apart. +export function UsageDataDialog({ open, onOpenChange }: UsageDataDialogProps) { + const { t } = useTranslation(["settings", "common"]); + + return ( + + + + + {t("privacy.telemetry.usageDialog.title")} + + + + {t("privacy.telemetry.usageDialog.intro")} + +
+
+

+ {t("privacy.telemetry.usageDialog.collectTitle")} +

+

+ {t("privacy.telemetry.usageDialog.collectBody")} +

+
+
+

+ {t("privacy.telemetry.usageDialog.notCollectTitle")} +

+

+ {t("privacy.telemetry.usageDialog.notCollectBody")} +

+
+
+
+
+ ); +} diff --git a/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx b/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx index 3c639144a..532d500e8 100644 --- a/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx +++ b/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx @@ -127,6 +127,30 @@ describe("TelemetryConsentRow", () => { consoleWarn.mockRestore(); }); + it("presents the usage-data details from the learn-more link", async () => { + renderWithProviders(); + + await userEvent.click( + screen.getByRole("button", { + name: enSettings.privacy.telemetry.learnMore, + }), + ); + + expect( + screen.getByRole("dialog", { + name: enSettings.privacy.telemetry.usageDialog.title, + }), + ).toBeInTheDocument(); + expect( + screen.getByText(enSettings.privacy.telemetry.usageDialog.collectTitle), + ).toBeInTheDocument(); + expect( + screen.getByText( + enSettings.privacy.telemetry.usageDialog.notCollectTitle, + ), + ).toBeInTheDocument(); + }); + it("renders nothing in enforced builds", () => { enforced.mockReturnValue(true); const { container } = renderWithProviders(); diff --git a/src/main.test.tsx b/src/main.test.tsx index be53222c5..807e5de1e 100644 --- a/src/main.test.tsx +++ b/src/main.test.tsx @@ -21,6 +21,10 @@ vi.mock("@/app/RendererTelemetry", () => ({ RendererTelemetry: () => null, })); +vi.mock("@/app/ui/StartupLoadingView", () => ({ + StartupLoadingView: () =>
, +})); + vi.mock("@/app/SessionWindowApp", () => ({ SessionWindowApp: ({ sessionId }: { sessionId: string }) => (
{sessionId}
@@ -72,26 +76,47 @@ describe("main entrypoint telemetry startup", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + localStorage.clear(); + mockInvoke.mockResolvedValue("fresh-with-landing-v1"); globalThis.fetch = vi.fn() as typeof globalThis.fetch; }); afterEach(() => { document.body.innerHTML = ""; + localStorage.clear(); globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); - it("runs the production startup path without telemetry network or native-command work", async () => { + // The one native command the main window's boot may issue is the + // installation-cohort lookup — telemetry itself stays off the network and + // off native commands in this build. + it("resolves the installation cohort before rendering the main app, with no telemetry work", async () => { await loadMainAt(""); await screen.findByTestId("main-app"); expect(globalThis.fetch).not.toHaveBeenCalled(); - expect(mockInvoke).not.toHaveBeenCalled(); + expect(mockInvoke).toHaveBeenCalledOnce(); + expect(mockInvoke).toHaveBeenCalledWith("get_installation_cohort"); expect(mockInstallRendererDiagnostics).toHaveBeenCalledWith({ windowKind: "main", }); }); + it("reports cohort lookup failures without blocking startup", async () => { + const error = new Error("state unavailable"); + mockInvoke.mockRejectedValueOnce(error); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await loadMainAt(""); + + await screen.findByTestId("main-app"); + expect(mockReportRendererError).toHaveBeenCalledWith( + "installation_cohort_failed", + error, + ); + }); + it("runs the session window startup path without telemetry network or native-command work", async () => { await loadMainAt("?sessionKey=c2Vzc2lvbi0xMjM"); diff --git a/src/main.tsx b/src/main.tsx index afeb3df72..7adb705af 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,7 +15,10 @@ import { App } from "@/app/App"; import { GitStateEvents } from "@/app/GitStateEvents"; import { LocalMediaCacheEvents } from "@/app/LocalMediaCacheEvents"; import { RendererTelemetry } from "@/app/RendererTelemetry"; +import { StartupLoadingView } from "@/app/ui/StartupLoadingView"; import { BackgroundQueuedMessageDrain } from "@/features/chat/ui/BackgroundQueuedMessageDrain"; +import { getInstallationCohort } from "@/features/onboarding/api/installationCohort"; +import { initializeOnboardingGraduation } from "@/features/onboarding/model"; import { UpdaterProvider } from "@/features/updates/hooks/useUpdater"; import { I18nProvider } from "@/shared/i18n"; import { initTelemetry, trackAppLaunched } from "@/shared/telemetry/client"; @@ -170,30 +173,52 @@ if (bootError) { // screen's Reload button). Re-initializing is the point — the reloaded // renderer needs a live pipeline — while trackAppLaunched() reports only on // the first load of this window session, since a reload is not an app start. + // Running before consent is answered is safe by design: events buffer + // through the consent gate and are dropped unless the persisted setting + // loads as enabled, so a fresh install sends nothing until the user opts in + // on the welcome page or in Settings. initTelemetry(); trackAppLaunched(); reactRoot.render( - - - - - - - - - - - - - - - - - - - + + + , ); + getInstallationCohort() + .then((cohort) => { + initializeOnboardingGraduation(cohort); + }) + .catch((error) => { + console.error("Failed to resolve installation cohort:", error); + reportRendererError("installation_cohort_failed", error); + initializeOnboardingGraduation("unknown"); + }) + .finally(() => { + reactRoot.render( + + + + + + + + + + + + + + + + + + + + + , + ); + }); } diff --git a/src/shared/i18n/locales/en/onboarding.json b/src/shared/i18n/locales/en/onboarding.json index 4970c3430..29c68cfbf 100644 --- a/src/shared/i18n/locales/en/onboarding.json +++ b/src/shared/i18n/locales/en/onboarding.json @@ -3,12 +3,11 @@ "goBack": "Go back" }, "welcome": { - "title": "Welcome to Berd", - "projects": "A place to build projects", - "agents": "work with agents", - "and": "and", - "done": "get work done", - "getStarted": "Get started" + "title": "Welcome to Berd.", + "subtitle": "Your place for doing.", + "getStarted": "Let’s go", + "usageConsent": "Share anonymous usage data to help improve Berd.", + "learnMore": "Learn more" }, "workTypes": { "title": "What type of work will you use Berd for?", diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index e51546740..580015025 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -218,11 +218,6 @@ "defaultLabel": "default", "description": "Opt into in-progress Berd features on this device. Experiments can change, break, or disappear.", "emptyDescription": "New experiments will appear here when they are ready for opt-in testing.", - "firstRunOnboarding": { - "description": "Preview the new first-install onboarding flow. Unfinished progress resumes when the experiment is enabled again.", - "replay": "Replay onboarding", - "title": "First-run onboarding" - }, "onboarding": { "cancel": "Cancel", "confirm": "Reset canvas", @@ -468,7 +463,16 @@ "telemetry": { "description": "Help improve Berd by sharing usage events, like which features are used. Chat content is never sent.", "label": "Share usage data", - "saveError": "Couldn't update the usage data setting. Try again." + "learnMore": "Learn more", + "saveError": "Couldn't update the usage data setting. Try again.", + "usageDialog": { + "collectBody": "Usage events, such as features used, errors, and performance, tied to a random installation ID.", + "collectTitle": "What we collect", + "intro": "We use a random installation ID, not your identity, to see how Berd is used and fix what’s broken.", + "notCollectBody": "Your name, email, or account. Berd has no login, so there’s nothing to tie data to.", + "notCollectTitle": "What we don’t collect", + "title": "Sharing usage data" + } }, "title": "Privacy" }, diff --git a/src/shared/i18n/locales/es/onboarding.json b/src/shared/i18n/locales/es/onboarding.json index 5898093dc..c74f88605 100644 --- a/src/shared/i18n/locales/es/onboarding.json +++ b/src/shared/i18n/locales/es/onboarding.json @@ -3,12 +3,11 @@ "goBack": "Volver" }, "welcome": { - "title": "Te damos la bienvenida a Berd", - "projects": "Un lugar para crear proyectos", - "agents": "trabajar con agentes", - "and": "y", - "done": "completar tu trabajo", - "getStarted": "Comenzar" + "title": "Te damos la bienvenida a Berd.", + "subtitle": "Tu lugar para hacer.", + "getStarted": "Vamos", + "usageConsent": "Comparte datos de uso anónimos para ayudar a mejorar Berd.", + "learnMore": "Más información" }, "workTypes": { "title": "¿Para qué tipo de trabajo usarás Berd?", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 3cd4236df..06e8ea0e7 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -218,11 +218,6 @@ "defaultLabel": "predeterminado", "description": "Activa funciones de Berd en desarrollo en este dispositivo. Los experimentos pueden cambiar, fallar o desaparecer.", "emptyDescription": "Los nuevos experimentos aparecerán aquí cuando estén listos para probarse.", - "firstRunOnboarding": { - "description": "Previsualiza el nuevo flujo de incorporación de la primera instalación. El progreso sin terminar se reanuda cuando se vuelve a activar el experimento.", - "replay": "Repetir incorporación", - "title": "Incorporación del primer inicio" - }, "onboarding": { "cancel": "Cancelar", "confirm": "Restablecer lienzo", @@ -471,7 +466,16 @@ "telemetry": { "description": "Ayuda a mejorar Berd compartiendo eventos de uso, como qué funciones se usan. El contenido de los chats nunca se envía.", "label": "Compartir datos de uso", - "saveError": "No se pudo actualizar la configuración de datos de uso. Inténtalo de nuevo." + "learnMore": "Más información", + "saveError": "No se pudo actualizar la configuración de datos de uso. Inténtalo de nuevo.", + "usageDialog": { + "collectBody": "Eventos de uso, como las funciones utilizadas, errores y rendimiento, vinculados a un identificador de instalación aleatorio.", + "collectTitle": "Qué recopilamos", + "intro": "Usamos un identificador de instalación aleatorio, no tu identidad, para saber cómo se usa Berd y corregir lo que no funciona.", + "notCollectBody": "Tu nombre, correo electrónico o cuenta. Berd no requiere iniciar sesión, así que no hay nada que vincule los datos contigo.", + "notCollectTitle": "Qué no recopilamos", + "title": "Compartir datos de uso" + } }, "title": "Privacidad" }, diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx index 63cc9f7cb..9de2609c6 100644 --- a/src/shared/ui/dialog.tsx +++ b/src/shared/ui/dialog.tsx @@ -1,7 +1,18 @@ import type * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { Slot } from "@radix-ui/react-slot"; -import { XIcon } from "lucide-react"; +function SolidXIcon({ className }: { className?: string }) { + return ( + + ); +} import { cn } from "@/shared/lib/cn"; @@ -81,6 +92,7 @@ function DialogContent({ overlayClassName, positionerClassName, showCloseButton = true, + closeLabel = "Close", size = "lg", surface = "glass", ...props @@ -88,6 +100,7 @@ function DialogContent({ overlayClassName?: string; positionerClassName?: string; showCloseButton?: boolean; + closeLabel?: string; size?: DialogSize; surface?: DialogSurface; }) { @@ -123,8 +136,8 @@ function DialogContent({ data-slot="dialog-close" className="focus-visible:ring-ring/50 data-[state=open]:bg-muted data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" > - - Close + + {closeLabel} )} From a2cc6281c749d0e52ff61849020603927248a183 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 18 Aug 2026 22:21:25 +1000 Subject: [PATCH 3/7] fix(onboarding): render onboarding before the startup gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh install's first screen was whatever app startup was doing. AppShell checked the startup loader and the connectivity diagnostic before the onboarding gate, and `useAppStartup` hard-awaits the full chat runtime — spawning and connecting to the `goosed` sidecar, loading runtime config, refreshing provider readiness. So a slow sidecar showed a spinner and a broken one (Gatekeeper/AV interference, port exhaustion, corrupted install) showed a technical diagnostic, and in neither case did the user reach the welcome page, which is also where telemetry consent is asked for. The ordering predates this branch, but graduating first-run onboarding to default behavior made incomplete onboarding the route for every fresh install, so the fix belongs to finishing that graduation. Onboarding now renders first, and the steps that actually need the runtime take its state instead of assuming it is up: - AppShell derives an `OnboardingRuntimeState` (`ready` means startup settled without an issue, since `startup.ready` is set even when startup threw) and hands it to `OnboardingFlow`. The dev-only `?startupLoading` override still preempts everything for loader parity, and once onboarding completes the loader and diagnostic gates apply as before — a still-broken runtime lands on the normal diagnostic with the consent and setup choices already captured. - Agent adoption is the only onboarding step that calls the runtime (`listPersonas`/`createPersona` over ACP), so it is the only one gated: "Keep 'em" holds a pending state while startup is in flight and gives way to an inline error plus a retry when startup failed. "Skip for now" needs nothing and stays available throughout. Welcome, the work-type picker, and the harness picker are static, and harness setup drives native doctor and agent-setup commands that do not need the sidecar. Normal startup latency now hides behind reading the consent page instead of behind a spinner. Validated with just check and just test. Co-Authored-By: Claude Opus 5 Signed-off-by: Matt Toohey --- src/app/AppShell.startupDiagnostics.test.tsx | 78 +++++++++++++++++- src/app/AppShell.tsx | 42 ++++++++-- src/features/onboarding/model/index.ts | 1 + src/features/onboarding/model/runtime.ts | 14 ++++ .../onboarding/ui/OnboardingFlow.test.tsx | 72 ++++++++++++++++- src/features/onboarding/ui/OnboardingFlow.tsx | 13 ++- .../ui/RecommendationsStep.test.tsx | 80 ++++++++++++++++--- .../onboarding/ui/RecommendationsStep.tsx | 42 +++++++--- src/shared/i18n/locales/en/onboarding.json | 3 + src/shared/i18n/locales/es/onboarding.json | 3 + 10 files changed, 319 insertions(+), 29 deletions(-) create mode 100644 src/features/onboarding/model/runtime.ts diff --git a/src/app/AppShell.startupDiagnostics.test.tsx b/src/app/AppShell.startupDiagnostics.test.tsx index 82b275dd1..78dd4f134 100644 --- a/src/app/AppShell.startupDiagnostics.test.tsx +++ b/src/app/AppShell.startupDiagnostics.test.tsx @@ -1,12 +1,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { dispatchOnboarding } from "@/features/onboarding/model"; +import { + dispatchOnboarding, + resetOnboarding, +} from "@/features/onboarding/model"; import { AppShell } from "./AppShell"; const mocks = vi.hoisted(() => ({ @@ -98,6 +101,22 @@ vi.mock("./ui/AppShellContent", () => ({ AppShellContent: () =>
, })); +// The flow's own tests cover the steps; here only the gate order and the +// runtime state AppShell hands down matter. +vi.mock("@/features/onboarding/ui/OnboardingFlow", () => ({ + OnboardingFlow: ({ + runtime, + }: { + runtime: { ready: boolean; failed: boolean }; + }) => ( +
+ ), +})); + function renderAppShell() { const queryClient = new QueryClient({ defaultOptions: { @@ -199,6 +218,61 @@ describe("AppShell startup diagnostics", () => { expect(mocks.startupRetry).toHaveBeenCalledTimes(1); }); + // First-run onboarding renders ahead of the startup gates: the welcome page + // is where a fresh install answers telemetry consent, and it needs nothing + // from the `goosed` sidecar. + it.each([ + [ + "startup has not settled", + () => { + mocks.startupState.ready = false; + }, + { ready: "false", failed: "false" }, + ], + [ + "startup failed", + () => { + mocks.startupState.error = new Error( + "Failed to spawn goose serve (binary: goosed): denied", + ); + }, + { ready: "false", failed: "true" }, + ], + ])("renders onboarding while %s", (_case, arrange, expected) => { + arrange(); + resetOnboarding(); + + renderAppShell(); + + const flow = screen.getByTestId("onboarding-flow"); + expect(flow).toHaveAttribute("data-runtime-ready", expected.ready); + expect(flow).toHaveAttribute("data-runtime-failed", expected.failed); + expect( + screen.queryByRole("status", { name: "Starting Berd" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: "Berd couldn't start" }), + ).not.toBeInTheDocument(); + }); + + it("falls through to the startup gates once onboarding completes", () => { + mocks.startupState.error = new Error( + "Failed to spawn goose serve (binary: goosed): denied", + ); + resetOnboarding(); + renderAppShell(); + expect(screen.getByTestId("onboarding-flow")).toBeInTheDocument(); + + act(() => { + dispatchOnboarding({ type: "complete" }); + }); + + expect( + screen.getByRole("heading", { name: "Berd couldn't start" }), + ).toBeInTheDocument(); + expect(screen.queryByTestId("onboarding-flow")).not.toBeInTheDocument(); + }); + it("shows a blocking configuration unavailable startup error", () => { mocks.startupState.error = Object.assign( new Error( diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 44092f832..410e680c6 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -222,7 +222,10 @@ import { import { isDesignSystemExplorerEnabled } from "@/features/design-system/lib/designSystemEnabled"; import { useExperiment } from "@/features/experiments/experimentPreferences"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; -import { useOnboardingState } from "@/features/onboarding/model"; +import { + type OnboardingRuntimeState, + useOnboardingState, +} from "@/features/onboarding/model"; import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; import { PocketVoiceSetupDialog } from "@/features/voice-conversation/ui/PocketVoiceSetupDialog"; @@ -4320,6 +4323,18 @@ export function AppShell({ const forceStartupLoading = import.meta.env.DEV && new URLSearchParams(window.location.search).has("startupLoading"); + // Onboarding renders ahead of the startup gates below, so the steps that do + // call the chat runtime need its state instead of being able to assume it is + // up. `startup.ready` only says startup settled — it is set even when startup + // threw — so a usable runtime is "settled without an issue". + const onboardingRuntime = useMemo( + () => ({ + ready: startup.ready && startupIssue === null, + failed: startupIssue !== null, + retry: startup.retry, + }), + [startup.ready, startup.retry, startupIssue], + ); const isGlobalComposerHandoff = globalComposerPlacement === "handoff"; const isGlobalComposerRouteDisallowed = targetLocation.view === "automations" && @@ -4938,7 +4953,26 @@ export function AppShell({ setStarterTasksEligible(true); }; - if (forceStartupLoading || !startup.ready || !startupLoadingMinElapsed) { + // The dev-only `?startupLoading` override still preempts everything, so the + // loader stays inspectable on a fresh install. + if (forceStartupLoading) { + return ; + } + + // Onboarding comes before the startup gates. The landing page — the surface + // that asks for telemetry consent — and the work-type picker need nothing + // from the `goosed` sidecar, so a slow or broken runtime must not turn a + // first-run user's first screen into a spinner or a connectivity diagnostic; + // it also masks normal startup latency behind reading the consent page. The + // steps that do use the runtime gate themselves on `onboardingRuntime`, and + // once onboarding completes the gates below apply as usual, so a still-broken + // runtime lands on the diagnostic with the consent and setup choices already + // captured. + if (onboardingState.lifecycle !== "completed") { + return ; + } + + if (!startup.ready || !startupLoadingMinElapsed) { return ; } @@ -4948,10 +4982,6 @@ export function AppShell({ ); } - if (onboardingState.lifecycle !== "completed") { - return ; - } - return ( void; +} diff --git a/src/features/onboarding/ui/OnboardingFlow.test.tsx b/src/features/onboarding/ui/OnboardingFlow.test.tsx index 8862e1c29..f5c6cb59f 100644 --- a/src/features/onboarding/ui/OnboardingFlow.test.tsx +++ b/src/features/onboarding/ui/OnboardingFlow.test.tsx @@ -9,6 +9,7 @@ import { dispatchOnboarding, resetOnboardingStoreForTests, } from "../model/onboardingStore"; +import type { OnboardingRuntimeState } from "../model"; import { OnboardingFlow } from "./OnboardingFlow"; const mockCreatePersona = vi.hoisted(() => vi.fn()); @@ -60,6 +61,15 @@ vi.mock("@/shared/ui/avatar-media", () => ({ ), })); +vi.mock("@/features/projects/artifact/ProjectArtifactPreview", () => ({ + ProjectArtifactPreview: () =>
, +})); + +vi.mock("@/shared/telemetry/consent", () => ({ + updateTelemetryEnabled: vi.fn(async () => undefined), + telemetryConsentEnforced: () => false, +})); + function createdPersona(request: CreatePersonaRequest) { return { id: `/Users/x/.agents/agents/${request.displayName.toLowerCase()}.md`, @@ -73,10 +83,16 @@ function createdPersona(request: CreatePersonaRequest) { }; } -function renderFlow() { +const readyRuntime: OnboardingRuntimeState = { + ready: true, + failed: false, + retry: vi.fn(), +}; + +function renderFlow(runtime: OnboardingRuntimeState = readyRuntime) { return render( - + , ); } @@ -160,3 +176,55 @@ describe("OnboardingFlow agent adoption telemetry", () => { expect(toast.warning).toHaveBeenCalledTimes(1); }); }); + +// AppShell renders the flow ahead of its startup gates, so the runtime-free +// steps must not depend on the chat runtime having started. +describe("OnboardingFlow runtime independence", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetOnboardingStoreForTests(); + dispatchOnboarding({ type: "start" }); + }); + + it.each([ + ["startup has not settled", { ready: false, failed: false }], + ["startup failed", { ready: false, failed: true }], + ])("renders the landing page while %s", (_case, runtime) => { + renderFlow({ ...runtime, retry: vi.fn() }); + + expect( + screen.getByRole("heading", { + name: "Welcome to Berd. Your place for doing.", + }), + ).toBeInTheDocument(); + }); + + it("moves on to the work-type picker without the runtime", async () => { + renderFlow({ ready: false, failed: false, retry: vi.fn() }); + + await userEvent.click(screen.getByRole("button", { name: "Let’s go" })); + + expect( + screen.getByRole("heading", { + name: "What type of work will you use Berd for?", + }), + ).toBeInTheDocument(); + }); + + it("waits for the runtime before agent adoption can call ACP", async () => { + dispatchOnboarding({ + type: "set-work-types", + workTypeIds: ["engineering"], + }); + dispatchOnboarding({ type: "go-to", step: "recommendations" }); + renderFlow({ ready: false, failed: false, retry: vi.fn() }); + + await userEvent.click( + screen.getByRole("button", { name: "Getting ready…" }), + ); + + expect(mockListPersonas).not.toHaveBeenCalled(); + expect(mockCreatePersona).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/onboarding/ui/OnboardingFlow.tsx b/src/features/onboarding/ui/OnboardingFlow.tsx index 73247c981..f1f34972d 100644 --- a/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/src/features/onboarding/ui/OnboardingFlow.tsx @@ -21,6 +21,7 @@ import { isWorkTypeId, recommendationsForWorkTypes, type CuratedHarnessId, + type OnboardingRuntimeState, type RecommendedAgent, useOnboardingState, } from "../model"; @@ -77,7 +78,16 @@ function decodeImage(src: string | undefined): void { void image.decode?.().catch(() => {}); } -export function OnboardingFlow() { +interface OnboardingFlowProps { + // The flow renders before AppShell's startup gates, so the chat runtime can + // still be starting — or have failed — while a step is on screen. Adoption is + // the only step that talks to it: welcome, the work-type picker, and the + // harness picker are static, and harness setup runs on native doctor and + // agent-setup commands that do not need the `goosed` sidecar. + runtime: OnboardingRuntimeState; +} + +export function OnboardingFlow({ runtime }: OnboardingFlowProps) { const state = useOnboardingState(); const queryClient = useQueryClient(); const selectedWorkTypes = state.selectedWorkTypeIds.filter(isWorkTypeId); @@ -145,6 +155,7 @@ export function OnboardingFlow() { return ( dispatchOnboarding({ type: "go-to", step: "work-types" })} onKeep={async () => { const result = await adoptAgents(recommendations); diff --git a/src/features/onboarding/ui/RecommendationsStep.test.tsx b/src/features/onboarding/ui/RecommendationsStep.test.tsx index 02dafd8cc..d11b6b506 100644 --- a/src/features/onboarding/ui/RecommendationsStep.test.tsx +++ b/src/features/onboarding/ui/RecommendationsStep.test.tsx @@ -1,7 +1,8 @@ import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { RecommendationsStep } from "./RecommendationsStep"; -import type { RecommendedAgent } from "../model"; +import type { OnboardingRuntimeState, RecommendedAgent } from "../model"; let avatarReady: (() => void) | undefined; @@ -35,20 +36,37 @@ const agent: RecommendedAgent = { workTypeIds: ["engineering"], }; +const readyRuntime: OnboardingRuntimeState = { + ready: true, + failed: false, + retry: () => {}, +}; + +function renderStep( + overrides: { + runtime?: OnboardingRuntimeState; + onKeep?: () => Promise; + onSkip?: () => void; + } = {}, +) { + return render( + {}} + onKeep={overrides.onKeep ?? (async () => {})} + onSkip={overrides.onSkip ?? (() => {})} + />, + ); +} + describe("RecommendationsStep", () => { beforeEach(() => { avatarReady = undefined; }); it("reveals stacked-alpha avatar media through its shared readiness callback", () => { - render( - {}} - onKeep={async () => {}} - onSkip={() => {}} - />, - ); + renderStep(); const media = screen.getByTestId("avatar-media"); expect(media).toHaveClass("opacity-0"); @@ -59,4 +77,48 @@ describe("RecommendationsStep", () => { expect(media).toHaveClass("opacity-100"); expect(media).not.toHaveClass("opacity-0"); }); + + // The step renders before app startup settles, so adoption — the one ACP call + // in onboarding — has to wait for the runtime instead of hanging on it. + it("holds Keep in a pending state while the runtime is still starting", async () => { + const onKeep = vi.fn(async () => {}); + renderStep({ + runtime: { ready: false, failed: false, retry: () => {} }, + onKeep, + }); + + const keep = screen.getByRole("button", { name: "Getting ready…" }); + expect(keep).toBeDisabled(); + await userEvent.click(keep); + expect(onKeep).not.toHaveBeenCalled(); + + // Skipping needs no runtime, so it stays available throughout. + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + }); + + it("offers a runtime retry instead of Keep when startup failed", async () => { + const retry = vi.fn(); + const onKeep = vi.fn(async () => {}); + renderStep({ runtime: { ready: false, failed: true, retry }, onKeep }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Berd couldn’t finish starting, so agents can’t be added yet.", + ); + expect(screen.queryByRole("button", { name: "Keep ’em" })).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(retry).toHaveBeenCalledOnce(); + expect(onKeep).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + }); + + it("adopts agents once the runtime is ready", async () => { + const onKeep = vi.fn(async () => {}); + renderStep({ onKeep }); + + await userEvent.click(screen.getByRole("button", { name: "Keep ’em" })); + + expect(onKeep).toHaveBeenCalledOnce(); + }); }); diff --git a/src/features/onboarding/ui/RecommendationsStep.tsx b/src/features/onboarding/ui/RecommendationsStep.tsx index b2d93de1b..b46e61563 100644 --- a/src/features/onboarding/ui/RecommendationsStep.tsx +++ b/src/features/onboarding/ui/RecommendationsStep.tsx @@ -5,7 +5,7 @@ import { Button } from "@/shared/ui/button"; import { useAvatarImage, useAvatarMedia } from "@/shared/hooks/useAvatarSrc"; import { AvatarMedia } from "@/shared/ui/avatar-media"; import { cn } from "@/shared/lib/cn"; -import type { RecommendedAgent } from "../model"; +import type { OnboardingRuntimeState, RecommendedAgent } from "../model"; import { OnboardingShell } from "./OnboardingShell"; function AgentChoice({ @@ -89,6 +89,9 @@ function AgentChoice({ interface RecommendationsStepProps { agents: RecommendedAgent[]; + // Keeping agents creates personas over ACP, so this step is the one that has + // to wait for the chat runtime the surrounding flow no longer waits for. + runtime: OnboardingRuntimeState; onBack: () => void; onKeep: () => Promise; onSkip: () => void; @@ -96,6 +99,7 @@ interface RecommendationsStepProps { export function RecommendationsStep({ agents, + runtime, onBack, onKeep, onSkip, @@ -105,6 +109,9 @@ export function RecommendationsStep({ const [saving, setSaving] = useState(false); const savingRef = useRef(false); const [error, setError] = useState(null); + // Startup has neither settled nor failed yet: agents cannot be created, but + // the step still renders and Skip still works. + const runtimeStarting = !runtime.ready && !runtime.failed; const keep = async () => { if (savingRef.current) return; @@ -138,14 +145,31 @@ export function RecommendationsStep({ {error}

) : null} - + {runtime.failed ? ( + <> +

+ {t("recommendations.runtimeUnavailable")} +

+ + + ) : ( + // A loading feedback state also disables the button, so Keep cannot + // fire an ACP call while the runtime is still starting. + + )}