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..3629546ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,8 +33,10 @@ 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" infer = "0.19.0" libc = "0.2" log = "0.4.29" @@ -51,7 +53,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 +148,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 @@ -143,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 e61d7cd1a..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; @@ -44,6 +45,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..bcb5ffee2 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,34 @@ 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 (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); @@ -484,6 +516,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, @@ -510,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/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/src/services/installation_cohort.rs b/src-tauri/src/services/installation_cohort.rs new file mode 100644 index 000000000..17f5862de --- /dev/null +++ b/src-tauri/src/services/installation_cohort.rs @@ -0,0 +1,314 @@ +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); + } + + // A probe that answered affirmatively has already proven the install is + // established, so a failure from the other probe cannot unprove it. Only an + // install that is genuinely indeterminate — no affirmative sighting and at + // least one probe that could not answer — is an error. + let cohort = match (current_layout_exists, legacy_layout_exists) { + (Ok(true), _) | (_, Ok(true)) => InstallationCohort::EstablishedBeforeLandingV1, + (Ok(false), Ok(false)) => InstallationCohort::FreshWithLandingV1, + (Err(error), _) | (_, Err(error)) => return Err(error), + }; + + // The marker is a cache of the classification, not its source of truth. A + // full disk or an `fsync` that fails on a network-mounted home directory + // must not turn a detected cohort into `Unknown`, which would route an + // established user into first-run onboarding. + match persist_marker(&marker_path, cohort) { + Ok(published) => Ok(published), + Err(error) => { + log::warn!("Installation cohort marker not persisted, using detected cohort: {error}"); + Ok(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 an_affirmative_probe_outweighs_a_failing_probe() { + let current = tempdir().unwrap(); + assert_eq!( + initialize_installation_cohort( + current.path(), + Ok(true), + Err("legacy directories unavailable".into()), + ) + .unwrap(), + InstallationCohort::EstablishedBeforeLandingV1 + ); + + let legacy = tempdir().unwrap(); + assert_eq!( + initialize_installation_cohort( + legacy.path(), + Err("metadata unavailable".into()), + Ok(true), + ) + .unwrap(), + InstallationCohort::EstablishedBeforeLandingV1 + ); + } + + #[test] + fn an_unclassifiable_installation_stays_an_error() { + let both = tempdir().unwrap(); + assert!(initialize_installation_cohort( + both.path(), + Err("metadata unavailable".into()), + Err("legacy directories unavailable".into()), + ) + .is_err()); + + let legacy_only = tempdir().unwrap(); + assert!(initialize_installation_cohort( + legacy_only.path(), + Ok(false), + Err("legacy directories unavailable".into()), + ) + .is_err()); + } + + #[test] + #[cfg(unix)] + fn reports_the_detected_cohort_when_the_marker_cannot_be_written() { + use std::os::unix::fs::PermissionsExt; + + let root = tempdir().unwrap(); + let original_permissions = fs::metadata(root.path()).unwrap().permissions(); + let mut read_only_permissions = original_permissions.clone(); + read_only_permissions.set_mode(0o500); + fs::set_permissions(root.path(), read_only_permissions).unwrap(); + + let cohort = initialize_installation_cohort(root.path(), Ok(true), Ok(false)); + + fs::set_permissions(root.path(), original_permissions).unwrap(); + + assert_eq!( + cohort.unwrap(), + InstallationCohort::EstablishedBeforeLandingV1 + ); + assert!(!root.path().join(MARKER_FILE_NAME).exists()); + } + + #[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-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.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..78dd4f134 100644 --- a/src/app/AppShell.startupDiagnostics.test.tsx +++ b/src/app/AppShell.startupDiagnostics.test.tsx @@ -1,11 +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, + resetOnboarding, +} from "@/features/onboarding/model"; import { AppShell } from "./AppShell"; const mocks = vi.hoisted(() => ({ @@ -97,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: { @@ -117,6 +137,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"; @@ -197,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 806861b20..410e680c6 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, @@ -219,10 +220,12 @@ 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"; +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"; @@ -771,9 +774,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>( () => @@ -2955,9 +2955,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 }, ); @@ -4317,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" && @@ -4935,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 ; } @@ -4945,13 +4982,6 @@ export function AppShell({ ); } - const shouldShowOnboarding = - onboardingExperiment?.enabled === true && - onboardingState.lifecycle !== "completed"; - if (shouldShowOnboarding) { - return ; - } - return ( ({ + 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/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/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..35c5842e2 --- /dev/null +++ b/src/features/home/lib/homePinTelemetry.test.ts @@ -0,0 +1,484 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Layout, LayoutItem } 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 confirmed layout that `layoutItemsToHomeWidgets` cannot map: the item is not + * a layout item at all, so mapping it throws. The real seam the containment has + * to survive — the gate maps the whole confirmed layout on every record and + * flush. + */ +function setUnmappableConfirmedLayout(): void { + useHomeWidgetStore.setState({ + loadStatus: "ready", + itemRevision: 1, + lastConfirmedLayout: { + ...layoutOf([]), + items: [null as unknown as LayoutItem], + }, + }); +} + +/** 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("never throws out of a record, and keeps recording afterwards", () => { + setUnmappableConfirmedLayout(); + + // Both anchors run synchronously inside the pin/unpin handlers, after the + // optimistic mutation applied: a throw here is a false failure toast, an + // aborted half-batch, or an uncaught canvas handler error. + expect(() => + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }), + ).not.toThrow(); + expect(() => + recordHomeItemUnpinIntent({ kind: "skill", itemId: "skill-1" }), + ).not.toThrow(); + + settleSave("confirmed"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + + // A dropped intent must not leave the gate broken for the next one. + setConfirmedLayout([]); + recordHomeItemPinIntent({ kind: "chat", itemId: "session-2" }); + setConfirmedLayout([chatPin("session-2")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).toHaveBeenCalledOnce(); + expect(trackHomeItemPinned).toHaveBeenCalledWith({ kind: "chat" }); + }); + + it("never throws out of a flush, and still spends the intent", () => { + setConfirmedLayout([]); + recordHomeItemPinIntent({ kind: "chat", itemId: "session-1" }); + + // The confirmed layout goes unreadable between recording and resolution. + setUnmappableConfirmedLayout(); + expect(() => settleSave("confirmed")).not.toThrow(); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + expect(trackHomeItemUnpinned).not.toHaveBeenCalled(); + + // Resolution is terminal even when it fails: a held-back intent would let + // the next save's transition — possibly another window's — read as this + // user's action arriving. + setConfirmedLayout([chatPin("session-1")]); + settleSave("confirmed"); + + expect(trackHomeItemPinned).not.toHaveBeenCalled(); + }); + + 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..eb2138180 --- /dev/null +++ b/src/features/home/lib/homePinTelemetry.ts @@ -0,0 +1,262 @@ +/** + * 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 { perfLog } from "@/shared/lib/perfLog"; +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; + } + + // Drain before resolving, so resolution stays terminal even if it throws: + // holding a spent intent back would leave a later transition — one another + // window made — looking like this user's action arriving. + const intents = [...pendingIntents.values()]; + pendingIntents.clear(); + + // Observation only. This runs from the save-lifecycle listeners rather than a + // product caller, so a throw cannot break a pin surface, but it maps the whole + // confirmed layout, and a corrupt one should stay a dropped event rather than + // become window.onerror noise. + try { + const confirmed = confirmedInstances(); + + 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 }); + } + } + } catch (error) { + perfLog(`[telemetry] home pin intent flush failed: ${String(error)}`); + } +} + +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 { + // Observation only, structurally: recording resolves a chat's pin identity and + // maps the whole confirmed layout, and it runs synchronously inside the pin and + // unpin handlers *after* their optimistic store mutation has already applied. + // A throw would therefore turn a change that succeeded into a false failure + // toast (usePinToHomeWidget), an aborted half-batch (pinBatchToHome / + // unpinBatchFromHome), or an uncaught error out of HomeView's canvas handlers. + try { + 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, + ), + }); + } catch (error) { + perfLog(`[telemetry] home pin intent failed: ${String(error)}`); + } +} + +/** 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..ef4eb086e 100644 --- a/src/features/home/ui/HomeView.tsx +++ b/src/features/home/ui/HomeView.tsx @@ -7,7 +7,13 @@ import { BERDY_ONBOARDING_EXPERIMENT_ID } from "@/features/experiments/experimen import { useExperiment } from "@/features/experiments/experimentPreferences"; import { useSetTopBarActions } from "@/app/contexts/TopBarActionsContext"; import type { SkillInfo } from "@/features/skills/api/skills"; +import { perfLog } from "@/shared/lib/perfLog"; 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 +129,7 @@ export function HomeView({ retryInitialize, copyErrorDetails, widgetMutations, + seedWidget, camera, constraints, saveCamera, @@ -228,7 +235,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 +248,7 @@ export function HomeView({ starterAgentSeedAttemptedRef.current = true; pendingStarterAgentSeedRef.current = true; } - }, [instances, loadStatus, personas, personasLoading, widgetMutations]); + }, [instances, loadStatus, personas, personasLoading, seedWidget]); useEffect(() => { if ( @@ -780,23 +787,79 @@ 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; + } + // Observation only: the widget is already on the canvas by now, so a throw + // would surface as an error out of the picker's add handler for an add + // that succeeded. + try { + const target = entityPinTargetFromWidget(type, state); + if (target) { + recordHomeItemPinIntent({ kind: target.kind, itemId: target.id }); + } + } catch (error) { + perfLog(`[telemetry] home pin intent failed: ${String(error)}`); + } + return added; + }, + [addWidget], + ); + + const removeWidgetWithTelemetry = useCallback( + (id) => { + // Resolve the entity before removal, while the instance still exists — + // and contain that separately from the removal, so a resolution throw + // degrades to "no intent recorded" rather than a remove button that + // silently does nothing. + let target: ReturnType = null; + try { + const instance = useHomeWidgetStore + .getState() + .instances.find((candidate) => candidate.id === id); + target = instance + ? entityPinTargetFromWidget(instance.type, instance.state) + : null; + } catch (error) { + perfLog( + `[telemetry] home unpin target resolution failed: ${String(error)}`, + ); + } + 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 +871,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/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/index.ts b/src/features/onboarding/model/index.ts index 128302199..c76b766ae 100644 --- a/src/features/onboarding/model/index.ts +++ b/src/features/onboarding/model/index.ts @@ -1,3 +1,4 @@ export * from "./catalog"; export * from "./onboardingState"; export * from "./onboardingStore"; +export * from "./runtime"; diff --git a/src/features/onboarding/model/onboardingState.test.ts b/src/features/onboarding/model/onboardingState.test.ts index cbf0576b4..e51b72f6e 100644 --- a/src/features/onboarding/model/onboardingState.test.ts +++ b/src/features/onboarding/model/onboardingState.test.ts @@ -67,6 +67,20 @@ describe("onboardingReducer", () => { expect(back.completedHarnessSetupIds).toEqual(["claude-acp"]); }); + it("records the usage-data answer given on the welcome page", () => { + const declined = onboardingReducer( + { ...INITIAL_ONBOARDING_STATE }, + { type: "set-share-usage-data", shareUsageData: false }, + ); + expect(declined.shareUsageData).toBe(false); + expect( + onboardingReducer(declined, { + type: "set-share-usage-data", + shareUsageData: true, + }).shareUsageData, + ).toBe(true); + }); + it("replay clears choices but preserves durable setup outcomes", () => { const completed = onboardingReducer( { @@ -75,13 +89,17 @@ describe("onboardingReducer", () => { selectedAgentId: "builder", selectedHarnessId: "goose", completedHarnessSetupIds: ["goose"], + shareUsageData: false, }, { type: "complete" }, ); + // The consent answer is a durable outcome like configured providers: a + // replayed tour shows what was chosen, and reset returns to "never asked". expect(onboardingReducer(completed, { type: "replay" })).toEqual({ ...INITIAL_ONBOARDING_STATE, lifecycle: "in-progress", completedHarnessSetupIds: ["goose"], + shareUsageData: false, }); expect(onboardingReducer(completed, { type: "reset" })).toEqual( INITIAL_ONBOARDING_STATE, diff --git a/src/features/onboarding/model/onboardingState.ts b/src/features/onboarding/model/onboardingState.ts index 623030c58..ae257cbc1 100644 --- a/src/features/onboarding/model/onboardingState.ts +++ b/src/features/onboarding/model/onboardingState.ts @@ -17,6 +17,10 @@ export interface OnboardingState { selectedAgentId: string | null; selectedHarnessId: string | null; completedHarnessSetupIds: string[]; + // null means the welcome consent ceremony has never been completed; the + // answer is recorded when the user advances past the landing page, not per + // checkbox toggle, so declining is still just leaving the page. + shareUsageData: boolean | null; } export const INITIAL_ONBOARDING_STATE: Readonly = { @@ -26,6 +30,7 @@ export const INITIAL_ONBOARDING_STATE: Readonly = { selectedAgentId: null, selectedHarnessId: null, completedHarnessSetupIds: [], + shareUsageData: null, }; export type OnboardingAction = @@ -38,6 +43,7 @@ export type OnboardingAction = | { type: "select-agent"; agentId: string | null } | { type: "select-harness"; harnessId: string | null } | { type: "complete-harness-setup"; harnessId: string } + | { type: "set-share-usage-data"; shareUsageData: boolean } | { type: "complete" } | { type: "replay" } | { type: "reset" }; @@ -107,6 +113,8 @@ export function onboardingReducer( action.harnessId, ]), }; + case "set-share-usage-data": + return { ...state, shareUsageData: action.shareUsageData }; case "complete": return { ...state, lifecycle: "completed", step: "complete" }; case "replay": @@ -115,8 +123,10 @@ export function onboardingReducer( lifecycle: "in-progress", step: "welcome", // Replay resets the presentation, not durable product outcomes. A - // provider configured during onboarding remains configured. + // provider configured during onboarding remains configured, and a + // consent answer already given stays answered. completedHarnessSetupIds: state.completedHarnessSetupIds, + shareUsageData: state.shareUsageData, }; case "reset": return { ...INITIAL_ONBOARDING_STATE }; diff --git a/src/features/onboarding/model/onboardingStore.test.ts b/src/features/onboarding/model/onboardingStore.test.ts index 7005a4c1d..ecc9aa9aa 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" }); @@ -68,6 +146,35 @@ describe("onboarding persistence", () => { expect(getOnboardingSnapshot()).toEqual(INITIAL_ONBOARDING_STATE); }); + it.each([ + ["a recorded opt-out", false, false], + ["a recorded opt-in", true, true], + ["an unanswered ceremony", null, null], + ["a record predating the field", undefined, null], + ])("hydrates %s as the usage-data answer", (_label, persisted, expected) => { + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + version: ONBOARDING_STORAGE_VERSION, + state: { ...INITIAL_ONBOARDING_STATE, shareUsageData: persisted }, + }), + ); + + expect(getOnboardingSnapshot().shareUsageData).toBe(expected); + }); + + it("rejects a persisted non-boolean usage-data answer", () => { + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + version: ONBOARDING_STORAGE_VERSION, + state: { ...INITIAL_ONBOARDING_STATE, shareUsageData: "yes" }, + }), + ); + + expect(getOnboardingSnapshot()).toEqual(INITIAL_ONBOARDING_STATE); + }); + it("hydrates catalog-backed IDs and deduplicates persisted arrays", () => { window.localStorage.setItem( ONBOARDING_STORAGE_KEY, diff --git a/src/features/onboarding/model/onboardingStore.ts b/src/features/onboarding/model/onboardingStore.ts index cb01cb2d5..f6db84d44 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,41 @@ 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.shareUsageData === undefined || + state.shareUsageData === null || + typeof state.shareUsageData === "boolean") && + (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 +169,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 { @@ -108,6 +181,7 @@ function parsePersisted(raw: string | null): OnboardingState { completedHarnessSetupIds: [ ...new Set(state.completedHarnessSetupIds ?? []), ], + shareUsageData: state.shareUsageData ?? null, }; } catch { return { ...INITIAL_ONBOARDING_STATE }; @@ -233,3 +307,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/model/runtime.ts b/src/features/onboarding/model/runtime.ts new file mode 100644 index 000000000..37737ee75 --- /dev/null +++ b/src/features/onboarding/model/runtime.ts @@ -0,0 +1,14 @@ +/** + * Chat-runtime startup as the onboarding steps see it. + * + * Onboarding renders ahead of `AppShell`'s startup gates so the landing page — + * the surface that asks for telemetry consent — never waits on the `goosed` + * sidecar. Steps that do call the runtime take this instead of assuming it is + * up: `ready` means startup settled without an issue, `failed` means it settled + * with one, and `retry` re-runs it. + */ +export interface OnboardingRuntimeState { + ready: boolean; + failed: boolean; + retry: () => void; +} diff --git a/src/features/onboarding/ui/OnboardingFlow.test.tsx b/src/features/onboarding/ui/OnboardingFlow.test.tsx new file mode 100644 index 000000000..3e7d325c5 --- /dev/null +++ b/src/features/onboarding/ui/OnboardingFlow.test.tsx @@ -0,0 +1,294 @@ +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 type { OnboardingRuntimeState } from "../model"; +import { OnboardingFlow } from "./OnboardingFlow"; + +const mockCreatePersona = vi.hoisted(() => vi.fn()); +const mockListPersonas = vi.hoisted(() => vi.fn()); +const mockTrackAgentCreateCompleted = vi.hoisted(() => vi.fn()); +const mockUpdateTelemetryEnabled = vi.hoisted(() => + vi.fn(async () => undefined), +); + +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 }) => ( + + ), +})); + +vi.mock("@/features/projects/artifact/ProjectArtifactPreview", () => ({ + ProjectArtifactPreview: () =>
, +})); + +vi.mock("@/shared/telemetry/consent", () => ({ + updateTelemetryEnabled: mockUpdateTelemetryEnabled, + telemetryConsentEnforced: () => false, +})); + +// Pin the telemetry capability on so the welcome consent checkbox renders +// regardless of the test build's feature profile. +vi.mock("@/shared/profile/capabilities", () => ({ + useProfileCapability: () => true, +})); + +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, + }; +} + +const readyRuntime: OnboardingRuntimeState = { + ready: true, + failed: false, + retry: vi.fn(), +}; + +function renderFlow(runtime: OnboardingRuntimeState = readyRuntime) { + 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); + }); +}); + +// The welcome consent answer lives in the onboarding store, so Back +// navigation must show what was actually chosen and can never launder an +// explicit opt-out back into the default-ON write. +describe("OnboardingFlow welcome consent round-trip", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + resetOnboardingStoreForTests(); + dispatchOnboarding({ type: "start" }); + }); + + it("keeps an opt-out unchecked across Back and never writes consent ON", async () => { + renderFlow(); + const user = userEvent.setup(); + + await user.click( + screen.getByRole("checkbox", { name: /share anonymous usage data/i }), + ); + await user.click(screen.getByRole("button", { name: "Let’s go" })); + expect( + screen.getByRole("heading", { + name: "What type of work will you use Berd for?", + }), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Go back" })); + expect( + screen.getByRole("checkbox", { name: /share anonymous usage data/i }), + ).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Let’s go" })); + + // Each advance past the unchecked box re-asserts disabled; no + // interleaving of this journey may produce a write of true. + expect(mockUpdateTelemetryEnabled.mock.calls).toEqual([[false], [false]]); + }); + + it("does not rewrite an untouched opt-in when revisiting the landing page", async () => { + renderFlow(); + const user = userEvent.setup(); + + await user.click(screen.getByRole("button", { name: "Let’s go" })); + await user.click(screen.getByRole("button", { name: "Go back" })); + expect( + screen.getByRole("checkbox", { name: /share anonymous usage data/i }), + ).toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Let’s go" })); + + // Only the first ceremony writes; the untouched pass back through + // welcome cannot overwrite a Settings change made in the meantime. + expect(mockUpdateTelemetryEnabled.mock.calls).toEqual([[true]]); + }); +}); + +// 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 d9f2796d6..f783aab96 100644 --- a/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/src/features/onboarding/ui/OnboardingFlow.tsx @@ -15,11 +15,13 @@ 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, recommendationsForWorkTypes, type CuratedHarnessId, + type OnboardingRuntimeState, type RecommendedAgent, useOnboardingState, } from "../model"; @@ -54,6 +56,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 { @@ -70,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); @@ -114,6 +131,10 @@ export function OnboardingFlow() { if (state.step === "welcome") { return ( + dispatchOnboarding({ type: "set-share-usage-data", shareUsageData }) + } onStart={() => dispatchOnboarding({ type: "go-to", step: "work-types" }) } @@ -138,6 +159,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/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/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. + + )} - -
+ {t("welcome.getStarted")} + + + {consentHidden ? null : ( +
+ setVisitAnswer(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/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..ea8be5819 --- /dev/null +++ b/src/features/settings/ui/TelemetryConsentRow.tsx @@ -0,0 +1,98 @@ +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"; +import { + ensureTelemetryConsentLoaded, + telemetryConsentEnforced, + 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 +// 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); + 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 + // 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 ( + + + {t("privacy.telemetry.description")}{" "} + + + } + > + void handleEnabledChange(next)} + disabled={!loaded || saving} + aria-label={t("privacy.telemetry.label")} + /> + + + + ); +} 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 new file mode 100644 index 000000000..532d500e8 --- /dev/null +++ b/src/features/settings/ui/__tests__/TelemetryConsentRow.test.tsx @@ -0,0 +1,193 @@ +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("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(); + + 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..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,32 +76,55 @@ 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("does not start launch telemetry for session windows", async () => { + 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"); 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 +151,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..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"; @@ -135,6 +138,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,30 +169,56 @@ 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. + // 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/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/onboarding.json b/src/shared/i18n/locales/en/onboarding.json index 4970c3430..76f9ae630 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?", @@ -50,8 +49,11 @@ "title": "Your recommended agents", "description": "Meet a few agents picked for your work. You can configure them later.", "adopting": "Adopting…", + "waitingForRuntime": "Getting ready…", "keep": "Keep ’em", "skip": "Skip for now", + "runtimeUnavailable": "Berd couldn’t finish starting, so agents can’t be added yet.", + "retryRuntime": "Try again", "genericError": "Some agents could not be adopted. Try again.", "adoptionError": "Couldn’t adopt {{names}}. The other agents were kept; try again for the rest.", "agents": { diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index efd535692..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", @@ -464,6 +459,23 @@ }, "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", + "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" + }, "projects": { "empty": "Archived projects will show here.", "sectionTitle": "Archived projects", diff --git a/src/shared/i18n/locales/es/onboarding.json b/src/shared/i18n/locales/es/onboarding.json index 5898093dc..1ddc78f58 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?", @@ -50,8 +49,11 @@ "title": "Tus agentes recomendados", "description": "Conoce algunos agentes elegidos para tu trabajo. Puedes configurarlos más tarde.", "adopting": "Añadiendo…", + "waitingForRuntime": "Preparando…", "keep": "Conservar", "skip": "Omitir por ahora", + "runtimeUnavailable": "Berd no terminó de iniciarse, así que aún no se pueden añadir agentes.", + "retryRuntime": "Reintentar", "genericError": "No se pudieron añadir algunos agentes. Inténtalo de nuevo.", "adoptionError": "No se pudo añadir a {{names}}. Los demás agentes se conservaron; inténtalo de nuevo para añadir el resto.", "agents": { diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 4d9bb8291..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", @@ -467,6 +462,23 @@ }, "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", + "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" + }, "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..8211f48f6 --- /dev/null +++ b/src/shared/telemetry/consent.test.ts @@ -0,0 +1,161 @@ +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"); +} + +// Lets a test order the boot read's resolution against a consent write. +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // The rejection path is asserted after the fact, so nothing awaits this + // promise directly; swallow it here to keep it from surfacing as unhandled. + promise.catch(() => {}); + return { promise, resolve, reject }; +} + +function flushMicrotasks() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +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("does not let a stale read clobber a write that already settled", async () => { + // The welcome page's fire-and-forget write can land while the boot read is + // still in flight, so the read must not settle a store a write already owns. + const read = deferred<{ enabled: boolean }>(); + getTelemetrySettings.mockReturnValue(read.promise); + setTelemetryEnabled.mockResolvedValue({ enabled: true }); + const consent = await loadConsent(); + + consent.ensureTelemetryConsentLoaded(); + await consent.updateTelemetryEnabled(true); + + read.resolve({ enabled: false }); + await flushMicrotasks(); + + expect(consent.telemetryConsentGranted()).toBe(true); + expect(consent.useTelemetryConsentStore.getState()).toEqual({ + loaded: true, + enabled: true, + }); + }); + + it("does not let a failed read force-disable consent a write already granted", async () => { + const read = deferred<{ enabled: boolean }>(); + getTelemetrySettings.mockReturnValue(read.promise); + setTelemetryEnabled.mockResolvedValue({ enabled: true }); + const consent = await loadConsent(); + + consent.ensureTelemetryConsentLoaded(); + await consent.updateTelemetryEnabled(true); + + read.reject(new Error("state went away")); + await flushMicrotasks(); + + expect(consent.telemetryConsentGranted()).toBe(true); + 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..9e4c60629 --- /dev/null +++ b/src/shared/telemetry/consent.ts @@ -0,0 +1,114 @@ +/** + * 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. + * + * The read only ever settles a store that is still unsettled; a user write + * that lands first wins. Because the `loadStarted` guard admits exactly one + * read, `loaded` being true at resolution time can only mean + * `updateTelemetryEnabled` settled first, and that write's stored value + * reflects what the native side holds *after* this read's snapshot was taken. + * Overwriting it would silently revoke consent the user just granted (or, + * on the failure branch, force-disable it) for the rest of the session. + */ +export function ensureTelemetryConsentLoaded(): void { + if (loadStarted || telemetryConsentEnforced()) return; + loadStarted = true; + const settleFromRead = (enabled: boolean) => { + if (useTelemetryConsentStore.getState().loaded) return; + useTelemetryConsentStore.setState({ loaded: true, enabled }); + }; + void getTelemetrySettings().then( + ({ enabled }) => settleFromRead(enabled), + (error) => { + // Logged unconditionally: a failed read is worth recording even when a + // user write already superseded the value it would have settled. + perfLog( + `[telemetry] failed to load the telemetry setting: ${String(error)}`, + ); + settleFromRead(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. + * + * This overwrites unconditionally — a completed write is the freshest fact + * about the persisted setting, so it outranks the boot read either way round. + */ +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/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} )} 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