fix(privacy): stop telemetry on consent revocation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Dennis V
2026-07-14 06:30:09 +00:00
parent 679475d0a4
commit f9b18a06f3
8 changed files with 114 additions and 46 deletions

View File

@@ -73,14 +73,26 @@ export default function SettingsScreen() {
const { settings, hasBiometrics, updateSettings, lock } = useAuth() const { settings, hasBiometrics, updateSettings, lock } = useAuth()
const { notifications, setNotification } = useSettings() const { notifications, setNotification } = useSettings()
const [osGranted, setOsGranted] = useState<boolean | null>(null) const [osGranted, setOsGranted] = useState<boolean | null>(null)
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
// Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false. // Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false.
// We initialise local state from in-memory value; updates call setTelemetryConsent(). // We initialise local state from in-memory value; updates call setTelemetryConsent().
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false) const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
const handleCrashReportingToggle = useCallback(async (value: boolean) => { const handleCrashReportingToggle = useCallback(async (value: boolean) => {
setCrashReporting(value) setTelemetryUpdating(true)
await setTelemetryConsent(value) try {
await setTelemetryConsent(value)
setCrashReporting(value)
} catch {
setCrashReporting(hasTelemetryConsent() ?? false)
Alert.alert(
"Privacy Setting Not Saved",
"Crash reporting is off for this session, but your choice could not be saved. Please try again.",
)
} finally {
setTelemetryUpdating(false)
}
}, []) }, [])
// Check OS permission state on first toggle attempt // Check OS permission state on first toggle attempt
@@ -193,6 +205,7 @@ export default function SettingsScreen() {
<Switch <Switch
value={crashReporting} value={crashReporting}
onValueChange={handleCrashReportingToggle} onValueChange={handleCrashReportingToggle}
disabled={telemetryUpdating}
trackColor={{ false: "#767577", true: "#22c55e" }} trackColor={{ false: "#767577", true: "#22c55e" }}
/> />
} }

View File

@@ -98,7 +98,7 @@ export default function EditConnectionScreen() {
url.trim(), url.trim(),
username.trim() && password ? { username: username.trim(), password } : undefined, username.trim() && password ? { username: username.trim(), password } : undefined,
) )
captureDiagnostic(report, result.error ? new Error(result.error) : undefined) captureDiagnostic(report)
setIsTesting(false) setIsTesting(false)
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [ Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [

View File

@@ -100,7 +100,7 @@ export default function AddConnectionScreen() {
serverUrl, serverUrl,
username.trim() && password ? { username: username.trim(), password } : undefined, username.trim() && password ? { username: username.trim(), password } : undefined,
) )
captureDiagnostic(report, result.error ? new Error(result.error) : undefined) captureDiagnostic(report)
setIsConnecting(false) setIsConnecting(false)
Alert.alert( Alert.alert(
"Connection Failed", "Connection Failed",

View File

@@ -171,7 +171,7 @@ OpenCode Mobile does NOT collect any of the following:
- Browsing history, search history - Browsing history, search history
- Sensitive info - Sensitive info
- User content (code, prompts, AI responses are not sent to our servers) - User content (code, prompts, AI responses are not sent to our servers)
- Identifiers (User ID, Device ID — Sentry uses an installation-scoped anonymous ID, see below) - User ID (the app has no accounts or user identity)
### Data Linked to You: None ### Data Linked to You: None
@@ -179,13 +179,14 @@ OpenCode Mobile does NOT collect any of the following:
| Data Type | Category | Purpose | Optional? | | Data Type | Category | Purpose | Optional? |
|---|---|---|---| |---|---|---|---|
| Crash Data | Diagnostics | App functionality | No — always on (see note) | | Crash Data | Diagnostics | App functionality | Yes — explicit opt-in |
| Performance Data | Diagnostics | App functionality | No — always on (see note) | | Performance Data | Diagnostics | App functionality | Yes — explicit opt-in |
| Other Diagnostic Data | Diagnostics | App functionality | No | | Other Diagnostic Data | Diagnostics | App functionality | Yes — explicit opt-in |
| Device ID | Identifiers | App functionality | Yes — Sentry installation ID with explicit opt-in |
**Explanation**: Sentry crash reporting sends device model, OS version, app version, and stack traces. Sentry assigns an anonymous installation ID (not linked to any Apple ID or personal information). No user-generated content (code, prompts, responses) is ever sent. **Explanation**: Sentry crash reporting sends device model, OS version, app version, and stack traces. Sentry assigns an anonymous installation ID (not linked to any Apple ID or personal information). No user-generated content (code, prompts, responses) is ever sent.
**Sentry opt-in status**: As of v0.2.3, Sentry is **always on** when a DSN is configured. If you add a settings toggle for Sentry consent (planned), change Optional? to "Yes" and add a note that users who decline are in the "Data Not Collected" category. In App Store Connect, once opt-in is implemented, this section can be removed or marked optional. **Sentry opt-in status**: Crash reporting is off by default. The first-launch consent prompt and Settings → Privacy toggle control it. Declining does not initialize Sentry; turning it off later closes the active SDK and stops new event capture.
**"Are you or your third-party partners using this data to track users?"**: No **"Are you or your third-party partners using this data to track users?"**: No

View File

@@ -186,7 +186,7 @@
<ul> <ul>
<li>Open the app → <strong>Settings</strong> → <strong>Privacy</strong> → <li>Open the app → <strong>Settings</strong> → <strong>Privacy</strong> →
<strong>Crash reporting</strong> toggle.</li> <strong>Crash reporting</strong> toggle.</li>
<li>When the toggle is off, Sentry is never initialised and no data leaves your device.</li> <li>If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured.</li>
</ul> </ul>
<h2>5. Third-Party Services</h2> <h2>5. Third-Party Services</h2>

View File

@@ -55,7 +55,7 @@ URL scrubbing: before any event is sent to Sentry, our code strips all server UR
Crash reporting is **opt-in and off by default**. On first launch you will see a consent prompt. You can change this at any time: Crash reporting is **opt-in and off by default**. On first launch you will see a consent prompt. You can change this at any time:
- Open the app → **Settings** → **Privacy** → **Crash reporting** toggle. - Open the app → **Settings** → **Privacy** → **Crash reporting** toggle.
- When the toggle is off, Sentry is never initialised and no data leaves your device. - If you decline, Sentry is never initialised. If you turn reporting off later, the active SDK is closed and no new events are captured.
--- ---

View File

@@ -13,17 +13,18 @@ import * as Sentry from "@sentry/react-native"
import appJson from "../../app.json" import appJson from "../../app.json"
import { log } from "./logbuffer" import { log } from "./logbuffer"
import type { DiagnosticReport } from "./diagnostics" import type { DiagnosticReport } from "./diagnostics"
import { scrubUrl, scrubString, scrubObject } from "./scrub"
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown" const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown"
let enabled = false let enabled = false
let handlersInstalled = false
export function initSentry() { export function initSentry() {
if (enabled) return
if (!DSN) { if (!DSN) {
log.info("sentry", "no DSN configured — telemetry disabled") log.info("sentry", "no DSN configured — telemetry disabled")
installGlobalHandlers(false) installGlobalHandlers()
return return
} }
try { try {
@@ -50,9 +51,9 @@ export function initSentry() {
}, },
beforeBreadcrumb(crumb) { beforeBreadcrumb(crumb) {
if (crumb.data && typeof crumb.data === "object") { if (crumb.data && typeof crumb.data === "object") {
crumb.data = scrubObject(crumb.data as Record<string, unknown>) crumb.data = redactObject(crumb.data as Record<string, unknown>)
} }
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message) if (typeof crumb.message === "string") crumb.message = redactString(crumb.message)
return crumb return crumb
}, },
}) })
@@ -62,7 +63,14 @@ export function initSentry() {
} catch (e) { } catch (e) {
log.warn("sentry", "init failed", String(e)) log.warn("sentry", "init failed", String(e))
} }
installGlobalHandlers(enabled) installGlobalHandlers()
}
export async function disableSentry() {
if (!enabled) return
enabled = false
await Sentry.close()
log.info("sentry", "disabled by user")
} }
// Install belt-and-braces global handlers. The Sentry RN SDK already wires // Install belt-and-braces global handlers. The Sentry RN SDK already wires
@@ -72,7 +80,10 @@ export function initSentry() {
// shared diagnostic report) even when Sentry is disabled. // shared diagnostic report) even when Sentry is disabled.
// * Telemetry-disabled builds still leave a breadcrumb that something blew // * Telemetry-disabled builds still leave a breadcrumb that something blew
// up, which is invaluable when triaging a user-shared report offline. // up, which is invaluable when triaging a user-shared report offline.
function installGlobalHandlers(sentryEnabled: boolean) { function installGlobalHandlers() {
if (handlersInstalled) return
handlersInstalled = true
type GlobalErrorUtils = { type GlobalErrorUtils = {
getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void
setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void
@@ -83,7 +94,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => { errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => {
const error = toError(err) const error = toError(err)
log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "") log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "")
if (sentryEnabled) { if (enabled) {
Sentry.captureException(error, (scope) => { Sentry.captureException(error, (scope) => {
scope.setLevel(isFatal ? "fatal" : "error") scope.setLevel(isFatal ? "fatal" : "error")
scope.setTag("crash.source", "js-global") scope.setTag("crash.source", "js-global")
@@ -104,7 +115,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
g.onunhandledrejection = (event) => { g.onunhandledrejection = (event) => {
const error = toError(event?.reason) const error = toError(event?.reason)
log.error("crash", "unhandled-rejection", error.message, error.stack ?? "") log.error("crash", "unhandled-rejection", error.message, error.stack ?? "")
if (sentryEnabled) { if (enabled) {
Sentry.captureException(error, (scope) => { Sentry.captureException(error, (scope) => {
scope.setLevel("error") scope.setLevel("error")
scope.setTag("crash.source", "promise-rejection") scope.setTag("crash.source", "promise-rejection")
@@ -130,24 +141,62 @@ function toError(value: unknown): Error {
export { scrubUrl } from "./scrub" export { scrubUrl } from "./scrub"
function scrubEvent<T extends Sentry.Event>(event: T): T { function scrubEvent<T extends Sentry.Event>(event: T): T {
if (event.request?.url) event.request.url = scrubUrl(event.request.url) if (event.request?.url) event.request.url = "<redacted-url>"
if (event.message) event.message = scrubString(event.message) if (event.message) event.message = redactString(event.message)
if (event.exception?.values) { if (event.exception?.values) {
for (const ex of event.exception.values) { for (const ex of event.exception.values) {
if (ex.value) ex.value = scrubString(ex.value) if (ex.value) ex.value = redactString(ex.value)
} }
} }
if (event.breadcrumbs) { if (event.breadcrumbs) {
for (const crumb of event.breadcrumbs) { for (const crumb of event.breadcrumbs) {
if (typeof crumb.message === "string") crumb.message = scrubString(crumb.message) if (typeof crumb.message === "string") crumb.message = redactString(crumb.message)
if (crumb.data && typeof crumb.data === "object") { if (crumb.data && typeof crumb.data === "object") {
crumb.data = scrubObject(crumb.data as Record<string, unknown>) crumb.data = redactObject(crumb.data as Record<string, unknown>)
} }
} }
} }
return event return event
} }
function redactString(value: string): string {
return value.replace(/https?:\/\/[^\s)\]}"']+/gi, "<redacted-url>")
}
function redactObject(value: Record<string, unknown>): Record<string, unknown> {
const redacted: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
if (
/^(?:id|.*Id|.*ID|url|host|hostname|port|address|server|serverUrl|target|endpoint|authorization|auth|token|password|secret|apiKey|username|cookie)$/i.test(
key,
)
) {
redacted[key] = "<redacted>"
continue
}
if (typeof item === "string") {
redacted[key] = redactString(item)
continue
}
if (Array.isArray(item)) {
redacted[key] = item.map((entry) =>
typeof entry === "string"
? redactString(entry)
: entry && typeof entry === "object"
? redactObject(entry as Record<string, unknown>)
: entry,
)
continue
}
if (item && typeof item === "object") {
redacted[key] = redactObject(item as Record<string, unknown>)
continue
}
redacted[key] = item
}
return redacted
}
// --- Helpers exposed to the rest of the app ------------------------------ // --- Helpers exposed to the rest of the app ------------------------------
export type Breadcrumb = { export type Breadcrumb = {
@@ -183,18 +232,14 @@ export function captureException(
}) })
} }
export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown) { export function captureDiagnostic(report: DiagnosticReport) {
log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)") log.info("sentry", "capture", report.classification, enabled ? "(uploading)" : "(local only)")
if (!enabled) return if (!enabled) return
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setTag("connect.classification", report.classification) scope.setTag("connect.classification", report.classification)
scope.setTag("connect.scheme", report.scheme ?? "n/a") scope.setTag("connect.scheme", report.scheme ?? "n/a")
scope.setContext("connection", { scope.setContext("connection", {
url: scrubUrl(report.url), targetType: report.isHostname ? "hostname" : "ip-address",
host: report.host,
port: report.port,
isHostname: report.isHostname,
summary: report.summary,
}) })
scope.setContext("probes", { scope.setContext("probes", {
attempts: report.attempts.map((a) => ({ attempts: report.attempts.map((a) => ({
@@ -202,13 +247,10 @@ export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown)
ok: a.ok, ok: a.ok,
status: a.status, status: a.status,
durationMs: a.durationMs, durationMs: a.durationMs,
error: a.error,
cause: a.errorCause,
})), })),
}) })
scope.setContext("device", report.device) scope.setContext("device", report.device)
const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`) Sentry.captureException(new Error(`connect ${report.classification}`))
Sentry.captureException(err)
}) })
} }

View File

@@ -20,13 +20,14 @@
*/ */
import * as SecureStore from "expo-secure-store" import * as SecureStore from "expo-secure-store"
import { initSentry, sentryEnabled } from "./sentry" import { disableSentry, initSentry, sentryEnabled } from "./sentry"
const CONSENT_KEY = "opencode_telemetry_consent" const CONSENT_KEY = "opencode_telemetry_consent"
export type ConsentState = "granted" | "denied" | "unknown" export type ConsentState = "granted" | "denied" | "unknown"
let _resolved: boolean | null = null // null = unknown, true = granted, false = denied let _resolved: boolean | null = null // null = unknown, true = granted, false = denied
let transition = Promise.resolve()
/** /**
* Load persisted consent from SecureStore. * Load persisted consent from SecureStore.
@@ -47,9 +48,8 @@ export async function loadTelemetryConsent(): Promise<ConsentState> {
_resolved = null _resolved = null
return "unknown" return "unknown"
} catch { } catch {
// SecureStore unavailable — don't clobber a previously resolved in-memory state. _resolved = false
// Return unknown so the caller can surface the modal. return "denied"
return "unknown"
} }
} }
@@ -67,14 +67,26 @@ export function hasTelemetryConsent(): boolean | null {
* Persist the user's consent decision and, if granted and Sentry is not yet * Persist the user's consent decision and, if granted and Sentry is not yet
* running, initialise it immediately. * running, initialise it immediately.
*/ */
export async function setTelemetryConsent(granted: boolean): Promise<void> { export function setTelemetryConsent(granted: boolean): Promise<void> {
_resolved = granted const next = transition.then(() => applyTelemetryConsent(granted))
try { transition = next.catch(() => undefined)
await SecureStore.setItemAsync(CONSENT_KEY, granted ? "granted" : "denied") return next
} catch { }
// Best-effort persist — in-memory state is still correct for this session.
async function applyTelemetryConsent(granted: boolean): Promise<void> {
if (granted) {
await SecureStore.setItemAsync(CONSENT_KEY, "granted")
_resolved = true
if (!sentryEnabled()) initSentry()
return
} }
if (granted && !sentryEnabled()) {
initSentry() _resolved = false
await disableSentry()
try {
await SecureStore.setItemAsync(CONSENT_KEY, "denied")
} catch (error) {
await SecureStore.deleteItemAsync(CONSENT_KEY)
throw error
} }
} }