merge: feat/activation-analytics — consent-gated PostHog activation funnel (reviewed: APPROVE after fixes)
This commit is contained in:
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -33,6 +33,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
|
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_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
|||||||
7
.github/workflows/publish-app-store.yml
vendored
7
.github/workflows/publish-app-store.yml
vendored
@@ -23,9 +23,10 @@
|
|||||||
# provisioning profile so CI never needs to prompt):
|
# provisioning profile so CI never needs to prompt):
|
||||||
# eas login && eas build --platform ios --profile production
|
# eas login && eas build --platform ios --profile production
|
||||||
#
|
#
|
||||||
# Optional crash reporting belongs in the EAS `production` environment because the
|
# Optional crash reporting + analytics belong in the EAS `production` environment
|
||||||
# iOS bundle is built on a remote EAS worker. Configure EXPO_PUBLIC_SENTRY_DSN,
|
# because the iOS bundle is built on a remote EAS worker. Configure
|
||||||
# SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT in Expo before releasing.
|
# 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 number is managed remotely by EAS (eas.json: cli.appVersionSource=remote,
|
||||||
# build.production.ios.autoIncrement=buildNumber). The workflow only injects the
|
# build.production.ios.autoIncrement=buildNumber). The workflow only injects the
|
||||||
|
|||||||
1
.github/workflows/publish-fdroid.yml
vendored
1
.github/workflows/publish-fdroid.yml
vendored
@@ -12,6 +12,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
env:
|
env:
|
||||||
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
|
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_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
|||||||
1
.github/workflows/publish-play-store.yml
vendored
1
.github/workflows/publish-play-store.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
|
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_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { TelemetryConsentModal } from "../src/components/TelemetryConsentModal"
|
|||||||
import * as notifications from "../src/lib/notifications"
|
import * as notifications from "../src/lib/notifications"
|
||||||
import { addBreadcrumb, wrap } from "../src/lib/sentry"
|
import { addBreadcrumb, wrap } from "../src/lib/sentry"
|
||||||
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
import { loadTelemetryConsent, setTelemetryConsent } from "../src/lib/telemetry"
|
||||||
|
import { initAnalytics, trackAppOpened } from "../src/lib/analytics"
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ function RootLayout() {
|
|||||||
initSentry()
|
initSentry()
|
||||||
addBreadcrumb({ category: "app.lifecycle", message: "app started" })
|
addBreadcrumb({ category: "app.lifecycle", message: "app started" })
|
||||||
})
|
})
|
||||||
|
initAnalytics()
|
||||||
|
trackAppOpened()
|
||||||
setConsentState("decided")
|
setConsentState("decided")
|
||||||
} else if (state === "denied") {
|
} else if (state === "denied") {
|
||||||
addBreadcrumb({ category: "app.lifecycle", message: "app started (telemetry off)" })
|
addBreadcrumb({ category: "app.lifecycle", message: "app started (telemetry off)" })
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export default function EditConnectionScreen() {
|
|||||||
directory: directory.trim() || undefined,
|
directory: directory.trim() || undefined,
|
||||||
username: username.trim() || undefined,
|
username: username.trim() || undefined,
|
||||||
},
|
},
|
||||||
|
"edit_test",
|
||||||
password || undefined,
|
password || undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { ConnectionType } from "../../src/lib/types"
|
|||||||
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
import { captureDiagnostic } from "../../src/lib/sentry"
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||||
|
import { AnalyticsEvent, track } from "../../src/lib/analytics"
|
||||||
|
|
||||||
export default function AddConnectionScreen() {
|
export default function AddConnectionScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
@@ -67,6 +68,7 @@ export default function AddConnectionScreen() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track(AnalyticsEvent.ConnectionFormSubmitted, { mode: "quick" })
|
||||||
setIsConnecting(true)
|
setIsConnecting(true)
|
||||||
|
|
||||||
// Test connection first
|
// Test connection first
|
||||||
@@ -78,6 +80,7 @@ export default function AddConnectionScreen() {
|
|||||||
url: serverUrl,
|
url: serverUrl,
|
||||||
username: username.trim() || undefined,
|
username: username.trim() || undefined,
|
||||||
},
|
},
|
||||||
|
"onboarding",
|
||||||
password || undefined,
|
password || undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +130,11 @@ export default function AddConnectionScreen() {
|
|||||||
return
|
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)
|
setIsConnecting(true)
|
||||||
await addConnection(
|
await addConnection(
|
||||||
{
|
{
|
||||||
|
|||||||
116
package-lock.json
generated
116
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@sentry/react-native": "~6.22.0",
|
"@sentry/react-native": "~6.22.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-store-review": "~9.0.9",
|
"expo-store-review": "~9.0.9",
|
||||||
|
"posthog-react-native": "^4.57.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
@@ -2222,6 +2224,21 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@@ -6258,6 +6287,15 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"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==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@@ -8036,6 +8086,72 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@gorhom/bottom-sheet": "5.2.8",
|
"@gorhom/bottom-sheet": "5.2.8",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/native": "^7.0.14",
|
"@react-navigation/native": "^7.0.14",
|
||||||
"@sentry/react-native": "~6.22.0",
|
"@sentry/react-native": "~6.22.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"expo-speech-recognition": "3.0.1",
|
"expo-speech-recognition": "3.0.1",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-store-review": "~9.0.9",
|
"expo-store-review": "~9.0.9",
|
||||||
|
"posthog-react-native": "^4.57.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
|||||||
49
src/lib/analytics-classify.test.ts
Normal file
49
src/lib/analytics-classify.test.ts
Normal file
@@ -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")
|
||||||
|
})
|
||||||
28
src/lib/analytics-classify.ts
Normal file
28
src/lib/analytics-classify.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
176
src/lib/analytics.ts
Normal file
176
src/lib/analytics.ts
Normal file
@@ -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<PostHog["fetch"]>[1]): ReturnType<PostHog["fetch"]> {
|
||||||
|
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<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`, 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 })
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Telemetry consent + initialisation gate.
|
* Telemetry consent + initialisation gate.
|
||||||
*
|
*
|
||||||
* Wraps sentry.ts so that initSentry() is only called when the user has
|
* Wraps sentry.ts AND analytics.ts so that initSentry()/initAnalytics() are
|
||||||
* explicitly opted in. Consent state is persisted in expo-secure-store so
|
* only called when the user has explicitly opted in. Both crash reporting
|
||||||
* it survives app restarts.
|
* 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:
|
* Usage:
|
||||||
* import { loadTelemetryConsent, setTelemetryConsent, hasTelemetryConsent } from './telemetry'
|
* 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'
|
* 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:
|
* // 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:
|
* // Check in Settings screen:
|
||||||
* const current = hasTelemetryConsent() // boolean | null (null = not yet decided)
|
* const current = hasTelemetryConsent() // boolean | null (null = not yet decided)
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
|
|
||||||
import * as SecureStore from "expo-secure-store"
|
import * as SecureStore from "expo-secure-store"
|
||||||
import { disableSentry, initSentry, sentryEnabled } from "./sentry"
|
import { disableSentry, initSentry, sentryEnabled } from "./sentry"
|
||||||
|
import { initAnalytics, shutdownAnalytics, analyticsEnabled, trackAppOpened } from "./analytics"
|
||||||
|
|
||||||
const CONSENT_KEY = "opencode_telemetry_consent"
|
const CONSENT_KEY = "opencode_telemetry_consent"
|
||||||
|
|
||||||
@@ -78,11 +81,20 @@ async function applyTelemetryConsent(granted: boolean): Promise<void> {
|
|||||||
await SecureStore.setItemAsync(CONSENT_KEY, "granted")
|
await SecureStore.setItemAsync(CONSENT_KEY, "granted")
|
||||||
_resolved = true
|
_resolved = true
|
||||||
if (!sentryEnabled()) initSentry()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_resolved = false
|
_resolved = false
|
||||||
await disableSentry()
|
await disableSentry()
|
||||||
|
await shutdownAnalytics()
|
||||||
try {
|
try {
|
||||||
await SecureStore.setItemAsync(CONSENT_KEY, "denied")
|
await SecureStore.setItemAsync(CONSENT_KEY, "denied")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as Crypto from "expo-crypto"
|
|||||||
import type { ServerConnection, ConnectionType } from "../lib/types"
|
import type { ServerConnection, ConnectionType } from "../lib/types"
|
||||||
import { createClient, type Client, type Project } from "../lib/sdk"
|
import { createClient, type Client, type Project } from "../lib/sdk"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics"
|
||||||
import { buildAuth } from "../lib/auth"
|
import { buildAuth } from "../lib/auth"
|
||||||
|
|
||||||
const CONNECTIONS_KEY = "opencode_connections"
|
const CONNECTIONS_KEY = "opencode_connections"
|
||||||
@@ -33,7 +34,13 @@ interface ConnectionsState {
|
|||||||
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
|
addConnection: (connection: Omit<ServerConnection, "id">, password?: string) => Promise<void>
|
||||||
removeConnection: (id: string) => Promise<void>
|
removeConnection: (id: string) => Promise<void>
|
||||||
setActiveConnection: (id: string) => Promise<void>
|
setActiveConnection: (id: string) => Promise<void>
|
||||||
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<ServerConnection>) => Promise<void>
|
updateConnection: (id: string, updates: Partial<ServerConnection>) => Promise<void>
|
||||||
refreshProject: () => Promise<void>
|
refreshProject: () => Promise<void>
|
||||||
// Create a one-off client pointing at a specific directory (for cross-project operations)
|
// Create a one-off client pointing at a specific directory (for cross-project operations)
|
||||||
@@ -242,7 +249,8 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
testConnection: async (connection, password) => {
|
testConnection: async (connection, source, password) => {
|
||||||
|
track(AnalyticsEvent.ConnectionAttempted, { source })
|
||||||
try {
|
try {
|
||||||
const client = createClient({
|
const client = createClient({
|
||||||
baseUrl: connection.url,
|
baseUrl: connection.url,
|
||||||
@@ -251,9 +259,12 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
await client.global.health()
|
await client.global.health()
|
||||||
|
track(AnalyticsEvent.ConnectionSucceeded, { source })
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
} catch (error) {
|
} 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 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import { useConnections } from "./connections"
|
import { useConnections } from "./connections"
|
||||||
import { useSessions } from "./sessions"
|
import { useSessions, abortedSessions } from "./sessions"
|
||||||
import { send as notify } from "../lib/notifications"
|
import { send as notify } from "../lib/notifications"
|
||||||
import { sanitizeBody } from "../lib/notify-format"
|
import { sanitizeBody } from "../lib/notify-format"
|
||||||
import { statusFromPart } from "../lib/status-labels"
|
import { statusFromPart } from "../lib/status-labels"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
import { AnalyticsEvent, track } from "../lib/analytics"
|
||||||
import { recordSuccessfulSession } from "../lib/store-review"
|
import { recordSuccessfulSession } from "../lib/store-review"
|
||||||
import type { Client, Part, Session, Message } from "../lib/sdk"
|
import type { Client, Part, Session, Message } from "../lib/sdk"
|
||||||
|
|
||||||
@@ -167,8 +168,11 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
const previous = get().sessionStatus[sessionID]
|
const previous = get().sessionStatus[sessionID]
|
||||||
const completed = previous?.type === "busy" && status.type === "idle"
|
const completed = previous?.type === "busy" && status.type === "idle"
|
||||||
|
|
||||||
// A new run starts — forget any error from the previous one
|
// A new run starts — forget any error/abort from the previous one
|
||||||
if (status.type === "busy") erroredSessions.delete(sessionID)
|
if (status.type === "busy") {
|
||||||
|
erroredSessions.delete(sessionID)
|
||||||
|
abortedSessions.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
|
sessionStatus: { ...state.sessionStatus, [sessionID]: status },
|
||||||
@@ -189,6 +193,10 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (completed) {
|
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)
|
const match = useSessions.getState().sessions.find((s) => s.id === sessionID)
|
||||||
notify({
|
notify({
|
||||||
category: "completed",
|
category: "completed",
|
||||||
@@ -199,8 +207,8 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
// Genuinely positive moment — count it toward the one-time
|
// Genuinely positive moment — count it toward the one-time
|
||||||
// store review prompt, but only if this run never errored
|
// store review prompt, but only if this run never errored
|
||||||
// (session.error doesn't touch sessionStatus, so an errored
|
// (session.error doesn't touch sessionStatus, so an errored
|
||||||
// session still lands here via busy -> idle).
|
// session still lands here via busy -> idle) and wasn't aborted.
|
||||||
if (!erroredSessions.has(sessionID)) void recordSuccessfulSession()
|
if (!aborted && !erroredSessions.has(sessionID)) void recordSuccessfulSession()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -376,6 +384,7 @@ export const useEvents = create<EventsState>((set, get) => ({
|
|||||||
controller?.abort()
|
controller?.abort()
|
||||||
controller = null
|
controller = null
|
||||||
erroredSessions.clear()
|
erroredSessions.clear()
|
||||||
|
abortedSessions.clear()
|
||||||
set({
|
set({
|
||||||
connected: false,
|
connected: false,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Session, Message, Part, Event, MessageWithParts, Client } from "..
|
|||||||
import { useConnections } from "./connections"
|
import { useConnections } from "./connections"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
|
import { AnalyticsEvent, track } from "../lib/analytics"
|
||||||
|
|
||||||
// Helper to convert API response to our internal format
|
// Helper to convert API response to our internal format
|
||||||
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
function parseMessages(response: MessageWithParts[]): { messages: Message[]; parts: Record<string, Part[]> } {
|
||||||
@@ -52,6 +53,14 @@ interface SessionsState {
|
|||||||
handleEvent: (event: Event) => void
|
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<string>()
|
||||||
|
|
||||||
// Get the right client for a session's directory
|
// Get the right client for a session's directory
|
||||||
function clientFor(directory?: string): Client | null {
|
function clientFor(directory?: string): Client | null {
|
||||||
const connState = useConnections.getState()
|
const connState = useConnections.getState()
|
||||||
@@ -223,6 +232,7 @@ export const useSessions = create<SessionsState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
|
set((state) => ({ sending: { ...state.sending, [session.id]: true }, error: null }))
|
||||||
|
track(AnalyticsEvent.MessageSent)
|
||||||
|
|
||||||
// Add user message optimistically
|
// Add user message optimistically
|
||||||
const ts = Date.now()
|
const ts = Date.now()
|
||||||
@@ -308,6 +318,9 @@ export const useSessions = create<SessionsState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.abort(session.id)
|
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 } }))
|
set((state) => ({ sending: { ...state.sending, [session.id]: false } }))
|
||||||
} catch {
|
} catch {
|
||||||
set({ error: "Failed to abort session" })
|
set({ error: "Failed to abort session" })
|
||||||
|
|||||||
Reference in New Issue
Block a user