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 { 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" }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})`, [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user