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

@@ -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}`))
})
}

View File

@@ -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
}
}