fix(privacy): stop telemetry on consent revocation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -73,14 +73,26 @@ export default function SettingsScreen() {
|
||||
const { settings, hasBiometrics, updateSettings, lock } = useAuth()
|
||||
const { notifications, setNotification } = useSettings()
|
||||
const [osGranted, setOsGranted] = useState<boolean | null>(null)
|
||||
const [telemetryUpdating, setTelemetryUpdating] = useState(false)
|
||||
|
||||
// Telemetry consent: hasTelemetryConsent() returns null (unknown), true, or false.
|
||||
// We initialise local state from in-memory value; updates call setTelemetryConsent().
|
||||
const [crashReporting, setCrashReporting] = useState<boolean>(hasTelemetryConsent() ?? false)
|
||||
|
||||
const handleCrashReportingToggle = useCallback(async (value: boolean) => {
|
||||
setCrashReporting(value)
|
||||
await setTelemetryConsent(value)
|
||||
setTelemetryUpdating(true)
|
||||
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
|
||||
@@ -193,6 +205,7 @@ export default function SettingsScreen() {
|
||||
<Switch
|
||||
value={crashReporting}
|
||||
onValueChange={handleCrashReportingToggle}
|
||||
disabled={telemetryUpdating}
|
||||
trackColor={{ false: "#767577", true: "#22c55e" }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function EditConnectionScreen() {
|
||||
url.trim(),
|
||||
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||
)
|
||||
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||
captureDiagnostic(report)
|
||||
setIsTesting(false)
|
||||
|
||||
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
|
||||
|
||||
@@ -100,7 +100,7 @@ export default function AddConnectionScreen() {
|
||||
serverUrl,
|
||||
username.trim() && password ? { username: username.trim(), password } : undefined,
|
||||
)
|
||||
captureDiagnostic(report, result.error ? new Error(result.error) : undefined)
|
||||
captureDiagnostic(report)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
"Connection Failed",
|
||||
|
||||
@@ -171,7 +171,7 @@ OpenCode Mobile does NOT collect any of the following:
|
||||
- Browsing history, search history
|
||||
- Sensitive info
|
||||
- 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
|
||||
|
||||
@@ -179,13 +179,14 @@ OpenCode Mobile does NOT collect any of the following:
|
||||
|
||||
| Data Type | Category | Purpose | Optional? |
|
||||
|---|---|---|---|
|
||||
| Crash Data | Diagnostics | App functionality | No — always on (see note) |
|
||||
| Performance Data | Diagnostics | App functionality | No — always on (see note) |
|
||||
| Other Diagnostic Data | Diagnostics | App functionality | No |
|
||||
| Crash Data | Diagnostics | App functionality | Yes — explicit opt-in |
|
||||
| Performance Data | Diagnostics | App functionality | Yes — explicit opt-in |
|
||||
| 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.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
<ul>
|
||||
<li>Open the app → <strong>Settings</strong> → <strong>Privacy</strong> →
|
||||
<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>
|
||||
|
||||
<h2>5. Third-Party Services</h2>
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,17 +13,18 @@ import * as Sentry from "@sentry/react-native"
|
||||
import appJson from "../../app.json"
|
||||
import { log } from "./logbuffer"
|
||||
import type { DiagnosticReport } from "./diagnostics"
|
||||
import { scrubUrl, scrubString, scrubObject } from "./scrub"
|
||||
|
||||
const DSN = process.env.EXPO_PUBLIC_SENTRY_DSN
|
||||
const APP_VERSION = (appJson as { expo?: { version?: string } }).expo?.version ?? "unknown"
|
||||
|
||||
let enabled = false
|
||||
let handlersInstalled = false
|
||||
|
||||
export function initSentry() {
|
||||
if (enabled) return
|
||||
if (!DSN) {
|
||||
log.info("sentry", "no DSN configured — telemetry disabled")
|
||||
installGlobalHandlers(false)
|
||||
installGlobalHandlers()
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -50,9 +51,9 @@ export function initSentry() {
|
||||
},
|
||||
beforeBreadcrumb(crumb) {
|
||||
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
|
||||
},
|
||||
})
|
||||
@@ -62,7 +63,14 @@ export function initSentry() {
|
||||
} catch (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
|
||||
@@ -72,7 +80,10 @@ export function initSentry() {
|
||||
// shared diagnostic report) even when Sentry is disabled.
|
||||
// * Telemetry-disabled builds still leave a breadcrumb that something blew
|
||||
// up, which is invaluable when triaging a user-shared report offline.
|
||||
function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
function installGlobalHandlers() {
|
||||
if (handlersInstalled) return
|
||||
handlersInstalled = true
|
||||
|
||||
type GlobalErrorUtils = {
|
||||
getGlobalHandler?: () => (err: unknown, isFatal?: boolean) => void
|
||||
setGlobalHandler?: (handler: (err: unknown, isFatal?: boolean) => void) => void
|
||||
@@ -83,7 +94,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
errorUtils.setGlobalHandler((err: unknown, isFatal?: boolean) => {
|
||||
const error = toError(err)
|
||||
log.error("crash", isFatal ? "FATAL" : "non-fatal", error.message, error.stack ?? "")
|
||||
if (sentryEnabled) {
|
||||
if (enabled) {
|
||||
Sentry.captureException(error, (scope) => {
|
||||
scope.setLevel(isFatal ? "fatal" : "error")
|
||||
scope.setTag("crash.source", "js-global")
|
||||
@@ -104,7 +115,7 @@ function installGlobalHandlers(sentryEnabled: boolean) {
|
||||
g.onunhandledrejection = (event) => {
|
||||
const error = toError(event?.reason)
|
||||
log.error("crash", "unhandled-rejection", error.message, error.stack ?? "")
|
||||
if (sentryEnabled) {
|
||||
if (enabled) {
|
||||
Sentry.captureException(error, (scope) => {
|
||||
scope.setLevel("error")
|
||||
scope.setTag("crash.source", "promise-rejection")
|
||||
@@ -130,24 +141,62 @@ function toError(value: unknown): Error {
|
||||
export { scrubUrl } from "./scrub"
|
||||
|
||||
function scrubEvent<T extends Sentry.Event>(event: T): T {
|
||||
if (event.request?.url) event.request.url = scrubUrl(event.request.url)
|
||||
if (event.message) event.message = scrubString(event.message)
|
||||
if (event.request?.url) event.request.url = "<redacted-url>"
|
||||
if (event.message) event.message = redactString(event.message)
|
||||
if (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) {
|
||||
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") {
|
||||
crumb.data = scrubObject(crumb.data as Record<string, unknown>)
|
||||
crumb.data = redactObject(crumb.data as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ------------------------------
|
||||
|
||||
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)")
|
||||
if (!enabled) return
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("connect.classification", report.classification)
|
||||
scope.setTag("connect.scheme", report.scheme ?? "n/a")
|
||||
scope.setContext("connection", {
|
||||
url: scrubUrl(report.url),
|
||||
host: report.host,
|
||||
port: report.port,
|
||||
isHostname: report.isHostname,
|
||||
summary: report.summary,
|
||||
targetType: report.isHostname ? "hostname" : "ip-address",
|
||||
})
|
||||
scope.setContext("probes", {
|
||||
attempts: report.attempts.map((a) => ({
|
||||
@@ -202,13 +247,10 @@ export function captureDiagnostic(report: DiagnosticReport, rawError?: unknown)
|
||||
ok: a.ok,
|
||||
status: a.status,
|
||||
durationMs: a.durationMs,
|
||||
error: a.error,
|
||||
cause: a.errorCause,
|
||||
})),
|
||||
})
|
||||
scope.setContext("device", report.device)
|
||||
const err = rawError instanceof Error ? rawError : new Error(`connect ${report.classification}: ${report.summary}`)
|
||||
Sentry.captureException(err)
|
||||
Sentry.captureException(new Error(`connect ${report.classification}`))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
*/
|
||||
|
||||
import * as SecureStore from "expo-secure-store"
|
||||
import { initSentry, sentryEnabled } from "./sentry"
|
||||
import { disableSentry, initSentry, sentryEnabled } from "./sentry"
|
||||
|
||||
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.
|
||||
@@ -47,9 +48,8 @@ export async function loadTelemetryConsent(): Promise<ConsentState> {
|
||||
_resolved = null
|
||||
return "unknown"
|
||||
} catch {
|
||||
// SecureStore unavailable — don't clobber a previously resolved in-memory state.
|
||||
// Return unknown so the caller can surface the modal.
|
||||
return "unknown"
|
||||
_resolved = false
|
||||
return "denied"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +67,26 @@ export function hasTelemetryConsent(): boolean | null {
|
||||
* Persist the user's consent decision and, if granted and Sentry is not yet
|
||||
* running, initialise it immediately.
|
||||
*/
|
||||
export async function setTelemetryConsent(granted: boolean): Promise<void> {
|
||||
_resolved = granted
|
||||
try {
|
||||
await SecureStore.setItemAsync(CONSENT_KEY, granted ? "granted" : "denied")
|
||||
} catch {
|
||||
// Best-effort persist — in-memory state is still correct for this session.
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user