diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce1b180..173806a 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 763961b..caf4363 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 90f7ce5..36ec044 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 b1c7c0b..bd7e85d 100644 --- a/app.json +++ b/app.json @@ -86,4 +86,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/[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 34416f3..c8d9bb3 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 @@ -78,6 +80,7 @@ export default function AddConnectionScreen() { url: serverUrl, username: username.trim() || undefined, }, + "onboarding", password || undefined, ) @@ -127,6 +130,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, { source: "onboarding" }) setIsConnecting(true) await addConnection( { diff --git a/package-lock.json b/package-lock.json index 5d569ee..c27d331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,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.22.0", "@tanstack/react-query": "^5.62.0", @@ -27,6 +28,7 @@ "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", + "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -2222,6 +2224,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", @@ -2409,6 +2426,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", @@ -6258,6 +6287,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", @@ -7045,6 +7083,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", @@ -8036,6 +8086,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 255af13..0e14da1 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.22.0", "@tanstack/react-query": "^5.62.0", @@ -31,6 +32,7 @@ "expo-speech-recognition": "3.0.1", "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", + "posthog-react-native": "^4.57.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", 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 new file mode 100644 index 0000000..4e4e106 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,176 @@ +// 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. 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). +// +// 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" + +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. +const HOST = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com" + +const FIRST_OPEN_KEY = "opencode_analytics_first_open_done" + +// 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 + +/** 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 { + /** 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", + /** 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), + * 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) { + log.info("analytics", "no API key configured — analytics disabled") + return + } + try { + 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) { + log.warn("analytics", "init failed", String(e)) + } +} + +/** 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 — buffered events dropped") +} + +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`, 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 || appOpenedTracked) return + appOpenedTracked = true + 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 }) +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index b259dd6..fdd335b 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, trackAppOpened } from "./analytics" const CONSENT_KEY = "opencode_telemetry_consent" @@ -78,11 +81,20 @@ async function applyTelemetryConsent(granted: boolean): Promise { await SecureStore.setItemAsync(CONSENT_KEY, "granted") _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 } _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..ef3b12d 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, type ConnectionTestSource } from "../lib/analytics" import { buildAuth } from "../lib/auth" const CONNECTIONS_KEY = "opencode_connections" @@ -33,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) @@ -242,7 +249,8 @@ export const useConnections = create((set, get) => ({ }) }, - testConnection: async (connection, password) => { + testConnection: async (connection, source, password) => { + track(AnalyticsEvent.ConnectionAttempted, { source }) try { const client = createClient({ baseUrl: connection.url, @@ -251,9 +259,12 @@ export const useConnections = create((set, get) => ({ }) await client.global.health() + track(AnalyticsEvent.ConnectionSucceeded, { source }) 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, { source, error_class: classifyConnectionError(message) }) + return { ok: false, error: message } } }, diff --git a/src/stores/events.ts b/src/stores/events.ts index 9164598..875508c 100644 --- a/src/stores/events.ts +++ b/src/stores/events.ts @@ -1,10 +1,11 @@ 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" import { addBreadcrumb } from "../lib/sentry" +import { AnalyticsEvent, track } from "../lib/analytics" import { recordSuccessfulSession } from "../lib/store-review" import type { Client, Part, Session, Message } from "../lib/sdk" @@ -167,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 }, @@ -189,6 +193,10 @@ export const useEvents = create((set, get) => ({ } if (completed) { + // 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", @@ -199,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 } @@ -376,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 3349f92..aee117c 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 } { @@ -52,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() @@ -223,6 +232,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() @@ -308,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" })