From ace8c19816b19fbfe22326e6a091d40c8bbc3dbf Mon Sep 17 00:00:00 2001 From: engineer Date: Thu, 16 Jul 2026 15:48:16 -0700 Subject: [PATCH] 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()