From ace8c19816b19fbfe22326e6a091d40c8bbc3dbf Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 16 Jul 2026 15:48:16 -0700 Subject: [PATCH 1/2] feat(analytics): add consent-gated activation-funnel analytics via PostHog Installs are up 615% but 7-day retention is ~0% and we had no analytics SDK to see where users drop off. Adds a thin PostHog wrapper (src/lib/analytics.ts) that tracks app_opened, connection_form_submitted, connection_attempted, connection_succeeded/failed (with a coarse error_class, e.g. the known 401 auth bug), message_sent, and response_received. PostHog was chosen over Aptabase for its GMS-free JS-only RN SDK (fine for the F-Droid/no-Firebase build), EU-hosted/self-host option, and generous free tier. Analytics shares the exact same consent flag as Sentry (telemetry.ts now gates both) so zero network calls happen without explicit opt-in. Requires a new EXPO_PUBLIC_POSTHOG_KEY CI secret (wired into build.yml, publish-fdroid.yml, publish-play-store.yml, and documented in publish-app-store.yml alongside the existing Sentry secrets). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E --- .github/workflows/build.yml | 1 + .github/workflows/publish-app-store.yml | 7 +- .github/workflows/publish-fdroid.yml | 1 + .github/workflows/publish-play-store.yml | 1 + app.json | 2 +- app/_layout.tsx | 3 + app/connection/add.tsx | 7 ++ package-lock.json | 140 +++++++++++++++++++++-- package.json | 4 +- src/lib/analytics.ts | 139 ++++++++++++++++++++++ src/lib/telemetry.ts | 17 ++- src/stores/connections.ts | 7 +- src/stores/events.ts | 2 + src/stores/sessions.ts | 2 + 14 files changed, 309 insertions(+), 24 deletions(-) create mode 100644 src/lib/analytics.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc18846..b3bf176 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,6 +33,7 @@ jobs: runs-on: ubuntu-latest env: EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/publish-app-store.yml b/.github/workflows/publish-app-store.yml index 82496cc..b7aae85 100644 --- a/.github/workflows/publish-app-store.yml +++ b/.github/workflows/publish-app-store.yml @@ -23,9 +23,10 @@ # provisioning profile so CI never needs to prompt): # eas login && eas build --platform ios --profile production # -# Optional crash reporting belongs in the EAS `production` environment because the -# iOS bundle is built on a remote EAS worker. Configure EXPO_PUBLIC_SENTRY_DSN, -# SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT in Expo before releasing. +# Optional crash reporting + analytics belong in the EAS `production` environment +# because the iOS bundle is built on a remote EAS worker. Configure +# EXPO_PUBLIC_SENTRY_DSN, SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT, and +# EXPO_PUBLIC_POSTHOG_KEY (PostHog project API key) in Expo before releasing. # # Build number is managed remotely by EAS (eas.json: cli.appVersionSource=remote, # build.production.ios.autoIncrement=buildNumber). The workflow only injects the diff --git a/.github/workflows/publish-fdroid.yml b/.github/workflows/publish-fdroid.yml index 5f0ef42..5b5aa07 100644 --- a/.github/workflows/publish-fdroid.yml +++ b/.github/workflows/publish-fdroid.yml @@ -12,6 +12,7 @@ jobs: contents: write env: EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/publish-play-store.yml b/.github/workflows/publish-play-store.yml index 8b03528..aeca5a3 100644 --- a/.github/workflows/publish-play-store.yml +++ b/.github/workflows/publish-play-store.yml @@ -31,6 +31,7 @@ jobs: runs-on: ubuntu-latest env: EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} + EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/app.json b/app.json index f66fb07..d74d631 100644 --- a/app.json +++ b/app.json @@ -80,4 +80,4 @@ "reactCompiler": true } } -} \ No newline at end of file +} diff --git a/app/_layout.tsx b/app/_layout.tsx index dee0e2c..ef6e758 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,7 @@ import { TelemetryConsentModal } from "../src/components/TelemetryConsentModal" import * as notifications from "../src/lib/notifications" import { addBreadcrumb, wrap } from "../src/lib/sentry" import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry" +import { initAnalytics, trackAppOpened } from "../src/lib/analytics" const queryClient = new QueryClient() @@ -51,6 +52,8 @@ function RootLayout() { initSentry() addBreadcrumb({ category: "app.lifecycle", message: "app started" }) }) + initAnalytics() + trackAppOpened() setConsentState("decided") } else if (state === "denied") { addBreadcrumb({ category: "app.lifecycle", message: "app started (telemetry off)" }) diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 34416f3..064de6a 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -18,6 +18,7 @@ import type { ConnectionType } from "../../src/lib/types" import { probeConnection, shareReport } from "../../src/lib/diagnostics" import { captureDiagnostic } from "../../src/lib/sentry" import { parseUrl } from "../../src/lib/diagnostics-classify" +import { AnalyticsEvent, track } from "../../src/lib/analytics" export default function AddConnectionScreen() { const colorScheme = useColorScheme() @@ -67,6 +68,7 @@ export default function AddConnectionScreen() { return } + track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "quick" }) setIsConnecting(true) // Test connection first @@ -127,6 +129,11 @@ export default function AddConnectionScreen() { return } + track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "advanced" }) + // Advanced mode saves directly without a pre-flight health check (see + // useConnections.addConnection), so unlike quick-connect there is no + // success/failure signal to report here — only that an attempt was made. + track(AnalyticsEvent.ConnectionAttempted) setIsConnecting(true) await addConnection( { diff --git a/package-lock.json b/package-lock.json index 5d580ab..002c771 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@opencode-ai/mobile", - "version": "0.4.3", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencode-ai/mobile", - "version": "0.4.3", + "version": "0.4.7", "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.0.14", "@sentry/react-native": "~6.14.0", "@tanstack/react-query": "^5.62.0", @@ -26,6 +27,7 @@ "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", + "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -2221,6 +2223,21 @@ "react-native": "*" } }, + "node_modules/@posthog/core": { + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.43.1.tgz", + "integrity": "sha512-hGM8f5sp3we6Em/RQHXbmyYm554hUx9+9jhf92ZQgDS4/xW72KHyZiO9wcFye+qAx2cJAOhOCDIznmO1FV8IbA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.396.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.396.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.396.0.tgz", + "integrity": "sha512-S0izvq+Hqvz2GPoYJO4x7fAtlCSHNN+JiugpBmQRdG7RrYW7kZ+GipmbTItjPSKuXoJx4KbEnxBvr6NHhoZV4w==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2408,6 +2425,18 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -4828,6 +4857,16 @@ "expo": "*" } }, + "node_modules/expo-file-system": { + "version": "19.0.23", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz", + "integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-image-loader": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", @@ -5466,16 +5505,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.22", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", - "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-font": { "version": "14.0.11", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", @@ -6243,6 +6272,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7030,6 +7068,18 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8021,6 +8071,72 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-react-native": { + "version": "4.57.0", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.57.0.tgz", + "integrity": "sha512-HcTk59aanBZmtC1gE3ermZL8nxA+5ID2SnvPD8jU0LyXM110faxCcm7VIMjvpSdd2hqxCzz5/kUm4b/brmFI7Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.43.0", + "@posthog/types": "^1.396.0" + }, + "peerDependencies": { + "@posthog/react-native-plugin": ">= 2.2.0", + "@react-native-async-storage/async-storage": ">=1.0.0", + "@react-navigation/native": ">= 5.0.0", + "expo-application": ">= 4.0.0", + "expo-device": ">= 4.0.0", + "expo-file-system": ">= 13.0.0", + "expo-localization": ">= 11.0.0", + "posthog-react-native-session-replay": ">= 1.6.0", + "react-native-device-info": ">= 10.0.0", + "react-native-localize": ">= 3.0.0", + "react-native-navigation": ">= 6.0.0", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-svg": ">= 15.0.0" + }, + "peerDependenciesMeta": { + "@posthog/react-native-plugin": { + "optional": true + }, + "@react-native-async-storage/async-storage": { + "optional": true + }, + "@react-navigation/native": { + "optional": true + }, + "expo-application": { + "optional": true + }, + "expo-device": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-localization": { + "optional": true + }, + "posthog-react-native-session-replay": { + "optional": true + }, + "react-native-device-info": { + "optional": true + }, + "react-native-localize": { + "optional": true + }, + "react-native-navigation": { + "optional": true + }, + "react-native-safe-area-context": { + "optional": true + }, + "react-native-svg": { + "optional": true + } + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/package.json b/package.json index e6cd92b..929f4f9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "5.2.8", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.0.14", "@sentry/react-native": "~6.14.0", "@tanstack/react-query": "^5.62.0", @@ -30,6 +31,7 @@ "expo-secure-store": "~15.0.8", "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", + "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -55,4 +57,4 @@ ] } } -} \ No newline at end of file +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..6325c51 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,139 @@ +// Centralised PostHog wrapper for activation-funnel analytics. +// +// Mirrors sentry.ts's shape and guarantees: +// 1. Strict no-op when no API key is configured (dev/CI builds need no secrets). +// 2. Strict no-op when the user has not granted telemetry consent — this +// module never calls PostHog.init/capture on its own; it is only ever +// driven by ./telemetry.ts, which gates BOTH Sentry and analytics behind +// the exact same "opencode_telemetry_consent" flag. +// 3. No PII in event properties: never pass server URLs, tokens, prompts, +// or file contents. Only coarse, enumerated event names + small typed +// properties (booleans, enums, counts). +// +// Chosen SDK: PostHog (posthog-react-native), self-instantiated (no +// PostHogProvider / autocapture) so the app controls exactly what is sent — +// same "explicit event, no magic" posture as sentry.ts. + +import PostHog from "posthog-react-native" +import * as SecureStore from "expo-secure-store" +import { log } from "./logbuffer" + +const API_KEY = process.env.EXPO_PUBLIC_POSTHOG_KEY +// EU by default (GDPR-friendly region for opencode's mostly-EU/self-hosted user base). +// Override with EXPO_PUBLIC_POSTHOG_HOST for a self-hosted instance. +const HOST = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com" + +const FIRST_OPEN_KEY = "opencode_analytics_first_open_done" + +let client: PostHog | null = null +let enabled = false + +/** Coarse, non-identifying failure buckets — never include the raw error string + * (it may embed hostnames/tokens/paths). Reuses the vocabulary already + * established by diagnostics-classify.ts's Classification type. */ +export type ConnectionErrorClass = + | "malformed-url" + | "no-internet" + | "server-unreachable" + | "unauthorized" + | "tls-error" + | "timeout" + | "unknown" + +/** Activation-funnel events. Keep this list in 1:1 sync with the funnel steps + * tracked in the product analytics dashboard. */ +export enum AnalyticsEvent { + /** App process started and the user has an existing telemetry decision of "granted". */ + AppOpened = "app_opened", + /** User tapped Connect/Save with a non-empty server URL (quick or advanced mode). */ + ConnectionFormSubmitted = "connection_form_submitted", + /** A real network call to test/establish the connection started. */ + ConnectionAttempted = "connection_attempted", + /** The connection attempt succeeded (health check / project fetch responded). */ + ConnectionSucceeded = "connection_succeeded", + /** The connection attempt failed. Always paired with `error_class`. */ + ConnectionFailed = "connection_failed", + /** User sent a prompt/message to an agent session (excludes slash commands). */ + MessageSent = "message_sent", + /** An agent response finished streaming (session transitioned busy -> idle). */ + ResponseReceived = "response_received", +} + +export function initAnalytics() { + if (enabled) return + if (!API_KEY) { + log.info("analytics", "no API key configured — analytics disabled") + return + } + try { + client = new PostHog(API_KEY, { + host: HOST, + // We call track() explicitly at each funnel step — no implicit capture. + captureAppLifecycleEvents: false, + }) + enabled = true + log.info("analytics", "initialized", `host=${HOST}`) + } catch (e) { + log.warn("analytics", "init failed", String(e)) + } +} + +export async function shutdownAnalytics() { + if (!enabled || !client) return + enabled = false + const c = client + client = null + try { + await c.shutdown() + } catch (e) { + log.warn("analytics", "shutdown failed", String(e)) + } + log.info("analytics", "disabled by user") +} + +export function analyticsEnabled(): boolean { + return enabled +} + +/** Flat, JSON-safe event properties — keep it to primitives so nothing + * accidentally nests an object that could carry a URL/token. */ +export type AnalyticsProps = Record + +/** No-op unless consent has been granted (initAnalytics() was called) and a + * key is configured. Never throws. */ +export function track(event: AnalyticsEvent, props?: AnalyticsProps) { + if (!enabled || !client) return + try { + client.capture(event, props) + } catch (e) { + log.warn("analytics", "capture failed", String(e)) + } +} + +/** Fire AppOpened with `is_first_open`. The "seen before" flag is only ever + * read/written once consent is granted (this function is itself a no-op + * without consent), so nothing is recorded locally pre-consent either. */ +export async function trackAppOpened() { + if (!enabled) return + let isFirstOpen = false + try { + const seen = await SecureStore.getItemAsync(FIRST_OPEN_KEY) + isFirstOpen = !seen + if (isFirstOpen) await SecureStore.setItemAsync(FIRST_OPEN_KEY, "1") + } catch { + // SecureStore unavailable — still fire the event, just without the flag. + } + track(AnalyticsEvent.AppOpened, { is_first_open: isFirstOpen }) +} + +/** Classify a connection failure into a coarse bucket without leaking the + * raw error message (which can contain hostnames/IPs). */ +export function classifyConnectionError(message: string | undefined): ConnectionErrorClass { + const m = (message || "").toLowerCase() + if (/401|unauthoriz/.test(m)) return "unauthorized" + if (/ssl|tls|certificate|handshake/.test(m)) return "tls-error" + if (/timeout|timed out/.test(m)) return "timeout" + if (/network request failed|unreachable|econnrefused|fetch failed/.test(m)) return "server-unreachable" + if (/malformed|invalid url/.test(m)) return "malformed-url" + return "unknown" +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index b259dd6..2f0e7e8 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -1,19 +1,21 @@ /** * Telemetry consent + initialisation gate. * - * Wraps sentry.ts so that initSentry() is only called when the user has - * explicitly opted in. Consent state is persisted in expo-secure-store so - * it survives app restarts. + * Wraps sentry.ts AND analytics.ts so that initSentry()/initAnalytics() are + * only called when the user has explicitly opted in. Both crash reporting + * and activation-funnel analytics share this single consent flag — there is + * no separate toggle for analytics. Consent state is persisted in + * expo-secure-store so it survives app restarts. * * Usage: * import { loadTelemetryConsent, setTelemetryConsent, hasTelemetryConsent } from './telemetry' * - * // On app start — call BEFORE trying to initialise Sentry. + * // On app start — call BEFORE trying to initialise Sentry/analytics. * const state = await loadTelemetryConsent() // 'granted' | 'denied' | 'unknown' - * if (state === 'granted') initSentry() + * if (state === 'granted') { initSentry(); initAnalytics() } * * // After the user taps "Allow" in the consent modal: - * await setTelemetryConsent(true) // persists + calls initSentry() if not already done + * await setTelemetryConsent(true) // persists + calls initSentry()/initAnalytics() if not already done * * // Check in Settings screen: * const current = hasTelemetryConsent() // boolean | null (null = not yet decided) @@ -21,6 +23,7 @@ import * as SecureStore from "expo-secure-store" import { disableSentry, initSentry, sentryEnabled } from "./sentry" +import { initAnalytics, shutdownAnalytics, analyticsEnabled } from "./analytics" const CONSENT_KEY = "opencode_telemetry_consent" @@ -78,11 +81,13 @@ async function applyTelemetryConsent(granted: boolean): Promise { await SecureStore.setItemAsync(CONSENT_KEY, "granted") _resolved = true if (!sentryEnabled()) initSentry() + if (!analyticsEnabled()) initAnalytics() return } _resolved = false await disableSentry() + await shutdownAnalytics() try { await SecureStore.setItemAsync(CONSENT_KEY, "denied") } catch (error) { diff --git a/src/stores/connections.ts b/src/stores/connections.ts index a2700dd..f107113 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -4,6 +4,7 @@ import * as Crypto from "expo-crypto" import type { ServerConnection, ConnectionType } from "../lib/types" import { createClient, type Client, type Project } from "../lib/sdk" import { addBreadcrumb } from "../lib/sentry" +import { AnalyticsEvent, classifyConnectionError, track } from "../lib/analytics" import { buildAuth } from "../lib/auth" const CONNECTIONS_KEY = "opencode_connections" @@ -243,6 +244,7 @@ export const useConnections = create((set, get) => ({ }, testConnection: async (connection, password) => { + track(AnalyticsEvent.ConnectionAttempted) try { const client = createClient({ baseUrl: connection.url, @@ -251,9 +253,12 @@ export const useConnections = create((set, get) => ({ }) await client.global.health() + track(AnalyticsEvent.ConnectionSucceeded) return { ok: true } } catch (error) { - return { ok: false, error: error instanceof Error ? error.message : String(error) } + const message = error instanceof Error ? error.message : String(error) + track(AnalyticsEvent.ConnectionFailed, { error_class: classifyConnectionError(message) }) + return { ok: false, error: message } } }, diff --git a/src/stores/events.ts b/src/stores/events.ts index 8a26428..31dcafc 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -5,6 +5,7 @@ import { send as notify } from "../lib/notifications" import { sanitizeBody } from "../lib/notify-format" import { statusFromPart } from "../lib/status-labels" import { addBreadcrumb } from "../lib/sentry" +import { AnalyticsEvent, track } from "../lib/analytics" import type { Client, Part, Session, Message } from "../lib/sdk" // Session status from the server @@ -179,6 +180,7 @@ export const useEvents = create((set, get) => ({ } if (completed) { + track(AnalyticsEvent.ResponseReceived) const match = useSessions.getState().sessions.find((s) => s.id === sessionID) notify({ category: "completed", diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index 3349f92..cbdc651 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -3,6 +3,7 @@ import type { Session, Message, Part, Event, MessageWithParts, Client } from ".. import { useConnections } from "./connections" import { useSettings } from "./settings" import { addBreadcrumb } from "../lib/sentry" +import { AnalyticsEvent, track } from "../lib/analytics" // Helper to convert API response to our internal format function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record } { @@ -223,6 +224,7 @@ export const useSessions = create((set, get) => ({ try { set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null })) + track(AnalyticsEvent.MessageSent) // Add user message optimistically const ts = Date.now() From c3cac2b8e5ea3d64eb0c995f096801c0d116deae Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 16 Jul 2026 16:04:54 -0700 Subject: [PATCH 2/2] fix(analytics): address review findings on activation-funnel events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_opened now also fires on the consent-grant transition (modal Allow / Settings toggle), not just cold start with prior consent — the true first session was emitting nothing and session 2 got mislabeled is_first_open. trackAppOpened() is guarded once-per-JS-session so revoke->regrant cannot double-count. - testConnection() takes a source ('onboarding' | 'edit_test') carried on connection_attempted/succeeded/failed so the funnel can filter out the edit screen's repeat-tester noise. - Aborted runs no longer count: abortedSessions set (in sessions.ts, read by events.ts which already imports it — no new import cycle), marked after a successful abort call, cleared on busy, and checked on busy->idle for BOTH response_received and recordSuccessfulSession(). - Consent revocation now DROPS buffered events instead of flushing them: PostHog's optOut() only blocks new captures and shutdown() drains the queue over the network, so ConsentGatedPostHog overrides the public fetch() transport to answer with a synthetic 200 post-revoke — shutdown clears the persisted queue and timers with zero bytes leaving the device. Re-grant calls optIn() to clear the persisted SDK opt-out flag. - classifyConnectionError extracted to pure analytics-classify.ts with node --test coverage (same pattern as store-review-policy). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E --- app/connection/[id].tsx | 1 + app/connection/add.tsx | 3 +- src/lib/analytics-classify.test.ts | 49 ++++++++++++++ src/lib/analytics-classify.ts | 28 ++++++++ src/lib/analytics.ts | 105 +++++++++++++++++++---------- src/lib/telemetry.ts | 9 ++- src/stores/connections.ts | 18 +++-- src/stores/events.ts | 19 ++++-- src/stores/sessions.ts | 11 +++ 9 files changed, 195 insertions(+), 48 deletions(-) create mode 100644 src/lib/analytics-classify.test.ts create mode 100644 src/lib/analytics-classify.ts diff --git a/app/connection/[id].tsx b/app/connection/[id].tsx index 4328056..c873e52 100644 --- a/app/connection/[id].tsx +++ b/app/connection/[id].tsx @@ -84,6 +84,7 @@ export default function EditConnectionScreen() { directory: directory.trim() || undefined, username: username.trim() || undefined, }, + "edit_test", password || undefined, ) diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 064de6a..c8d9bb3 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -80,6 +80,7 @@ export default function AddConnectionScreen() { url: serverUrl, username: username.trim() || undefined, }, + "onboarding", password || undefined, ) @@ -133,7 +134,7 @@ export default function AddConnectionScreen() { // Advanced mode saves directly without a pre-flight health check (see // useConnections.addConnection), so unlike quick-connect there is no // success/failure signal to report here — only that an attempt was made. - track(AnalyticsEvent.ConnectionAttempted) + track(AnalyticsEvent.ConnectionAttempted, { source: "onboarding" }) setIsConnecting(true) await addConnection( { diff --git a/src/lib/analytics-classify.test.ts b/src/lib/analytics-classify.test.ts new file mode 100644 index 0000000..e5a69a9 --- /dev/null +++ b/src/lib/analytics-classify.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { classifyConnectionError } from "./analytics-classify.ts" + +test("401 / unauthorized errors -> unauthorized (the known connect bug)", () => { + assert.equal(classifyConnectionError("API Error: 401 - Unauthorized"), "unauthorized") + assert.equal(classifyConnectionError("401"), "unauthorized") + assert.equal(classifyConnectionError("Request failed: unauthorized"), "unauthorized") + assert.equal(classifyConnectionError("HTTP 401 Unauthorised"), "unauthorized") +}) + +test("TLS / certificate errors -> tls-error", () => { + assert.equal(classifyConnectionError("SSL handshake failed"), "tls-error") + assert.equal(classifyConnectionError("certificate verify failed"), "tls-error") + assert.equal(classifyConnectionError("TLS connection error"), "tls-error") +}) + +test("timeouts -> timeout", () => { + assert.equal(classifyConnectionError("timeout after 8000ms"), "timeout") + assert.equal(classifyConnectionError("Connection timed out"), "timeout") +}) + +test("network-level failures -> server-unreachable", () => { + assert.equal(classifyConnectionError("Network request failed"), "server-unreachable") + assert.equal(classifyConnectionError("connect ECONNREFUSED 192.0.2.1:4096"), "server-unreachable") + assert.equal(classifyConnectionError("host unreachable"), "server-unreachable") + assert.equal(classifyConnectionError("fetch failed"), "server-unreachable") +}) + +test("URL parse failures -> malformed-url", () => { + assert.equal(classifyConnectionError("Malformed URL"), "malformed-url") + assert.equal(classifyConnectionError("Invalid URL: htp:/oops"), "malformed-url") +}) + +test("anything else (or missing) -> unknown", () => { + assert.equal(classifyConnectionError("some new error"), "unknown") + assert.equal(classifyConnectionError(""), "unknown") + assert.equal(classifyConnectionError(undefined), "unknown") +}) + +test("classification is case-insensitive", () => { + assert.equal(classifyConnectionError("UNAUTHORIZED"), "unauthorized") + assert.equal(classifyConnectionError("TIMEOUT"), "timeout") +}) + +test("precedence: 401 wins over other matches in a combined message", () => { + // A 401 behind a TLS proxy should surface as auth, the actionable bucket. + assert.equal(classifyConnectionError("401 Unauthorized (tls terminated)"), "unauthorized") +}) diff --git a/src/lib/analytics-classify.ts b/src/lib/analytics-classify.ts new file mode 100644 index 0000000..f05ad81 --- /dev/null +++ b/src/lib/analytics-classify.ts @@ -0,0 +1,28 @@ +// Pure connection-failure classification for analytics, extracted from +// analytics.ts (which imports posthog/expo) so it is unit-testable under +// plain `node --test` — same pattern as diagnostics-classify.ts and +// store-review-policy.ts. + +/** Coarse, non-identifying failure buckets — never include the raw error string + * (it may embed hostnames/tokens/paths). Reuses the vocabulary already + * established by diagnostics-classify.ts's Classification type. */ +export type ConnectionErrorClass = + | "malformed-url" + | "no-internet" + | "server-unreachable" + | "unauthorized" + | "tls-error" + | "timeout" + | "unknown" + +/** Classify a connection failure into a coarse bucket without leaking the + * raw error message (which can contain hostnames/IPs). */ +export function classifyConnectionError(message: string | undefined): ConnectionErrorClass { + const m = (message || "").toLowerCase() + if (/401|unauthoriz/.test(m)) return "unauthorized" + if (/ssl|tls|certificate|handshake/.test(m)) return "tls-error" + if (/timeout|timed out/.test(m)) return "timeout" + if (/network request failed|unreachable|econnrefused|fetch failed/.test(m)) return "server-unreachable" + if (/malformed|invalid url/.test(m)) return "malformed-url" + return "unknown" +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 6325c51..4e4e106 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -6,7 +6,16 @@ // module never calls PostHog.init/capture on its own; it is only ever // driven by ./telemetry.ts, which gates BOTH Sentry and analytics behind // the exact same "opencode_telemetry_consent" flag. -// 3. No PII in event properties: never pass server URLs, tokens, prompts, +// 3. On consent REVOCATION, buffered-but-unsent events are DROPPED, not +// flushed: PostHog's shutdown() normally drains the queue over the +// network, and optOut() only blocks NEW captures (already-queued events +// would still be sent by the next flush). So ConsentGatedPostHog +// overrides the client's public fetch() transport; once revoked it +// answers every SDK request with a synthetic 200 without touching the +// network. shutdown() then "drains" the queue into that stub — clearing +// the persisted queue and stopping timers — while zero bytes leave the +// device. +// 4. No PII in event properties: never pass server URLs, tokens, prompts, // or file contents. Only coarse, enumerated event names + small typed // properties (booleans, enums, counts). // @@ -18,6 +27,8 @@ import PostHog from "posthog-react-native" import * as SecureStore from "expo-secure-store" import { log } from "./logbuffer" +export { classifyConnectionError, type ConnectionErrorClass } from "./analytics-classify" + const API_KEY = process.env.EXPO_PUBLIC_POSTHOG_KEY // EU by default (GDPR-friendly region for opencode's mostly-EU/self-hosted user base). // Override with EXPO_PUBLIC_POSTHOG_HOST for a self-hosted instance. @@ -25,25 +36,41 @@ const HOST = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com" const FIRST_OPEN_KEY = "opencode_analytics_first_open_done" -let client: PostHog | null = null -let enabled = false +// When true (set on consent revocation), the transport answers with a +// synthetic 200 instead of hitting the network, so queued events are +// discarded rather than uploaded. Reset when a new client is created. +let dropNetwork = false -/** Coarse, non-identifying failure buckets — never include the raw error string - * (it may embed hostnames/tokens/paths). Reuses the vocabulary already - * established by diagnostics-classify.ts's Classification type. */ -export type ConnectionErrorClass = - | "malformed-url" - | "no-internet" - | "server-unreachable" - | "unauthorized" - | "tls-error" - | "timeout" - | "unknown" +/** PostHog client whose transport is consent-gated: after revocation every + * request short-circuits to a fake success so nothing reaches the network. + * (Types derived from the base class to avoid importing the transitive + * @posthog/core package directly.) */ +class ConsentGatedPostHog extends PostHog { + fetch(url: string, options: Parameters[1]): ReturnType { + if (dropNetwork) { + return Promise.resolve({ + status: 200, + text: async () => "", + json: async () => ({ status: 1 }), + }) + } + return super.fetch(url, options) + } +} + +let client: ConsentGatedPostHog | null = null +let enabled = false +// app_opened must fire at most once per JS session, whichever path enables +// analytics first (cold start with prior consent, or the consent modal / +// Settings toggle mid-session). Also prevents a revoke -> re-grant in the +// same session from double-counting. +let appOpenedTracked = false /** Activation-funnel events. Keep this list in 1:1 sync with the funnel steps * tracked in the product analytics dashboard. */ export enum AnalyticsEvent { - /** App process started and the user has an existing telemetry decision of "granted". */ + /** Fired once per app session, as soon as analytics is enabled (either at + * cold start with prior consent, or right after consent is granted). */ AppOpened = "app_opened", /** User tapped Connect/Save with a non-empty server URL (quick or advanced mode). */ ConnectionFormSubmitted = "connection_form_submitted", @@ -55,10 +82,17 @@ export enum AnalyticsEvent { ConnectionFailed = "connection_failed", /** User sent a prompt/message to an agent session (excludes slash commands). */ MessageSent = "message_sent", - /** An agent response finished streaming (session transitioned busy -> idle). */ + /** An agent response finished streaming (session transitioned busy -> idle), + * excluding user-aborted runs. */ ResponseReceived = "response_received", } +/** Where a connection test was initiated from. The activation funnel filters + * to source=onboarding; edit_test covers the Test button on the existing- + * connection edit screen, which would otherwise pollute the funnel with + * repeat-tester noise. */ +export type ConnectionTestSource = "onboarding" | "edit_test" + export function initAnalytics() { if (enabled) return if (!API_KEY) { @@ -66,11 +100,15 @@ export function initAnalytics() { return } try { - client = new PostHog(API_KEY, { + dropNetwork = false + client = new ConsentGatedPostHog(API_KEY, { host: HOST, // We call track() explicitly at each funnel step — no implicit capture. captureAppLifecycleEvents: false, }) + // A previous revoke persisted the SDK-level opt-out flag; clear it so the + // re-granted client can enqueue again. No-op on a fresh install. + void client.optIn() enabled = true log.info("analytics", "initialized", `host=${HOST}`) } catch (e) { @@ -78,17 +116,24 @@ export function initAnalytics() { } } +/** Consent revoked: block new captures, DROP anything buffered (see header + * note 3 — the gated fetch turns shutdown's drain into a no-network discard), + * and tear the client down. */ export async function shutdownAnalytics() { if (!enabled || !client) return enabled = false const c = client client = null + dropNetwork = true try { + // Persist SDK-level opt-out first so even a re-created client (without + // consent) could not capture, then let shutdown clear queue + timers. + await c.optOut() await c.shutdown() } catch (e) { log.warn("analytics", "shutdown failed", String(e)) } - log.info("analytics", "disabled by user") + log.info("analytics", "disabled by user — buffered events dropped") } export function analyticsEnabled(): boolean { @@ -110,11 +155,15 @@ export function track(event: AnalyticsEvent, props?: AnalyticsProps) { } } -/** Fire AppOpened with `is_first_open`. The "seen before" flag is only ever - * read/written once consent is granted (this function is itself a no-op - * without consent), so nothing is recorded locally pre-consent either. */ +/** Fire AppOpened with `is_first_open`, at most once per JS session. + * Called both from app start (consent already granted) and from the + * consent-grant transition (modal "Allow" / Settings toggle) — the session + * guard makes whichever happens first win. The "seen before" flag is only + * ever read/written once consent is granted (this function is itself a + * no-op without consent), so nothing is recorded locally pre-consent. */ export async function trackAppOpened() { - if (!enabled) return + if (!enabled || appOpenedTracked) return + appOpenedTracked = true let isFirstOpen = false try { const seen = await SecureStore.getItemAsync(FIRST_OPEN_KEY) @@ -125,15 +174,3 @@ export async function trackAppOpened() { } track(AnalyticsEvent.AppOpened, { is_first_open: isFirstOpen }) } - -/** Classify a connection failure into a coarse bucket without leaking the - * raw error message (which can contain hostnames/IPs). */ -export function classifyConnectionError(message: string | undefined): ConnectionErrorClass { - const m = (message || "").toLowerCase() - if (/401|unauthoriz/.test(m)) return "unauthorized" - if (/ssl|tls|certificate|handshake/.test(m)) return "tls-error" - if (/timeout|timed out/.test(m)) return "timeout" - if (/network request failed|unreachable|econnrefused|fetch failed/.test(m)) return "server-unreachable" - if (/malformed|invalid url/.test(m)) return "malformed-url" - return "unknown" -} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 2f0e7e8..fdd335b 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -23,7 +23,7 @@ import * as SecureStore from "expo-secure-store" import { disableSentry, initSentry, sentryEnabled } from "./sentry" -import { initAnalytics, shutdownAnalytics, analyticsEnabled } from "./analytics" +import { initAnalytics, shutdownAnalytics, analyticsEnabled, trackAppOpened } from "./analytics" const CONSENT_KEY = "opencode_telemetry_consent" @@ -82,6 +82,13 @@ async function applyTelemetryConsent(granted: boolean): Promise { _resolved = true if (!sentryEnabled()) initSentry() if (!analyticsEnabled()) initAnalytics() + // First-ever session reaches here via the consent modal's "Allow" (app + // start skipped init because consent was still unknown), so app_opened + // must also fire on the grant transition — otherwise the true first + // session emits nothing and session 2 gets mislabeled is_first_open. + // trackAppOpened() is internally once-per-session, so a mid-session + // revoke -> re-grant cannot double-count. + void trackAppOpened() return } diff --git a/src/stores/connections.ts b/src/stores/connections.ts index f107113..ef3b12d 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -4,7 +4,7 @@ import * as Crypto from "expo-crypto" import type { ServerConnection, ConnectionType } from "../lib/types" import { createClient, type Client, type Project } from "../lib/sdk" import { addBreadcrumb } from "../lib/sentry" -import { AnalyticsEvent, classifyConnectionError, track } from "../lib/analytics" +import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics" import { buildAuth } from "../lib/auth" const CONNECTIONS_KEY = "opencode_connections" @@ -34,7 +34,13 @@ interface ConnectionsState { addConnection: (connection: Omit, password?: string) => Promise removeConnection: (id: string) => Promise setActiveConnection: (id: string) => Promise - testConnection: (connection: ServerConnection, password?: string) => Promise<{ ok: boolean; error?: string }> + // `source` distinguishes the activation funnel (onboarding) from the edit + // screen's Test button (edit_test) in analytics. + testConnection: ( + connection: ServerConnection, + source: ConnectionTestSource, + password?: string, + ) => Promise<{ ok: boolean; error?: string }> updateConnection: (id: string, updates: Partial) => Promise refreshProject: () => Promise // Create a one-off client pointing at a specific directory (for cross-project operations) @@ -243,8 +249,8 @@ export const useConnections = create((set, get) => ({ }) }, - testConnection: async (connection, password) => { - track(AnalyticsEvent.ConnectionAttempted) + testConnection: async (connection, source, password) => { + track(AnalyticsEvent.ConnectionAttempted, { source }) try { const client = createClient({ baseUrl: connection.url, @@ -253,11 +259,11 @@ export const useConnections = create((set, get) => ({ }) await client.global.health() - track(AnalyticsEvent.ConnectionSucceeded) + track(AnalyticsEvent.ConnectionSucceeded, { source }) return { ok: true } } catch (error) { const message = error instanceof Error ? error.message : String(error) - track(AnalyticsEvent.ConnectionFailed, { error_class: classifyConnectionError(message) }) + track(AnalyticsEvent.ConnectionFailed, { source, error_class: classifyConnectionError(message) }) return { ok: false, error: message } } }, diff --git a/src/stores/events.ts b/src/stores/events.ts index 0ee33e4..875508c 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -1,6 +1,6 @@ import { create } from "zustand" import { useConnections } from "./connections" -import { useSessions } from "./sessions" +import { useSessions, abortedSessions } from "./sessions" import { send as notify } from "../lib/notifications" import { sanitizeBody } from "../lib/notify-format" import { statusFromPart } from "../lib/status-labels" @@ -168,8 +168,11 @@ export const useEvents = create((set, get) => ({ const previous = get().sessionStatus[sessionID] const completed = previous?.type === "busy" && status.type === "idle" - // A new run starts — forget any error from the previous one - if (status.type === "busy") erroredSessions.delete(sessionID) + // A new run starts — forget any error/abort from the previous one + if (status.type === "busy") { + erroredSessions.delete(sessionID) + abortedSessions.delete(sessionID) + } set((state) => ({ sessionStatus: { ...state.sessionStatus, [sessionID]: status }, @@ -190,7 +193,10 @@ export const useEvents = create((set, get) => ({ } if (completed) { - track(AnalyticsEvent.ResponseReceived) + // A user-cancelled run still ends busy -> idle; don't count it + // as a received response or a review-worthy success. + const aborted = abortedSessions.has(sessionID) + if (!aborted) track(AnalyticsEvent.ResponseReceived) const match = useSessions.getState().sessions.find((s) => s.id === sessionID) notify({ category: "completed", @@ -201,8 +207,8 @@ export const useEvents = create((set, get) => ({ // Genuinely positive moment — count it toward the one-time // store review prompt, but only if this run never errored // (session.error doesn't touch sessionStatus, so an errored - // session still lands here via busy -> idle). - if (!erroredSessions.has(sessionID)) void recordSuccessfulSession() + // session still lands here via busy -> idle) and wasn't aborted. + if (!aborted && !erroredSessions.has(sessionID)) void recordSuccessfulSession() } break } @@ -378,6 +384,7 @@ export const useEvents = create((set, get) => ({ controller?.abort() controller = null erroredSessions.clear() + abortedSessions.clear() set({ connected: false, reconnectAttempts: 0, diff --git a/src/stores/sessions.ts b/src/stores/sessions.ts index cbdc651..aee117c 100644 --- a/src/stores/sessions.ts +++ b/src/stores/sessions.ts @@ -53,6 +53,14 @@ interface SessionsState { handleEvent: (event: Event) => void } +// Sessions the user aborted since they last went busy. Mirrors events.ts's +// erroredSessions: SessionStatus has no "aborted" variant — an aborted run +// still ends with a busy -> idle transition — so without this mark a +// user-cancelled run would count as response_received in analytics and as a +// success toward the store review prompt. events.ts (which already imports +// this module) clears entries on busy and checks them on busy -> idle. +export const abortedSessions = new Set() + // Get the right client for a session's directory function clientFor(directory?: string): Client | null { const connState = useConnections.getState() @@ -310,6 +318,9 @@ export const useSessions = create((set, get) => ({ try { await client.session.abort(session.id) + // Mark only after the abort request succeeded — if it failed, the run + // continues and any eventual completion is a genuine response. + abortedSessions.add(session.id) set((state) => ({ sending: { ...state.sending, [session.id]: false } })) } catch { set({ error: "Failed to abort session" })