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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
This commit is contained in:
engineer
2026-07-16 15:48:16 -07:00
parent a5723bf087
commit ace8c19816
14 changed files with 309 additions and 24 deletions

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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)" })

View File

@@ -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(
{

140
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

139
src/lib/analytics.ts Normal file
View File

@@ -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<string, string | number | boolean | null>
/** 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"
}

View File

@@ -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<void> {
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) {

View File

@@ -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<ConnectionsState>((set, get) => ({
},
testConnection: async (connection, password) => {
track(AnalyticsEvent.ConnectionAttempted)
try {
const client = createClient({
baseUrl: connection.url,
@@ -251,9 +253,12 @@ export const useConnections = create<ConnectionsState>((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 }
}
},

View File

@@ -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<EventsState>((set, get) => ({
}
if (completed) {
track(AnalyticsEvent.ResponseReceived)
const match = useSessions.getState().sessions.find((s) => s.id === sessionID)
notify({
category: "completed",

View File

@@ -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<string, Part[]> } {
@@ -223,6 +224,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
try {
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
track(AnalyticsEvent.MessageSent)
// Add user message optimistically
const ts = Date.now()