- app_opened now also fires on the consent-grant transition (modal Allow /
Settings toggle), not just cold start with prior consent — the true first
session was emitting nothing and session 2 got mislabeled is_first_open.
trackAppOpened() is guarded once-per-JS-session so revoke->regrant cannot
double-count.
- testConnection() takes a source ('onboarding' | 'edit_test') carried on
connection_attempted/succeeded/failed so the funnel can filter out the
edit screen's repeat-tester noise.
- Aborted runs no longer count: abortedSessions set (in sessions.ts, read by
events.ts which already imports it — no new import cycle), marked after a
successful abort call, cleared on busy, and checked on busy->idle for BOTH
response_received and recordSuccessfulSession().
- Consent revocation now DROPS buffered events instead of flushing them:
PostHog's optOut() only blocks new captures and shutdown() drains the queue
over the network, so ConsentGatedPostHog overrides the public fetch()
transport to answer with a synthetic 200 post-revoke — shutdown clears the
persisted queue and timers with zero bytes leaving the device. Re-grant
calls optIn() to clear the persisted SDK opt-out flag.
- classifyConnectionError extracted to pure analytics-classify.ts with
node --test coverage (same pattern as store-review-policy).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
/**
|
|
* Telemetry consent + initialisation gate.
|
|
*
|
|
* 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/analytics.
|
|
* const state = await loadTelemetryConsent() // 'granted' | 'denied' | 'unknown'
|
|
* if (state === 'granted') { initSentry(); initAnalytics() }
|
|
*
|
|
* // After the user taps "Allow" in the consent modal:
|
|
* await setTelemetryConsent(true) // persists + calls initSentry()/initAnalytics() if not already done
|
|
*
|
|
* // Check in Settings screen:
|
|
* const current = hasTelemetryConsent() // boolean | null (null = not yet decided)
|
|
*/
|
|
|
|
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"
|
|
|
|
export type ConsentState = "granted" | "denied" | "unknown"
|
|
|
|
let _resolved: boolean | null = null // null = unknown, true = granted, false = denied
|
|
let transition = Promise.resolve()
|
|
|
|
/**
|
|
* Load persisted consent from SecureStore.
|
|
* Returns 'unknown' if the user has never been asked.
|
|
*/
|
|
export async function loadTelemetryConsent(): Promise<ConsentState> {
|
|
try {
|
|
const stored = await SecureStore.getItemAsync(CONSENT_KEY)
|
|
if (stored === "granted") {
|
|
_resolved = true
|
|
return "granted"
|
|
}
|
|
if (stored === "denied") {
|
|
_resolved = false
|
|
return "denied"
|
|
}
|
|
// No stored value — first launch
|
|
_resolved = null
|
|
return "unknown"
|
|
} catch {
|
|
_resolved = false
|
|
return "denied"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the in-memory resolved state (set by loadTelemetryConsent or setTelemetryConsent).
|
|
* null = consent decision not yet loaded.
|
|
* true = granted.
|
|
* false = denied.
|
|
*/
|
|
export function hasTelemetryConsent(): boolean | null {
|
|
return _resolved
|
|
}
|
|
|
|
/**
|
|
* Persist the user's consent decision and, if granted and Sentry is not yet
|
|
* running, initialise it immediately.
|
|
*/
|
|
export function setTelemetryConsent(granted: boolean): Promise<void> {
|
|
const next = transition.then(() => applyTelemetryConsent(granted))
|
|
transition = next.catch(() => undefined)
|
|
return next
|
|
}
|
|
|
|
async function applyTelemetryConsent(granted: boolean): Promise<void> {
|
|
if (granted) {
|
|
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) {
|
|
await SecureStore.deleteItemAsync(CONSENT_KEY)
|
|
throw error
|
|
}
|
|
}
|