diff --git a/README.md b/README.md index aa154e6b..33bd75dc 100644 --- a/README.md +++ b/README.md @@ -264,10 +264,11 @@ Info.plist keys at prebuild. | `environment` | yes | Sentry environment (e.g. `production`, `staging`). | | `release` | no | Release tag. Defaults to the app's version (`versionName`+`versionCode` / `CFBundleShortVersionString`+`CFBundleVersion`). | | `sampleRate` | no | Error sample rate (0–1). | -| `tracesSampleRate` | no | Performance trace sample rate. Default 0.1 when capture-application-data is on; 0 when off. | +| `tracesSampleRate` | no | Performance trace sample rate (0–1) for the non-`debug` baseline. The `debug` toggle forces full (1.0) sampling while its window is on; day-to-day performance signal rides the always-on metrics layer, so this can stay 0. | | `rpcArgsBytes` | no | Max bytes of RPC arguments captured on spans. | | `diagnosticsEnabledDefault` | no | Fresh-install default for the diagnostics toggle. | -| `captureApplicationDataDefault` | no | Fresh-install default for the capture-application-data toggle. Keep off in production. | +| `applicationUsageDataDefault` | no | Fresh-install default for the application-usage-data toggle. Keep off in production. | +| `debugDefault` | no | Fresh-install default for the debug toggle (per-RPC traces + richer capture, auto-expires 72h after enable). Keep off in production. | | `enableLogs` | no | Forward Sentry structured logs from the backend process. Pair with `enableLogs: true` in your host `Sentry.init` setup. | Omitting `sentry` (or removing it on a re-prebuild) strips all keys this plugin @@ -315,8 +316,12 @@ From `@comapeo/core-react-native/sentry`: - `getDiagnosticsEnabled()` / `setDiagnosticsEnabled(value)` — the diagnostics opt-out toggle. Restart-to-activate; setting `false` also wipes the on-disk envelope cache. -- `getCaptureApplicationData()` / `setCaptureApplicationData(value)` — the - capture-application-data toggle (gates traces and richer payloads). +- `getApplicationUsageData()` / `setApplicationUsageData(value)` — the + opt-in application-usage telemetry toggle (default off). Restart-to-activate; + setting `false` also wipes the on-disk envelope cache. +- `getDebugEnabled()` / `setDebugEnabled(value)` — opt-in debug mode (per-RPC + traces, `@comapeo/core` OTel spans, richer capture). Restart-to-activate and + auto-expires 72h after the most recent enable. ### Uploading artifacts to Sentry diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreApplicationLifecycleListener.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreApplicationLifecycleListener.kt index 00822d55..bcd1842a 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreApplicationLifecycleListener.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreApplicationLifecycleListener.kt @@ -54,7 +54,7 @@ class ComapeoCoreApplicationLifecycleListener : ApplicationLifecycleListener { context = application, processName = application.packageName, procKey = SentryTags.PROC_MAIN, - captureApplicationData = prefs.readCaptureApplicationData(), + applicationUsageData = prefs.readApplicationUsageData(), snapshot = snapshot, ) } diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt index f38b2a58..c3299281 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt @@ -173,27 +173,72 @@ class ComapeoCoreModule : Module() { // `sentryConfig` — baked-in by app.plugin.js at prebuild; spread into // `Sentry.init(...)` by the JS `/sentry` sub-export. Empty map when the - // plugin isn't registered so spreading is always safe. + // plugin isn't registered so spreading is always safe. `userId` is + // derived with the same launch snapshot the FGS uses, so both + // processes report the same Sentry user.id. Constant("sentryConfig") { - appContext.reactContext?.let { - SentryConfig.loadFromManifest(it)?.toSentryInitMap() + appContext.reactContext?.let { ctx -> + val prefs = ComapeoPrefs.open(ctx) + SentryConfig.loadFromManifest(ctx)?.toSentryInitMap( + DeviceTags.compute(ctx), + prefs.deriveSentryUserId(prefs.readApplicationUsageData()), + ) } ?: emptyMap() } - // `sentryPreferences` — snapshot-at-boot; toggle changes take effect on next - // launch. Returns baked-in defaults pre-attach so JS can spread unconditionally. - Constant("sentryPreferences") { + // The permanent root user ID (lazily generated on first read). Local + // debugging aid only — Sentry sees derived hashes, never this value. + // The host app may show it in a debug/about screen so a user can share + // it and support can recompute their historical monthly user.ids. + Function("getSentryRootUserId") { + val ctx = appContext.reactContext + ?: throw IllegalStateException( + "getSentryRootUserId called before native context attached", + ) + ComapeoPrefs.open(ctx).readRootUserId() + } + + // `sentryPreferencesAtLaunch` — the snapshot in effect this session; toggle + // changes take effect on next launch. Returns baked-in defaults pre-attach so + // JS can spread unconditionally. For the current saved value use + // `getCurrentSentryPreferences`. + Constant("sentryPreferencesAtLaunch") { + val ctx = appContext.reactContext + if (ctx == null) { + mapOf( + "diagnosticsEnabled" to ComapeoPrefs.DEFAULT_DIAGNOSTICS_ENABLED, + "applicationUsageData" to ComapeoPrefs.DEFAULT_APPLICATION_USAGE_DATA, + "debug" to ComapeoPrefs.DEFAULT_DEBUG, + ) + } else { + val prefs = ComapeoPrefs.open(ctx) + mapOf( + "diagnosticsEnabled" to prefs.readDiagnosticsEnabled(), + "applicationUsageData" to prefs.readApplicationUsageData(), + "debug" to prefs.readDebugEnabled(), + ) + } + } + + // Live read of the current persisted values — reflects a `setX` made this + // session and survives a JS reload (unlike the `sentryPreferencesAtLaunch` + // Constant), so a settings screen can read the user's choice without keeping + // its own copy. Raw `debug` (no 72h auto-off side effect — that's applied by + // readDebugEnabled at launch). + Function("getCurrentSentryPreferences") { val ctx = appContext.reactContext if (ctx == null) { mapOf( "diagnosticsEnabled" to ComapeoPrefs.DEFAULT_DIAGNOSTICS_ENABLED, - "captureApplicationData" to ComapeoPrefs.DEFAULT_CAPTURE_APPLICATION_DATA, + "applicationUsageData" to ComapeoPrefs.DEFAULT_APPLICATION_USAGE_DATA, + "debug" to ComapeoPrefs.DEFAULT_DEBUG, ) } else { val prefs = ComapeoPrefs.open(ctx) mapOf( "diagnosticsEnabled" to prefs.readDiagnosticsEnabled(), - "captureApplicationData" to prefs.readCaptureApplicationData(), + "applicationUsageData" to prefs.readApplicationUsageData(), + "debug" to prefs.readDebugStored(), ) } } @@ -210,12 +255,21 @@ class ComapeoCoreModule : Module() { if (!value) ComapeoPrefs.wipeSentryOutbox(ctx) } - AsyncFunction("setCaptureApplicationData") { value: Boolean -> + AsyncFunction("setApplicationUsageData") { value: Boolean -> + val ctx = appContext.reactContext + ?: throw IllegalStateException( + "setApplicationUsageData called before native context attached", + ) + ComapeoPrefs.open(ctx).writeApplicationUsageData(value) + if (!value) ComapeoPrefs.wipeSentryOutbox(ctx) + } + + AsyncFunction("setDebugEnabled") { value: Boolean -> val ctx = appContext.reactContext ?: throw IllegalStateException( - "setCaptureApplicationData called before native context attached", + "setDebugEnabled called before native context attached", ) - ComapeoPrefs.open(ctx).writeCaptureApplicationData(value) + ComapeoPrefs.open(ctx).writeDebugEnabled(value) if (!value) ComapeoPrefs.wipeSentryOutbox(ctx) } diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt index 9214922f..0970ad92 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt @@ -41,7 +41,10 @@ class ComapeoCoreService : Service() { // Snapshotted in onCreate, consumed when the Node backend is built lazily in // ensureBackendInitialized() after startForeground(). private var effectiveSentryConfig: SentryConfig? = null - private var captureApplicationData: Boolean = false + private var applicationUsageData: Boolean = false + private var debug: Boolean = false + private var deviceTags: DeviceTags? = null + private var sentryUserId: String? = null private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) /** Active self-terminate watchdog (see [onNodeStateChange]). Armed at most once @@ -88,14 +91,21 @@ class ComapeoCoreService : Service() { val sentryConfig = SentryConfig.loadFromManifest(applicationContext) val prefs = ComapeoPrefs.open(applicationContext) effectiveSentryConfig = if (prefs.readDiagnosticsEnabled()) sentryConfig else null - captureApplicationData = prefs.readCaptureApplicationData() + + // Read debug + usage before SentryFgsBridge.init: readDebugEnabled() + // may queue the auto_disabled breadcrumb, which init drains — read + // first or that crumb is lost on the auto-off launch. Independent of + // the diagnostics gate. + applicationUsageData = prefs.readApplicationUsageData() + debug = prefs.readDebugEnabled() // Init the Sentry bridge here, not after startForeground: breadcrumbs no-op // until it's initialised, so deferring it would drop the pre-start trail. // It's cheap relative to the Node backend (libnode load), which is the only // part deferred to ensureBackendInitialized() to keep off the FGS deadline. effectiveSentryConfig?.let { cfg -> - SentryFgsBridge.init(applicationContext, cfg) + sentryUserId = prefs.deriveSentryUserId(applicationUsageData) + SentryFgsBridge.init(applicationContext, cfg, sentryUserId) } logCrumb(SentryCategories.FGS, "ComapeoCoreService.onCreate") @@ -119,6 +129,7 @@ class ComapeoCoreService : Service() { ) } + deviceTags = DeviceTags.compute(applicationContext) serviceScope.launch(Dispatchers.IO) { // Snapshot the previous FGS session's anchors before stamping // this run's — the decoder must see what was true at the old exit. @@ -131,7 +142,7 @@ class ComapeoCoreService : Service() { // android:process — a rename can't silently break the filter. processName = currentProcessName(applicationContext), procKey = SentryTags.PROC_FGS, - captureApplicationData = captureApplicationData, + applicationUsageData = applicationUsageData, snapshot = snapshot, ) } @@ -145,7 +156,10 @@ class ComapeoCoreService : Service() { nodeJSService = NodeJSService( applicationContext, sentryConfig = effectiveSentryConfig, - captureApplicationData = captureApplicationData, + applicationUsageData = applicationUsageData, + debug = debug, + deviceTags = deviceTags, + sentryUserId = sentryUserId, ) } diff --git a/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt b/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt index 797a7a67..8379b8af 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt @@ -1,70 +1,168 @@ package com.comapeo.core import android.content.Context +import android.content.SharedPreferences import androidx.core.content.edit import java.io.File /** * Persistent storage for Sentry-related user preferences. Snapshot-at-boot: * each process reads on its own cold start, so toggle changes only take effect - * after the next launch. Flipping to `false` also wipes the on-disk Sentry - * envelope cache so events queued in the current session never ship. + * after the next launch. Flipping a toggle to `false` also wipes the on-disk + * Sentry envelope cache so events queued in the current session never ship. * - * Constructor takes pure read/write lambdas to keep tests free of - * `SharedPreferences` (unmocked on the JVM unit-test classpath). [open] is - * the production constructor. + * Persistence goes through a [Store] so unit tests can back it with a plain + * map instead of a real `SharedPreferences` (unmocked on the JVM unit-test + * classpath). [open] is the production constructor. */ internal class ComapeoPrefs( - private val readBool: (String) -> Boolean?, - private val writeBool: (String, Boolean) -> Unit, + private val store: Store, private val defaults: Defaults, + /** Wall clock; injectable so the debug-auto-off tests don't depend on real time. */ + private val now: () -> Long = { System.currentTimeMillis() }, ) { + /** + * Minimal persistence surface. `null` from a getter means "key absent" + * (so the caller falls back to a default). Production wraps + * `SharedPreferences`; tests use an in-memory map. + */ + internal interface Store { + fun getBoolean(key: String): Boolean? + fun putBoolean(key: String, value: Boolean) + fun getLong(key: String): Long? + fun putLong(key: String, value: Long) + fun getString(key: String): String? + fun putString(key: String, value: String) + fun remove(key: String) + } + data class Defaults( val diagnosticsEnabled: Boolean, - val captureApplicationData: Boolean, + val applicationUsageData: Boolean, + val debug: Boolean, ) fun readDiagnosticsEnabled(): Boolean = - readBool(KEY_DIAGNOSTICS_ENABLED) ?: defaults.diagnosticsEnabled - - fun readCaptureApplicationData(): Boolean = - readBool(KEY_CAPTURE_APPLICATION_DATA) ?: defaults.captureApplicationData + store.getBoolean(KEY_DIAGNOSTICS_ENABLED) ?: defaults.diagnosticsEnabled fun writeDiagnosticsEnabled(value: Boolean) { - writeBool(KEY_DIAGNOSTICS_ENABLED, value) + store.putBoolean(KEY_DIAGNOSTICS_ENABLED, value) + } + + fun readApplicationUsageData(): Boolean = + store.getBoolean(KEY_APPLICATION_USAGE_DATA) ?: defaults.applicationUsageData + + /** + * Raw stored `debug` value with no auto-off side effect — the user's saved + * toggle for a live settings read. [readDebugEnabled] applies the 72h + * auto-off (and its disk mutation) at launch; this must not, so a getter + * never writes. + */ + fun readDebugStored(): Boolean = + store.getBoolean(KEY_DEBUG) ?: defaults.debug + + fun writeApplicationUsageData(value: Boolean) { + store.putBoolean(KEY_APPLICATION_USAGE_DATA, value) + } + + /** + * Read the `debug` toggle, applying the [DEBUG_MAX_AGE_MS] auto-off: if + * debug was switched on longer ago than that, flip it off, clear the + * timestamp, queue a `comapeo.debug.auto_disabled` breadcrumb, and return + * `false`. A `debug=true` cell with no timestamp (e.g. enabled via the + * configured default) is treated as "enabled now" and stamped on first read. + * + * The window is wall-clock based (it must survive process restarts), so a + * backward clock change is treated conservatively: an enable timestamp in + * the future expires debug rather than extending it. This is a best-effort + * privacy window on the user's own device, not a security boundary. + */ + fun readDebugEnabled(): Boolean { + val stored = store.getBoolean(KEY_DEBUG) ?: defaults.debug + if (!stored) return false + val enabledAt = store.getLong(KEY_DEBUG_ENABLED_AT_MS) + if (enabledAt == null) { + // No recorded start (e.g. enabled via the default): start the clock. + store.putLong(KEY_DEBUG_ENABLED_AT_MS, now()) + return true + } + val age = now() - enabledAt + if (age < 0 || age > DEBUG_MAX_AGE_MS) { + store.putBoolean(KEY_DEBUG, false) + store.remove(KEY_DEBUG_ENABLED_AT_MS) + DebugAutoOff.queueBreadcrumb() + return false + } + return true } - fun writeCaptureApplicationData(value: Boolean) { - writeBool(KEY_CAPTURE_APPLICATION_DATA, value) + /** + * The permanent per-install root user ID (a short `XXXX-XXXX-XXXX` code — + * see [SentryUserId.generateRootId]), generated lazily on first read. + * Never sent to Sentry — Sentry `user.id` values are derived from it via + * [SentryUserId.derive]. Exposed to the host app (via + * `getSentryRootUserId`) so a user can share it for debugging and we can + * recompute their historical monthly IDs. Lives in SharedPreferences (not + * Keystore) deliberately: uninstall should genuinely reset identity. + */ + fun readRootUserId(): String { + store.getString(KEY_ROOT_USER_ID)?.let { return it } + // Benign first-launch race: the main and FGS processes could both + // generate before either write lands. Worst case is one session with + // two IDs; both processes converge on the persisted value next launch. + val generated = SentryUserId.generateRootId() + store.putString(KEY_ROOT_USER_ID, generated) + return generated + } + + /** + * The Sentry `user.id` for this launch: permanent when the user opted in + * to application-usage data, otherwise rotating monthly (UTC). + */ + fun deriveSentryUserId(applicationUsageData: Boolean): String = + SentryUserId.derive(readRootUserId(), applicationUsageData, now()) + + /** + * Write `debug`, stamping (true) or clearing (false) the enable timestamp + * synchronously so the window starts/stops with the value. Re-writing + * `true` refreshes the window. + */ + fun writeDebugEnabled(value: Boolean) { + store.putBoolean(KEY_DEBUG, value) + if (value) { + store.putLong(KEY_DEBUG_ENABLED_AT_MS, now()) + } else { + store.remove(KEY_DEBUG_ENABLED_AT_MS) + } } companion object { const val PREFS_NAME = "com.comapeo.core.prefs" const val KEY_DIAGNOSTICS_ENABLED = "sentry.diagnosticsEnabled" - const val KEY_CAPTURE_APPLICATION_DATA = "sentry.captureApplicationData" + const val KEY_APPLICATION_USAGE_DATA = "sentry.applicationUsageData" + const val KEY_DEBUG = "sentry.debug" + const val KEY_DEBUG_ENABLED_AT_MS = "sentry.debugEnabledAtMs" + const val KEY_ROOT_USER_ID = "sentry.rootUserId" + const val DEFAULT_DIAGNOSTICS_ENABLED = true - const val DEFAULT_CAPTURE_APPLICATION_DATA = false + const val DEFAULT_APPLICATION_USAGE_DATA = false + const val DEFAULT_DEBUG = false + + /** 72h in milliseconds — debug mode auto-disables this long after enable. */ + const val DEBUG_MAX_AGE_MS = 72L * 60 * 60 * 1000 - /** - * `commit = true` (not `apply`) so a subsequent [wipeSentryOutbox] is - * guaranteed to see a durable `false` on disk. Callers run from - * AsyncFunction coroutines so the sync cost is acceptable. - */ @JvmStatic fun open(context: Context): ComapeoPrefs { val sentryConfig = SentryConfig.loadFromManifest(context) val defaults = Defaults( diagnosticsEnabled = sentryConfig?.diagnosticsEnabledDefault ?: DEFAULT_DIAGNOSTICS_ENABLED, - captureApplicationData = sentryConfig?.captureApplicationDataDefault - ?: DEFAULT_CAPTURE_APPLICATION_DATA, + applicationUsageData = sentryConfig?.applicationUsageDataDefault + ?: DEFAULT_APPLICATION_USAGE_DATA, + debug = sentryConfig?.debugDefault ?: DEFAULT_DEBUG, ) val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return ComapeoPrefs( - readBool = { key -> if (sp.contains(key)) sp.getBoolean(key, false) else null }, - writeBool = { key, value -> sp.edit(commit = true) { putBoolean(key, value) } }, - defaults = defaults, - ) + return ComapeoPrefs(SharedPrefsStore(sp), defaults) } /** @@ -88,4 +186,67 @@ internal class ComapeoPrefs( } } } + + /** + * `commit = true` (not `apply`) so a subsequent [wipeSentryOutbox] is + * guaranteed to see a durable value on disk. Callers run from + * AsyncFunction coroutines so the sync cost is acceptable. + */ + private class SharedPrefsStore(private val sp: SharedPreferences) : Store { + override fun getBoolean(key: String): Boolean? = + if (sp.contains(key)) sp.getBoolean(key, false) else null + + override fun putBoolean(key: String, value: Boolean) { + sp.edit(commit = true) { putBoolean(key, value) } + } + + override fun getLong(key: String): Long? = + if (sp.contains(key)) sp.getLong(key, 0L) else null + + override fun putLong(key: String, value: Long) { + sp.edit(commit = true) { putLong(key, value) } + } + + override fun getString(key: String): String? = sp.getString(key, null) + + override fun putString(key: String, value: String) { + sp.edit(commit = true) { putString(key, value) } + } + + override fun remove(key: String) { + sp.edit(commit = true) { remove(key) } + } + } +} + +/** + * Holds the `comapeo.debug.auto_disabled` breadcrumb queued by the debug + * auto-off. [ComapeoPrefs.readDebugEnabled] runs before `Sentry.init`, so the + * breadcrumb can't be added directly; it's drained by [SentryFgsBridge.init] / + * RN init once the SDK is up. + * + * Known gap: the main app process and the `:ComapeoCore` FGS process are + * separate JVMs with separate `DebugAutoOff` statics, and only the FGS side + * drains it. If the main-process read ([ComapeoCoreModule]) performs the + * auto-off first, the FGS read then sees `debug` already false and queues + * nothing, so that one breadcrumb is dropped. Consequence is cosmetic — the + * auto-off itself still happens; only the "when it turned off" timeline marker + * is missing for that launch. Delivering it would need cross-process plumbing + * (expose the pending flag to JS and drain it in the RN `initSentry` path). + */ +internal object DebugAutoOff { + @Volatile + var pending: Boolean = false + private set + + fun queueBreadcrumb() { + pending = true + } + + /** Consume the pending flag; returns whether a breadcrumb is owed. */ + fun consume(): Boolean { + val owed = pending + pending = false + return owed + } } diff --git a/android/src/main/java/com/comapeo/core/DeviceTags.kt b/android/src/main/java/com/comapeo/core/DeviceTags.kt new file mode 100644 index 00000000..04f0d727 --- /dev/null +++ b/android/src/main/java/com/comapeo/core/DeviceTags.kt @@ -0,0 +1,80 @@ +package com.comapeo.core + +import android.app.ActivityManager +import android.content.Context +import android.os.Build + +/** + * Low-cardinality device classification. Buckets the device + * into low/mid/high by RAM + CPU cores so a metric like + * "low-end devices are 4× slower at observation.create" is a dashboard + * query rather than a 2,000-model cardinality explosion. Computed once + * at process start and cached on [SentryConfig]. + * + * Raw model/manufacturer stay on the event/trace scope (native SDK + * attaches them); only the bucket rides on metrics. + */ +data class DeviceTags( + val platform: String, + val deviceClass: String, + val osMajor: String, +) { + companion object { + const val PLATFORM = "android" + + const val CLASS_LOW = "low" + const val CLASS_MID = "mid" + const val CLASS_HIGH = "high" + + private const val GB = 1024L * 1024 * 1024 + + /** + * Thresholds: + * low: < 3 GB RAM OR < 4 cores + * mid: 3–6 GB AND 4–6 cores + * high: ≥ 6 GB AND ≥ 6 cores + * + * A device that's high on one axis but low on the other falls to + * the lower class — the slow axis dominates perceived perf. + * + * @param totalMemBytes `ActivityManager.MemoryInfo.totalMem`. + * @param cores `Runtime.getRuntime().availableProcessors()`. + */ + @JvmStatic + fun classify(totalMemBytes: Long, cores: Int): String { + // Boundaries are inclusive at the lower edge of each higher band: + // exactly 3 GB / exactly 4 cores is the floor of `mid`. + val ramHigh = totalMemBytes >= 6 * GB + val ramMid = totalMemBytes >= 3 * GB + val coresHigh = cores >= 6 + val coresMid = cores >= 4 + return when { + ramHigh && coresHigh -> CLASS_HIGH + ramMid && coresMid -> CLASS_MID + else -> CLASS_LOW + } + } + + /** `android.` from `Build.VERSION.RELEASE`. */ + @JvmStatic + fun osMajor(release: String?): String { + val major = release?.split(".")?.firstOrNull()?.takeIf { it.isNotEmpty() } + ?: "0" + return "$PLATFORM.$major" + } + + @JvmStatic + fun compute(context: Context): DeviceTags { + val activityManager = + context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager + val memInfo = ActivityManager.MemoryInfo() + activityManager?.getMemoryInfo(memInfo) + val cores = Runtime.getRuntime().availableProcessors() + return DeviceTags( + platform = PLATFORM, + deviceClass = classify(memInfo.totalMem, cores), + osMajor = osMajor(Build.VERSION.RELEASE), + ) + } + } +} diff --git a/android/src/main/java/com/comapeo/core/ExitReasonsCollector.kt b/android/src/main/java/com/comapeo/core/ExitReasonsCollector.kt index 9a20f288..300316bd 100644 --- a/android/src/main/java/com/comapeo/core/ExitReasonsCollector.kt +++ b/android/src/main/java/com/comapeo/core/ExitReasonsCollector.kt @@ -65,12 +65,12 @@ internal data class AnchorSnapshot( * `bg_duration_bucket`, `comapeo.fgs.killed_in_background`) are * low-resolution aggregate data and flow at the diagnostic tier; the exact * millisecond durations are usage-shape data and only flow when - * capture-application-data is on. + * application-usage-data is on. */ internal class ExitReasonsCollector( private val anchors: BackgroundAnchors, private val snapshot: AnchorSnapshot, - private val captureApplicationData: Boolean, + private val applicationUsageData: Boolean, private val nowMs: () -> Long = System::currentTimeMillis, ) { /** [newLastSeenMs] is non-null when there are metrics to report; the @@ -133,7 +133,7 @@ internal class ExitReasonsCollector( if (procKey == SentryTags.PROC_FGS) { put(SentryTags.FGS_KILLED_IN_BACKGROUND, backgroundedForMs != null) } - if (captureApplicationData) { + if (applicationUsageData) { aliveForMs?.let { put("alive_for_ms", it) } if (procKey == SentryTags.PROC_MAIN) { backgroundedForMs?.let { put("backgrounded_for_ms", it) } @@ -179,7 +179,7 @@ internal class ExitReasonsCollector( context: Context, processName: String, procKey: String, - captureApplicationData: Boolean, + applicationUsageData: Boolean, snapshot: AnchorSnapshot, ) { if (Build.VERSION.SDK_INT < 30) { @@ -203,7 +203,7 @@ internal class ExitReasonsCollector( val result = ExitReasonsCollector( anchors = anchors, snapshot = snapshot, - captureApplicationData = captureApplicationData, + applicationUsageData = applicationUsageData, ).collect(processName, procKey, queryRecords(context)) log("[${SentryCategories.EXIT}] $procKey: ${result.metrics.size} new exit record(s)") if (result.metrics.isEmpty()) return diff --git a/android/src/main/java/com/comapeo/core/NodeJSService.kt b/android/src/main/java/com/comapeo/core/NodeJSService.kt index 1b41d546..d408a120 100644 --- a/android/src/main/java/com/comapeo/core/NodeJSService.kt +++ b/android/src/main/java/com/comapeo/core/NodeJSService.kt @@ -63,8 +63,14 @@ class NodeJSService( context: android.content.Context, /** Forwarded as `--sentry*` argv to backend/loader.mjs. `null` → loader skips Sentry. */ private val sentryConfig: SentryConfig? = null, - /** Backend forwards span/event data with potential app content when `true`. Ignored when [sentryConfig] is null. */ - private val captureApplicationData: Boolean = false, + /** Gates the usage-tier metric dimensions (RPC `method`, sync volume buckets) on the backend. Ignored when [sentryConfig] is null. */ + private val applicationUsageData: Boolean = false, + /** Per-RPC tracing + consoleIntegration when `true`. Ignored when [sentryConfig] is null. */ + private val debug: Boolean = false, + /** Device classification tags forwarded to Node for the `.by_device` metrics. */ + private val deviceTags: DeviceTags? = null, + /** Derived Sentry user.id (monthly/permanent hash) forwarded as `--sentryUserId`. */ + private val sentryUserId: String? = null, /** Max ms in STARTING before the watchdog forces ERROR. 30 s covers cold boot + native addon dlopens. */ private val startupTimeoutMs: Long = 30_000, ) : ContextWrapper(context) { @@ -380,10 +386,21 @@ class NodeJSService( args += "--sentryEnvironment=${cfg.environment}" args += "--sentryRelease=${cfg.release}" cfg.sampleRate?.let { args += "--sentrySampleRate=$it" } - cfg.tracesSampleRate?.let { args += "--sentryTracesSampleRate=$it" } + // Native owns the trace-sampling decision: full while the debug + // window is on, else the plugin-configured cap (0 if unset). The + // backend mirrors this value rather than re-deciding. + val effectiveTracesSampleRate = if (debug) 1.0 else (cfg.tracesSampleRate ?: 0.0) + args += "--sentryTracesSampleRate=$effectiveTracesSampleRate" cfg.rpcArgsBytes?.let { args += "--sentryRpcArgsBytes=$it" } if (cfg.enableLogs == true) args += "--sentryEnableLogs" - if (captureApplicationData) args += "--captureApplicationData" + sentryUserId?.let { args += "--sentryUserId=$it" } + if (applicationUsageData) args += "--applicationUsageData" + if (debug) args += "--debug" + deviceTags?.let { + args += "--deviceClass=${it.deviceClass}" + args += "--osMajor=${it.osMajor}" + args += "--platformTag=${it.platform}" + } // Forward node-spawn's trace so Node spans nest under it; fall back to // the transaction defensively if node-spawn hasn't opened yet. diff --git a/android/src/main/java/com/comapeo/core/SentryConfig.kt b/android/src/main/java/com/comapeo/core/SentryConfig.kt index 80b23331..9bc42796 100644 --- a/android/src/main/java/com/comapeo/core/SentryConfig.kt +++ b/android/src/main/java/com/comapeo/core/SentryConfig.kt @@ -23,8 +23,10 @@ data class SentryConfig( val rpcArgsBytes: Int? = null, /** Fresh-install default for diagnostics toggle. `null` → `true`. User write wins. */ val diagnosticsEnabledDefault: Boolean? = null, - /** Fresh-install default for capture-application-data toggle. `null` → `false`. */ - val captureApplicationDataDefault: Boolean? = null, + /** Fresh-install default for application-usage-data toggle. `null` → `false`. */ + val applicationUsageDataDefault: Boolean? = null, + /** Fresh-install default for the `debug` toggle. `null` → `false`. */ + val debugDefault: Boolean? = null, /** Opt in to Sentry structured logs (`Sentry.logger.*`). `null` → off. */ val enableLogs: Boolean? = null, /** @@ -42,14 +44,30 @@ data class SentryConfig( /** * Subset that maps cleanly to `Sentry.init` options on the RN side. Sent to JS * as the `sentryConfig` constant; consumers spread into `Sentry.init({...})`. + * `userId` is the derived Sentry user.id (monthly hash or permanent hash, + * never the root ID) — `initSentry` applies it via `Sentry.setUser`. */ - fun toSentryInitMap(): Map = buildMap { + fun toSentryInitMap( + deviceTags: DeviceTags? = null, + userId: String? = null, + ): Map = buildMap { put("dsn", dsn) put("environment", environment) put("release", release) sampleRate?.let { put("sampleRate", it) } tracesSampleRate?.let { put("tracesSampleRate", it) } enableLogs?.let { put("enableLogs", it) } + userId?.let { put("userId", it) } + deviceTags?.let { + put( + "deviceTags", + mapOf( + "platform" to it.platform, + "deviceClass" to it.deviceClass, + "osMajor" to it.osMajor, + ), + ) + } } companion object { @@ -62,8 +80,9 @@ data class SentryConfig( const val META_RPC_ARGS_BYTES = "com.comapeo.core.sentry.rpcArgsBytes" const val META_DIAGNOSTICS_ENABLED_DEFAULT = "com.comapeo.core.sentry.diagnosticsEnabledDefault" - const val META_CAPTURE_APPLICATION_DATA_DEFAULT = - "com.comapeo.core.sentry.captureApplicationDataDefault" + const val META_APPLICATION_USAGE_DATA_DEFAULT = + "com.comapeo.core.sentry.applicationUsageDataDefault" + const val META_DEBUG_DEFAULT = "com.comapeo.core.sentry.debugDefault" const val META_ENABLE_LOGS = "com.comapeo.core.sentry.enableLogs" const val META_MODULE_VERSION = "com.comapeo.core.module.version" const val META_BACKEND_MODULES = "com.comapeo.core.backend.modules" @@ -113,9 +132,10 @@ data class SentryConfig( diagnosticsEnabledDefault = metaString( META_DIAGNOSTICS_ENABLED_DEFAULT, )?.toBooleanStrictOrNull(), - captureApplicationDataDefault = metaString( - META_CAPTURE_APPLICATION_DATA_DEFAULT, + applicationUsageDataDefault = metaString( + META_APPLICATION_USAGE_DATA_DEFAULT, )?.toBooleanStrictOrNull(), + debugDefault = metaString(META_DEBUG_DEFAULT)?.toBooleanStrictOrNull(), enableLogs = metaString(META_ENABLE_LOGS)?.toBooleanStrictOrNull(), moduleVersion = metaString(META_MODULE_VERSION), backendModulesJson = metaString(META_BACKEND_MODULES), diff --git a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt index 30de0944..5a437593 100644 --- a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt +++ b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt @@ -53,9 +53,11 @@ object SentryFgsBridge { private var initialized: Boolean = false /** Idempotent. Caller must pass a non-null `SentryConfig`; skip the call - * entirely when `loadFromManifest` returns null. */ + * entirely when `loadFromManifest` returns null. `userId` is the derived + * Sentry user.id (monthly or permanent hash — never the root ID). */ @JvmStatic - fun init(context: Context, config: SentryConfig) { + @JvmOverloads + fun init(context: Context, config: SentryConfig, userId: String? = null) { if (initialized) return try { // Parse once here rather than in the event processor on every capture. @@ -97,13 +99,30 @@ object SentryFgsBridge { } // SentryOptions has no "set context at init" hook; ride a configureScope after init. - if (backendModules != null) { + if (backendModules != null || userId != null) { Sentry.configureScope { scope -> - scope.setContexts("comapeoBackend", backendModules) + if (backendModules != null) { + scope.setContexts("comapeoBackend", backendModules) + } + if (userId != null) { + scope.user = io.sentry.protocol.User().apply { id = userId } + } } } initialized = true + + // Drain a `debug` 72h auto-off queued by the prefs + // reader, which runs before the SDK is up and so couldn't emit. + if (DebugAutoOff.consume()) { + Sentry.addBreadcrumb( + Breadcrumb().apply { + category = "comapeo.debug.auto_disabled" + message = "debug auto-disabled after 72h" + level = SentryLevel.INFO + }, + ) + } } catch (t: Throwable) { Log.e(TAG, "SentryFgsBridge.init failed; continuing without FGS Sentry", t) } diff --git a/android/src/main/java/com/comapeo/core/SentryTags.kt b/android/src/main/java/com/comapeo/core/SentryTags.kt index bb4df501..c1b2f6ae 100644 --- a/android/src/main/java/com/comapeo/core/SentryTags.kt +++ b/android/src/main/java/com/comapeo/core/SentryTags.kt @@ -3,7 +3,7 @@ package com.comapeo.core /** * Tag keys for Sentry events. Centralised so a typo can't silently route an * event to the wrong dashboard column. Values are documented in - * `docs/sentry-integration-plan.md` and `docs/ARCHITECTURE.md` §7. + * `docs/ARCHITECTURE.md`. * * `proc` reflects the OS process, not a logical layer: iOS is always `main`; * Android is `main` for RN/native in the host UI process and `fgs` for code diff --git a/android/src/main/java/com/comapeo/core/SentryUserId.kt b/android/src/main/java/com/comapeo/core/SentryUserId.kt new file mode 100644 index 00000000..4f26f0f9 --- /dev/null +++ b/android/src/main/java/com/comapeo/core/SentryUserId.kt @@ -0,0 +1,62 @@ +package com.comapeo.core + +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Calendar +import java.util.TimeZone + +/** + * Derives the Sentry `user.id` from the permanent per-install root user ID + * (see [ComapeoPrefs.readRootUserId]). The root ID itself never leaves the + * device; Sentry only ever sees a truncated hash: + * + * - usage opt-in **off** → `sha256("|")` — rotates each + * UTC month so cross-month events can't be linked to one install; + * - usage opt-in **on** → `sha256("|permanent")` — stable across + * launches and months so cohort analysis works. + * + * Both are recoverable from a user-shared root ID, so historical events can + * be re-associated for a support case. Must stay in lock-step with + * `SentryUserId.swift` (shared test vectors in both suites). + */ +internal object SentryUserId { + const val PERMANENT_SALT = "permanent" + private const val ID_LENGTH = 16 + + /** + * Crockford-style base32: no I/L/O/U, so a root ID read off a screen and + * typed back by hand can't be mis-transcribed. Must match `SentryUserId.swift`. + */ + const val ROOT_ID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + /** + * A fresh root user ID: 12 alphabet chars grouped `XXXX-XXXX-XXXX` (60 + * bits — comfortable headroom against collisions across the install base + * while staying short enough to copy from a screen by hand). Stored and + * hashed exactly as formatted, hyphens included. + */ + fun generateRootId(random: SecureRandom = SecureRandom()): String = + CharArray(12) { ROOT_ID_ALPHABET[random.nextInt(ROOT_ID_ALPHABET.length)] } + .concatToString() + .chunked(4) + .joinToString("-") + + fun derive(rootUserId: String, permanent: Boolean, nowMs: Long): String { + val salt = if (permanent) PERMANENT_SALT else utcYearMonth(nowMs) + return sha256Hex("$rootUserId|$salt").substring(0, ID_LENGTH) + } + + /** `YYYY-MM` in UTC. */ + fun utcYearMonth(nowMs: Long): String { + val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + cal.timeInMillis = nowMs + val year = cal.get(Calendar.YEAR) + val month = cal.get(Calendar.MONTH) + 1 + return "%04d-%02d".format(year, month) + } + + private fun sha256Hex(input: String): String = + MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } +} diff --git a/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt b/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt index f7e1ce36..a2add255 100644 --- a/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt +++ b/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt @@ -9,10 +9,10 @@ import org.junit.rules.TemporaryFolder import java.io.File /** - * JVM-only unit tests for [ComapeoPrefs]. Uses the constructor's - * lambda seam so we don't depend on a real `SharedPreferences` - * (unmocked on the JVM unit-test classpath). Same pattern as - * [SentryConfigTest] and [ControlFrameTest]. + * JVM-only unit tests for [ComapeoPrefs]. Backs the [ComapeoPrefs.Store] + * seam with an in-memory map so we don't depend on a real + * `SharedPreferences` (unmocked on the JVM unit-test classpath). Same + * pattern as [SentryConfigTest] and [ControlFrameTest]. * * Coverage rationale: this is the privacy toggle's persistence * layer. A regression that loses or mis-merges the default-fallback @@ -22,46 +22,60 @@ import java.io.File */ class ComapeoPrefsTest { - /** Backing HashMap stand-in for the SharedPreferences file. */ - private class FakeStore { - private val data = mutableMapOf() - val read: (String) -> Boolean? = { key -> data[key] } - val write: (String, Boolean) -> Unit = { key, value -> data[key] = value } - fun has(key: String) = data.containsKey(key) + /** In-memory [ComapeoPrefs.Store] stand-in for the SharedPreferences file. */ + private class FakeStore : ComapeoPrefs.Store { + private val bools = mutableMapOf() + private val longs = mutableMapOf() + private val strings = mutableMapOf() + override fun getBoolean(key: String): Boolean? = bools[key] + override fun putBoolean(key: String, value: Boolean) { bools[key] = value } + override fun getLong(key: String): Long? = longs[key] + override fun putLong(key: String, value: Long) { longs[key] = value } + override fun getString(key: String): String? = strings[key] + override fun putString(key: String, value: String) { strings[key] = value } + override fun remove(key: String) { + bools.remove(key); longs.remove(key); strings.remove(key) + } + fun has(key: String) = + bools.containsKey(key) || longs.containsKey(key) || strings.containsKey(key) } private fun prefs( store: FakeStore, diagnosticsDefault: Boolean = ComapeoPrefs.DEFAULT_DIAGNOSTICS_ENABLED, - captureDefault: Boolean = ComapeoPrefs.DEFAULT_CAPTURE_APPLICATION_DATA, + usageDefault: Boolean = ComapeoPrefs.DEFAULT_APPLICATION_USAGE_DATA, + debugDefault: Boolean = ComapeoPrefs.DEFAULT_DEBUG, + now: () -> Long = { 0L }, ): ComapeoPrefs = ComapeoPrefs( - readBool = store.read, - writeBool = store.write, + store = store, defaults = ComapeoPrefs.Defaults( diagnosticsEnabled = diagnosticsDefault, - captureApplicationData = captureDefault, + applicationUsageData = usageDefault, + debug = debugDefault, ), + now = now, ) @Test fun bakedDefaultWhenKeyAbsent() { // Fresh install, plugin didn't ship a default, user hasn't - // toggled anything — diagnostics on, capture-app-data off. + // toggled anything — diagnostics on, usage off, debug off. val p = prefs(FakeStore()) assertTrue(p.readDiagnosticsEnabled()) - assertFalse(p.readCaptureApplicationData()) + assertFalse(p.readApplicationUsageData()) + assertFalse(p.readDebugEnabled()) } @Test fun pluginDefaultOverridesBakedWhenKeyAbsent() { - // E.g. a dev/qa plugin config with both flags on by default. + // E.g. a dev/qa plugin config with usage on by default. val p = prefs( FakeStore(), diagnosticsDefault = false, - captureDefault = true, + usageDefault = true, ) assertFalse(p.readDiagnosticsEnabled()) - assertTrue(p.readCaptureApplicationData()) + assertTrue(p.readApplicationUsageData()) } @Test @@ -72,12 +86,100 @@ class ComapeoPrefsTest { val p = prefs( store, diagnosticsDefault = true, - captureDefault = false, + usageDefault = false, ) p.writeDiagnosticsEnabled(false) - p.writeCaptureApplicationData(true) + p.writeApplicationUsageData(true) assertFalse(p.readDiagnosticsEnabled()) - assertTrue(p.readCaptureApplicationData()) + assertTrue(p.readApplicationUsageData()) + } + + @Test + fun debugAutoOffBoundaries() { + // fresh enable true; just within window true; just past window false + cleared. + val store = FakeStore() + var clock = 1_000_000L + val p = prefs(store, now = { clock }) + p.writeDebugEnabled(true) + assertTrue("fresh enable reads true", p.readDebugEnabled()) + + clock += ComapeoPrefs.DEBUG_MAX_AGE_MS - 60_000 // one minute before expiry + assertTrue("within window reads true", p.readDebugEnabled()) + + clock += 120_000 // now past the window since enable + assertFalse("past window auto-disables", p.readDebugEnabled()) + assertFalse( + "auto-off clears the value", + store.getBoolean(ComapeoPrefs.KEY_DEBUG)!!, + ) + assertFalse( + "auto-off clears the timestamp", + store.has(ComapeoPrefs.KEY_DEBUG_ENABLED_AT_MS), + ) + // A second read does not mutate further. + assertFalse(p.readDebugEnabled()) + } + + @Test + fun debugExpiresWhenClockMovesBackwardPastEnable() { + // Backward wall-clock change must not extend debug: an enable + // timestamp in the future (age < 0) auto-disables rather than + // keeping debug on indefinitely. + val store = FakeStore() + var clock = 10_000_000L + val p = prefs(store, now = { clock }) + p.writeDebugEnabled(true) + assertTrue(p.readDebugEnabled()) + + clock -= 5_000_000L // clock moved back before the enable stamp + assertFalse("backward clock past enable auto-disables", p.readDebugEnabled()) + assertFalse(store.has(ComapeoPrefs.KEY_DEBUG_ENABLED_AT_MS)) + } + + @Test + fun debugReEnableRefreshesWindow() { + val store = FakeStore() + var clock = 0L + val p = prefs(store, now = { clock }) + p.writeDebugEnabled(true) + clock += ComapeoPrefs.DEBUG_MAX_AGE_MS - 60_000 + // Re-enable just before expiry → fresh full window. + p.writeDebugEnabled(true) + clock += ComapeoPrefs.DEBUG_MAX_AGE_MS - 60_000 + assertTrue("re-enable should reset the window clock", p.readDebugEnabled()) + } + + @Test + fun readDebugStoredIsRawWithNoAutoOff() { + // The live settings read must return the raw saved toggle and never + // mutate: past the window, readDebugEnabled auto-disables + writes, + // but readDebugStored still reads true and leaves disk untouched. + val store = FakeStore() + var clock = 1_000L + val p = prefs(store, now = { clock }) + p.writeDebugEnabled(true) + clock += ComapeoPrefs.DEBUG_MAX_AGE_MS + 60_000 + + assertTrue("raw read returns stored value", p.readDebugStored()) + assertTrue( + "raw read does not clear the stored value", + store.getBoolean(ComapeoPrefs.KEY_DEBUG)!!, + ) + assertTrue( + "raw read runs no auto-off (timestamp intact)", + store.has(ComapeoPrefs.KEY_DEBUG_ENABLED_AT_MS), + ) + } + + @Test + fun debugTrueWithoutTimestampStampsAndStaysOn() { + // Older install: debug=true cell exists, no timestamp. Treat as + // "enabled now" and stamp on first read. + val store = FakeStore() + store.putBoolean(ComapeoPrefs.KEY_DEBUG, true) + val p = prefs(store, now = { 500L }) + assertTrue(p.readDebugEnabled()) + assertEquals(500L, store.getLong(ComapeoPrefs.KEY_DEBUG_ENABLED_AT_MS)) } @Test @@ -105,15 +207,16 @@ class ComapeoPrefsTest { ComapeoPrefs.KEY_DIAGNOSTICS_ENABLED, ) assertEquals( - "sentry.captureApplicationData", - ComapeoPrefs.KEY_CAPTURE_APPLICATION_DATA, + "sentry.applicationUsageData", + ComapeoPrefs.KEY_APPLICATION_USAGE_DATA, ) + assertEquals("sentry.debug", ComapeoPrefs.KEY_DEBUG) } @Test fun prefsFileNameIsPinned() { - // Phase 6 plans to share this file. Pin the name so it can't - // be renamed without a deliberate, visible change. + // Other code may come to share this prefs file. Pin the name + // so it can't be renamed without a deliberate, visible change. assertEquals("com.comapeo.core.prefs", ComapeoPrefs.PREFS_NAME) } @@ -141,6 +244,47 @@ class ComapeoPrefsTest { ) } + @Test + fun rootUserIdIsGeneratedOnceAndStable() { + // Identity anchor: regenerating on every read would silently + // rotate the "permanent" user.id and break historical + // re-association from a user-shared root ID. + val store = FakeStore() + val p = prefs(store) + val first = p.readRootUserId() + assertTrue(first.isNotEmpty()) + assertEquals(first, p.readRootUserId()) + assertEquals(first, prefs(store).readRootUserId()) + assertTrue(store.has(ComapeoPrefs.KEY_ROOT_USER_ID)) + } + + @Test + fun deriveSentryUserIdRotatesMonthlyWithoutOptIn() { + val store = FakeStore() + var nowMs = 0L // 1970-01 + val p = prefs(store, now = { nowMs }) + val january = p.deriveSentryUserId(applicationUsageData = false) + nowMs = 32L * 24 * 60 * 60 * 1000 // 1970-02 + val february = p.deriveSentryUserId(applicationUsageData = false) + assertTrue(january != february) + // Same month → same id. + assertEquals(february, p.deriveSentryUserId(applicationUsageData = false)) + } + + @Test + fun deriveSentryUserIdIsPermanentWithOptIn() { + val store = FakeStore() + var nowMs = 0L + val p = prefs(store, now = { nowMs }) + val a = p.deriveSentryUserId(applicationUsageData = true) + nowMs = 400L * 24 * 60 * 60 * 1000 // > a year later + val b = p.deriveSentryUserId(applicationUsageData = true) + assertEquals(a, b) + // Neither derivation ever exposes the root ID itself. + assertTrue(a != p.readRootUserId()) + assertTrue(p.deriveSentryUserId(applicationUsageData = false) != p.readRootUserId()) + } + @Test fun wipeSentryOutboxIsNoOpWhenAbsent() { // First-run / already-wiped path: a missing directory is diff --git a/android/src/test/java/com/comapeo/core/DeviceTagsTest.kt b/android/src/test/java/com/comapeo/core/DeviceTagsTest.kt new file mode 100644 index 00000000..fa6b146a --- /dev/null +++ b/android/src/test/java/com/comapeo/core/DeviceTagsTest.kt @@ -0,0 +1,52 @@ +package com.comapeo.core + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Classification boundary cases. Pure JVM tests — [DeviceTags.classify] + * takes raw RAM bytes + core count so no `ActivityManager` mock is needed. + * + * Boundaries that matter: exactly 3 GB RAM, exactly 4 cores (the floor of + * `mid`); exactly 6 GB / 6 cores (the floor of `high`). A regression that + * flips an inclusive boundary to exclusive would silently re-bucket a whole + * device class. + */ +class DeviceTagsTest { + private val gb = 1024L * 1024 * 1024 + + @Test + fun exactly3GbAnd4CoresIsMid() { + assertEquals(DeviceTags.CLASS_MID, DeviceTags.classify(3 * gb, 4)) + } + + @Test + fun justUnder3GbIsLow() { + assertEquals(DeviceTags.CLASS_LOW, DeviceTags.classify(3 * gb - 1, 4)) + } + + @Test + fun threeCoresIsLowEvenWithAmpleRam() { + // Cores < 4 forces low regardless of RAM (slow axis dominates). + assertEquals(DeviceTags.CLASS_LOW, DeviceTags.classify(8 * gb, 3)) + } + + @Test + fun exactly6GbAnd6CoresIsHigh() { + assertEquals(DeviceTags.CLASS_HIGH, DeviceTags.classify(6 * gb, 6)) + } + + @Test + fun sixGbButOnlyFiveCoresIsMid() { + // High on RAM, mid on cores → mid (lower axis wins). + assertEquals(DeviceTags.CLASS_MID, DeviceTags.classify(6 * gb, 5)) + } + + @Test + fun osMajorTakesLeadingComponent() { + assertEquals("android.14", DeviceTags.osMajor("14")) + assertEquals("android.13", DeviceTags.osMajor("13.0.1")) + assertEquals("android.0", DeviceTags.osMajor(null)) + assertEquals("android.0", DeviceTags.osMajor("")) + } +} diff --git a/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt b/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt index fbdb7ad0..8085e017 100644 --- a/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt +++ b/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt @@ -24,11 +24,11 @@ class ExitReasonsCollectorTest { /** Snapshots [anchors] at call time — seed anchors first, like production * snapshots before stamping the current run's values. */ - private fun collector(procKey: String = MAIN, captureApplicationData: Boolean = true) = + private fun collector(procKey: String = MAIN, applicationUsageData: Boolean = true) = ExitReasonsCollector( anchors = anchors, snapshot = AnchorSnapshot.from(anchors, procKey), - captureApplicationData = captureApplicationData, + applicationUsageData = applicationUsageData, nowMs = { now }, ) @@ -51,8 +51,8 @@ class ExitReasonsCollectorTest { procKey: String = MAIN, processName: String = MAIN_PROC_NAME, records: List, - captureApplicationData: Boolean = true, - ) = collector(procKey, captureApplicationData).collect(processName, procKey, records).metrics + applicationUsageData: Boolean = true, + ) = collector(procKey, applicationUsageData).collect(processName, procKey, records).metrics // ── First run / high-water behaviour ─────────────────────────── @@ -288,7 +288,7 @@ class ExitReasonsCollectorTest { // // Coarse buckets are aggregate-resolution data and flow at the // diagnostic tier; exact millisecond durations are usage-shape data - // and only flow when capture-application-data is on. + // and only flow when application-usage-data is on. @Test fun diagnosticTierKeepsBucketsButOmitsExactDurations() { @@ -300,7 +300,7 @@ class ExitReasonsCollectorTest { procKey = FGS, processName = FGS_PROC_NAME, records = listOf(record(processName = FGS_PROC_NAME, timestampMs = exitAt)), - captureApplicationData = false, + applicationUsageData = false, ).single().attributes assertEquals("1-5m", attrs[SentryTags.UPTIME_BUCKET]) assertEquals("5-15m", attrs[SentryTags.BG_DURATION_BUCKET]) @@ -316,7 +316,7 @@ class ExitReasonsCollectorTest { anchors.writeProcessStartedAtMs(MAIN, exitAt - 120_000) val attrs = collectMetrics( records = listOf(record(timestampMs = exitAt)), - captureApplicationData = true, + applicationUsageData = true, ).single().attributes assertEquals(120_000L, attrs["alive_for_ms"]) } diff --git a/android/src/test/java/com/comapeo/core/SentryConfigTest.kt b/android/src/test/java/com/comapeo/core/SentryConfigTest.kt index b99c145a..2311e9be 100644 --- a/android/src/test/java/com/comapeo/core/SentryConfigTest.kt +++ b/android/src/test/java/com/comapeo/core/SentryConfigTest.kt @@ -51,7 +51,8 @@ class SentryConfigTest { assertNull(config.tracesSampleRate) assertNull(config.rpcArgsBytes) assertNull(config.diagnosticsEnabledDefault) - assertNull(config.captureApplicationDataDefault) + assertNull(config.applicationUsageDataDefault) + assertNull(config.debugDefault) assertNull(config.enableLogs) } @@ -59,7 +60,7 @@ class SentryConfigTest { fun pluginReleaseOverridesDefault() { // When the consumer passes `release` to the plugin, that // value wins over versionName+versionCode. Used to embed git - // SHAs from EAS_BUILD_GIT_COMMIT_HASH (plan §4.1). + // SHAs from EAS_BUILD_GIT_COMMIT_HASH. val config = SentryConfig.load( mapGetter( mapOf( @@ -154,34 +155,49 @@ class SentryConfigTest { } @Test - fun captureApplicationDataDefaultParses() { + fun applicationUsageDataDefaultParses() { val on = SentryConfig.load( mapGetter( mapOf( SentryConfig.META_DSN to "https://x@sentry.io/1", SentryConfig.META_ENVIRONMENT to "qa", - SentryConfig.META_CAPTURE_APPLICATION_DATA_DEFAULT to "true", + SentryConfig.META_APPLICATION_USAGE_DATA_DEFAULT to "true", ), ), DEFAULT_RELEASE, )!! - assertEquals(true, on.captureApplicationDataDefault) + assertEquals(true, on.applicationUsageDataDefault) val off = SentryConfig.load( mapGetter( mapOf( SentryConfig.META_DSN to "https://x@sentry.io/1", SentryConfig.META_ENVIRONMENT to "production", - SentryConfig.META_CAPTURE_APPLICATION_DATA_DEFAULT to "false", + SentryConfig.META_APPLICATION_USAGE_DATA_DEFAULT to "false", ), ), DEFAULT_RELEASE, )!! - assertEquals(false, off.captureApplicationDataDefault) + assertEquals(false, off.applicationUsageDataDefault) } @Test - fun captureApplicationDataDefaultStrictness() { + fun debugDefaultParses() { + val on = SentryConfig.load( + mapGetter( + mapOf( + SentryConfig.META_DSN to "https://x@sentry.io/1", + SentryConfig.META_ENVIRONMENT to "qa", + SentryConfig.META_DEBUG_DEFAULT to "true", + ), + ), + DEFAULT_RELEASE, + )!! + assertEquals(true, on.debugDefault) + } + + @Test + fun applicationUsageDataDefaultStrictness() { // `toBooleanStrictOrNull` rejects values other than "true" / // "false". Defensive: a stray "1"/"yes" from a hand-written // manifest should not silently flip the default. Returns @@ -191,12 +207,12 @@ class SentryConfigTest { mapOf( SentryConfig.META_DSN to "https://x@sentry.io/1", SentryConfig.META_ENVIRONMENT to "qa", - SentryConfig.META_CAPTURE_APPLICATION_DATA_DEFAULT to "yes", + SentryConfig.META_APPLICATION_USAGE_DATA_DEFAULT to "yes", ), ), DEFAULT_RELEASE, )!! - assertNull(config.captureApplicationDataDefault) + assertNull(config.applicationUsageDataDefault) } @Test @@ -241,7 +257,7 @@ class SentryConfigTest { @Test fun missingEnvironmentReturnsNullNotThrow() { - // The plugin refuses to prebuild without environment (§4.1), + // The plugin refuses to prebuild without environment, // but a stale prebuild from before that validation was added // would still ship. The original "throw" behaviour crashed // every cold start with no way to recover. Now we log loud diff --git a/android/src/test/java/com/comapeo/core/SentryUserIdTest.kt b/android/src/test/java/com/comapeo/core/SentryUserIdTest.kt new file mode 100644 index 00000000..5371cf98 --- /dev/null +++ b/android/src/test/java/com/comapeo/core/SentryUserIdTest.kt @@ -0,0 +1,58 @@ +package com.comapeo.core + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pinned vectors for the user.id derivation. The same vectors live in + * `SentryUserIdTests.swift` — both platforms must derive identical IDs + * from identical inputs so support can recompute a user's historical + * monthly IDs from a shared root ID regardless of platform. + * + * Vectors are `sha256("|")` hex, first 16 chars. + */ +class SentryUserIdTest { + + // 2026-07-01T00:00:00Z + private val july2026Ms = 1_782_864_000_000L + + @Test + fun monthlyVector() { + assertEquals( + "e15e7255ae360358", + SentryUserId.derive("test-root", permanent = false, nowMs = july2026Ms), + ) + } + + @Test + fun permanentVector() { + assertEquals( + "cbd8388dc87b1a9c", + SentryUserId.derive("test-root", permanent = true, nowMs = july2026Ms), + ) + } + + @Test + fun generateRootIdFormat() { + // XXXX-XXXX-XXXX from the no-I/L/O/U alphabet — a user must be able + // to hand-copy this from a screen without ambiguous characters. + val format = Regex("^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$") + repeat(20) { + val id = SentryUserId.generateRootId() + assertTrue("bad root id format: $id", format.matches(id)) + } + assertTrue(SentryUserId.generateRootId() != SentryUserId.generateRootId()) + } + + @Test + fun utcYearMonthPadsAndUsesUtc() { + assertEquals("1970-01", SentryUserId.utcYearMonth(0L)) + // 1ms before the month boundary vs 1ms after — the rotation + // boundary is UTC midnight, not device-local. + val feb1970Ms = 31L * 24 * 60 * 60 * 1000 + assertEquals("1970-01", SentryUserId.utcYearMonth(feb1970Ms - 1)) + assertEquals("1970-02", SentryUserId.utcYearMonth(feb1970Ms)) + assertEquals("2026-07", SentryUserId.utcYearMonth(july2026Ms)) + } +} diff --git a/app.plugin.js b/app.plugin.js index 91e26573..a7b614a2 100644 --- a/app.plugin.js +++ b/app.plugin.js @@ -51,8 +51,9 @@ const ANDROID_KEYS = { rpcArgsBytes: "com.comapeo.core.sentry.rpcArgsBytes", diagnosticsEnabledDefault: "com.comapeo.core.sentry.diagnosticsEnabledDefault", - captureApplicationDataDefault: - "com.comapeo.core.sentry.captureApplicationDataDefault", + applicationUsageDataDefault: + "com.comapeo.core.sentry.applicationUsageDataDefault", + debugDefault: "com.comapeo.core.sentry.debugDefault", enableLogs: "com.comapeo.core.sentry.enableLogs", // Identifies the @comapeo/core-react-native module build the FGS // process is running. Set on the FGS-side `sentry-android` scope @@ -81,8 +82,9 @@ const IOS_KEYS = { rpcArgsBytes: "ComapeoCoreSentryRpcArgsBytes", diagnosticsEnabledDefault: "ComapeoCoreSentryDiagnosticsEnabledDefault", - captureApplicationDataDefault: - "ComapeoCoreSentryCaptureApplicationDataDefault", + applicationUsageDataDefault: + "ComapeoCoreSentryApplicationUsageDataDefault", + debugDefault: "ComapeoCoreSentryDebugDefault", enableLogs: "ComapeoCoreSentryEnableLogs", }; @@ -504,7 +506,7 @@ function normalizeSentryProps(sentry) { if (!sentry.environment || typeof sentry.environment !== "string") { throw new Error( "@comapeo/core-react-native plugin: `sentry.environment` is required when sentry is configured. " + - "Sourced per build profile via EAS env vars (see plan §4.1).", + "Sourced per build profile via EAS env vars.", ); } @@ -529,11 +531,14 @@ function normalizeSentryProps(sentry) { ? "true" : "false"; } - if (sentry.captureApplicationDataDefault !== undefined) { - normalized.captureApplicationDataDefault = sentry.captureApplicationDataDefault + if (sentry.applicationUsageDataDefault !== undefined) { + normalized.applicationUsageDataDefault = sentry.applicationUsageDataDefault ? "true" : "false"; } + if (sentry.debugDefault !== undefined) { + normalized.debugDefault = sentry.debugDefault ? "true" : "false"; + } if (sentry.enableLogs !== undefined) { normalized.enableLogs = sentry.enableLogs ? "true" : "false"; } @@ -589,8 +594,13 @@ function withSentryAndroid(config, sentry, moduleIdent) { ); syncAndroidMetaData( application, - ANDROID_KEYS.captureApplicationDataDefault, - sentry.captureApplicationDataDefault, + ANDROID_KEYS.applicationUsageDataDefault, + sentry.applicationUsageDataDefault, + ); + syncAndroidMetaData( + application, + ANDROID_KEYS.debugDefault, + sentry.debugDefault, ); syncAndroidMetaData( application, @@ -658,9 +668,10 @@ function withSentryIos(config, sentry) { ); setOrDelete( plist, - IOS_KEYS.captureApplicationDataDefault, - sentry.captureApplicationDataDefault, + IOS_KEYS.applicationUsageDataDefault, + sentry.applicationUsageDataDefault, ); + setOrDelete(plist, IOS_KEYS.debugDefault, sentry.debugDefault); setOrDelete(plist, IOS_KEYS.enableLogs, sentry.enableLogs); return cfg; }); diff --git a/apps/integration/index.ts b/apps/integration/index.ts index f6b3e1e1..1452fdd4 100644 --- a/apps/integration/index.ts +++ b/apps/integration/index.ts @@ -12,7 +12,7 @@ import { initSentry } from "@comapeo/core-react-native/sentry"; // enableLogs all flow through the Expo plugin in `app.json` (and // `app.plugin.js`); the host doesn't pass them here. `initSentry` // owns `Sentry.init` so the privacy toggles (`diagnosticsEnabled`, -// `captureApplicationData`) can gate the call in one place. +// `applicationUsageData`) can gate the call in one place. // // The `integrations` extension hook receives the SDK defaults and // returns the final list. Default `appStartIntegration` attaches the diff --git a/backend/before-send.js b/backend/before-send.js new file mode 100644 index 00000000..e04ddb2b --- /dev/null +++ b/backend/before-send.js @@ -0,0 +1,247 @@ +// Node-side PII scrubber and forbidden-metric +// filter. Hand-mirrored from `src/sentry-scrub.ts` on the RN +// side — the two run in different module systems (rollup-bundled ESM +// here, the RN bundle there) so a build-time copy isn't practical. Keep +// the two regex lists in lock-step; each file points at the other. +// +// Wired as a `Sentry.addEventProcessor` in `lib/sentry.js`'s `init`, so +// the same scrubbing + drop behaviour runs on Node-side events before +// they leave the FGS. +// +// Mirrored from `src/sentry-scrub.ts`. The broad base64-22 token rule (to +// catch bare rootKeys / public keys / project ids) is intentionally NOT +// enabled here either — it over-matched Sentry's own 32-hex trace_ids, +// PascalCase exception type names, and error_class metric tags, redacting +// data we need. Pending a narrower design agreed with the team; bare tokens +// are unscrubbed until then. Object fields keyed +// lat/lng/latitude/longitude are redacted regardless of value type; lat/lng +// markers redact the trailing number; HTTP breadcrumb URLs reduce to host-only. + +const REDACTED = "[redacted]"; + +/** @type {RegExp[]} */ +const SCRUB_PATTERNS = [ + // Value stops at a field delimiter (whitespace, `,;&`, quote) so co-located + // fields in a compact `rootKey=abc,method=x` string survive. + /\broot[_-]?key\b\s*["']?\s*[:=]\s*[^\s,;&"']+/gi, + // `lon` is the field name @comapeo/schema observations actually use. + // Optional quote so JSON-serialized coordinates (`"lat":-12.3`) match. + /\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?/gi, +]; + +/** Object keys whose value is a raw coordinate — redacted regardless of type. */ +const SENSITIVE_KEY_PATTERN = /^(lat|lng|lon|latitude|longitude)$/i; + +const FORBIDDEN_METRIC_TAG_NAMES = new Set([ + "device.model", + "device.id", + "device.manufacturer", + "os.version", + "screen.resolution", + "screen.density", + "screen.dpi", + "locale", + "timezone", + "project_id", + "peer_id", + "peer_count", + "rootkey", +]); + +/** @type {RegExp[]} */ +const FORBIDDEN_METRIC_VALUE_PATTERNS = [ + /\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?/i, +]; + +/** @param {string} input */ +export function scrubString(input) { + let out = input; + for (const pattern of SCRUB_PATTERNS) { + out = out.replace(pattern, REDACTED); + } + return out; +} + +/** Reduce an HTTP(S) URL to scheme + host. @param {string} url */ +export function scrubUrlToHost(url) { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + // Relative/opaque URL (no scheme/host) — new URL() throws. Drop the query + // + fragment (where tokens ride), then string-scrub what remains. A plain + // non-URL string has neither and passes through the string scrubber. + return scrubString(url.replace(/[?#].*$/s, "")); + } +} + +/** Max nesting depth walked before we stop (backstop against deep/hostile data). */ +const MAX_SCRUB_DEPTH = 20; + +/** @param {unknown} value @returns {unknown} */ +function scrubValue(value) { + return scrubValueInner(value, new WeakSet(), 0); +} + +/** + * @param {unknown} value + * @param {WeakSet} seen + * @param {number} depth + * @returns {unknown} + */ +function scrubValueInner(value, seen, depth) { + if (typeof value === "string") return scrubString(value); + if (value === null || typeof value !== "object") return value; + // scrubEvent runs as an addEventProcessor, BEFORE Sentry normalises cycles, + // so guard against self-referential/over-deep data ourselves. + if (seen.has(value)) return "[Circular]"; + if (depth >= MAX_SCRUB_DEPTH) return "[Truncated]"; + seen.add(value); + let out; + if (Array.isArray(value)) { + out = value.map((v) => scrubValueInner(v, seen, depth + 1)); + } else { + /** @type {Record} */ + const record = {}; + for (const [k, v] of Object.entries(value)) { + record[k] = SENSITIVE_KEY_PATTERN.test(k) + ? REDACTED + : scrubValueInner(v, seen, depth + 1); + } + out = record; + } + seen.delete(value); + return out; +} + +/** + * Walk every text field of a Sentry event and scrub it: + * message, exception values, extra, contexts, request, breadcrumb + * messages + data, span descriptions + attributes. HTTP breadcrumb and + * request URLs reduce to host-only. Mutates and returns the event (event-processor + * contract). Returns the event (never drops here — call-site capture is + * the real fix; this is the net). + * + * @param {any} event + * @returns {any} + */ +export function scrubEvent(event) { + if (typeof event.message === "string") { + event.message = scrubString(event.message); + } + + if (event.exception && Array.isArray(event.exception.values)) { + for (const ex of event.exception.values) { + if (typeof ex.value === "string") ex.value = scrubString(ex.value); + if (typeof ex.type === "string") ex.type = scrubString(ex.type); + } + } + + if (event.extra && typeof event.extra === "object") { + event.extra = scrubValue(event.extra); + } + if (event.contexts && typeof event.contexts === "object") { + event.contexts = scrubValue(event.contexts); + } + + if (event.request && typeof event.request === "object") { + const req = event.request; + if (typeof req.url === "string") req.url = scrubUrlToHost(req.url); + if (req.query_string != null) req.query_string = scrubValue(req.query_string); + if (req.headers && typeof req.headers === "object") { + req.headers = scrubValue(req.headers); + } + if (req.cookies && typeof req.cookies === "object") { + req.cookies = scrubValue(req.cookies); + } + if (req.data != null) req.data = scrubValue(req.data); + } + + if (Array.isArray(event.breadcrumbs)) { + for (const crumb of event.breadcrumbs) { + scrubBreadcrumb(crumb); + } + } + + if (Array.isArray(event.spans)) { + for (const span of event.spans) { + if (typeof span.description === "string") { + span.description = scrubString(span.description); + } + if (span.data && typeof span.data === "object") { + span.data = scrubValue(span.data); + } + } + } + + return event; +} + +/** + * Scrub one breadcrumb in place: HTTP URL → host-only, message + * → string-scrubbed, data → recursively scrubbed. + * + * @param {any} crumb + * @returns {any} + */ +export function scrubBreadcrumb(crumb) { + if ( + (crumb.category === "http" || + crumb.category === "xhr" || + crumb.category === "fetch") && + crumb.data && + typeof crumb.data === "object" + ) { + if (typeof crumb.data.url === "string") { + crumb.data.url = scrubUrlToHost(crumb.data.url); + } + } + if (typeof crumb.message === "string") { + crumb.message = scrubString(crumb.message); + } + if (crumb.data && typeof crumb.data === "object") { + crumb.data = scrubValue(crumb.data); + } + return crumb; +} + +/** + * Scrub a structured log (the `Sentry.logger.*` channel): message + * string-scrubbed, attributes recursively scrubbed. Wired as + * `beforeSendLog` so logs get the same net as events/breadcrumbs. + * Mutates and returns the log. + * + * @template {{ message?: unknown, attributes?: unknown }} T + * @param {T} log + * @returns {T} + */ +export function scrubLog(log) { + if (typeof log.message === "string") { + log.message = scrubString(log.message); + } + if (log.attributes && typeof log.attributes === "object") { + log.attributes = scrubValue(log.attributes); + } + return log; +} + +/** + * `true` when a metric should be dropped: its name or any tag name is on + * the forbidden list, or any tag value matches a forbidden pattern + * (defensive gate). + * + * @param {string} name + * @param {Record} attributes + */ +export function isForbiddenMetric(name, attributes) { + if (FORBIDDEN_METRIC_TAG_NAMES.has(name)) return true; + for (const [tagName, tagValue] of Object.entries(attributes)) { + if (FORBIDDEN_METRIC_TAG_NAMES.has(tagName)) return true; + if (typeof tagValue === "string") { + for (const pattern of FORBIDDEN_METRIC_VALUE_PATTERNS) { + if (pattern.test(tagValue)) return true; + } + } + } + return false; +} diff --git a/backend/index.js b/backend/index.js index 3ed03ce3..de72846a 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,3 +1,4 @@ +import { monitorEventLoopDelay } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; import ensureError from "ensure-error"; import Fastify from "fastify"; @@ -7,6 +8,11 @@ import { createComapeo } from "./lib/create-comapeo.js"; import { createMapServer } from "./lib/create-map-server.js"; import { SimpleRpcServer } from "./lib/simple-rpc.js"; import * as sentry from "./lib/sentry.js"; +import * as metrics from "./lib/metrics.js"; + +// 60s sampler cadence for backend memory + uptime gauges. No-op +// when Sentry is off (the metrics layer never got its SDK). +const MEMORY_SAMPLE_INTERVAL_MS = 60_000; // Shared/Android entry. Android's nodejs-mobile build ships the // undici-backed `fetch`/`Response`/`Request` globals the map server needs; @@ -289,12 +295,79 @@ async function withPhase(phase, fn) { console.log(`Comapeo socket listening on ${comapeoSocketPath}`); controlIpcServer.setReadinessPhase("ready"); + metrics.bootOutcome("started"); + startMemorySampler(); + sampleStorageSize(privateStorageDir); } catch (error) { const phase = getStringProp(error, "phase") || "boot"; + metrics.bootOutcome("error", phase); handleFatal(phase, error); } })(); +/** + * 60s sampler for backend heap + event-loop delay. `unref()` so the timer + * never keeps the process alive past shutdown. No-op when Sentry is off — + * everything it does is emit metrics, so skip the timer entirely rather than + * firing a perpetual no-op wakeup. + * + * Event-loop delay comes from `monitorEventLoopDelay` (a real high-res + * histogram). We report the window **max** — the worst single stall in the + * interval — because that's what surfaces a stall; the mean buried a brief + * 800ms stall in ~6000 samples. Reset each tick so windows are independent. + * `monitorEventLoopDelay` is pure libuv (no native addon / worker / inspector), + * so it works in nodejs-mobile; and because its own sampling timer freezes when + * iOS suspends the app, a background suspension is correctly NOT counted as delay. + */ +function startMemorySampler() { + if (!metrics.isEnabled()) return; + const eld = monitorEventLoopDelay({ resolution: 10 }); + eld.enable(); + const timer = setInterval(() => { + metrics.backendMemorySample(); + metrics.eventLoopDelaySample(eld.max / 1e6); + eld.reset(); + }, MEMORY_SAMPLE_INTERVAL_MS); + timer.unref?.(); +} + +/** + * One-shot bucketed storage-size counter at STARTED. Reads the + * private storage dir recursively; best-effort — a stat error skips the + * sample rather than failing boot. + * + * @param {string | undefined} dir + */ +function sampleStorageSize(dir) { + if (!dir) return; + // The recursive stat-walk below is only worth running if its bucket + // metric will actually be recorded; skip it entirely when Sentry is off. + if (!metrics.isEnabled()) return; + import("node:fs") + .then(async ({ promises: fs }) => { + let total = 0; + /** @param {string} path */ + const walk = async (path) => { + const entries = await fs.readdir(path, { withFileTypes: true }); + for (const entry of entries) { + const full = `${path}/${entry.name}`; + if (entry.isDirectory()) { + await walk(full); + } else { + try { + total += (await fs.stat(full)).size; + } catch { + // Vanished mid-walk — skip. + } + } + } + }; + await walk(dir); + metrics.storageSizeBucket(metrics.storageBucket(total)); + }) + .catch(() => {}); +} + process.on("exit", () => { console.log("node exiting"); }); diff --git a/backend/lib/before-send.test.mjs b/backend/lib/before-send.test.mjs new file mode 100644 index 00000000..f6ae68e5 --- /dev/null +++ b/backend/lib/before-send.test.mjs @@ -0,0 +1,122 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + scrubString, + scrubUrlToHost, + scrubEvent, + scrubBreadcrumb, + scrubLog, + isForbiddenMetric, +} from "../before-send.js"; + +import { + scrubStringCases, + scrubUrlToHostCases, + forbiddenMetricCases, +} from "../../test-support/scrubber-cases.js"; + +/** + * Node-side scrubber + forbidden-metric filter. + * Symmetric with the RN-side `src/sentry-scrub.ts` — the data-driven cases + * come from the shared `test-support/scrubber-cases.js` table, run against + * both copies so the two regex lists can't drift. + */ + +for (const { name, input, expect } of scrubStringCases) { + test(`scrubString: ${name}`, () => { + assert.equal(scrubString(input), expect); + }); +} + +for (const { name, input, expect } of scrubUrlToHostCases) { + test(`scrubUrlToHost: ${name}`, () => { + assert.equal(scrubUrlToHost(input), expect); + }); +} + +for (const { name, metricName, attributes, expect } of forbiddenMetricCases) { + test(`isForbiddenMetric: ${name}`, () => { + assert.equal(isForbiddenMetric(metricName, attributes), expect); + }); +} + +test("scrubEvent redacts numeric lat/lng stored as object fields", () => { + const event = { + extra: { coords: { latitude: 12.3456, longitude: -56.78 } }, + contexts: { geo: { lat: 1.0, lng: 2.0 } }, + }; + scrubEvent(event); + assert.equal(event.extra.coords.latitude, "[redacted]"); + assert.equal(event.extra.coords.longitude, "[redacted]"); + assert.equal(event.contexts.geo.lat, "[redacted]"); + assert.equal(event.contexts.geo.lng, "[redacted]"); +}); + +test("scrubEvent reduces request.url to host-only and scrubs marked query/headers", () => { + const event = { + request: { + url: "https://cloud.comapeo.app/projects/abc?token=x", + query_string: "rootKey=supersecretvalue", + headers: { "x-secret": "root_key: supersecretvalue" }, + }, + }; + scrubEvent(event); + assert.equal(event.request.url, "https://cloud.comapeo.app"); + assert.match(event.request.query_string, /\[redacted\]/); + assert.match(event.request.headers["x-secret"], /\[redacted\]/); +}); + +test("scrubEvent walks message, exception, extra, breadcrumbs, spans", () => { + const event = { + message: "lat=1.0", + exception: { values: [{ type: "Error", value: "rootKey=aGVsbG8td29ybGQtMTIzNA" }] }, + extra: { note: "lng=2.0" }, + contexts: { custom: { coord: "latitude: 9.9" } }, + breadcrumbs: [ + { + category: "http", + data: { url: "https://x.example/path?q=1" }, + message: "lng=3.0", + }, + ], + spans: [{ description: "lat=4.0", data: { extra: "longitude: 5.0" } }], + }; + scrubEvent(event); + assert.match(event.message, /\[redacted\]/); + assert.match(event.exception.values[0].value, /\[redacted\]/); + assert.match(event.extra.note, /\[redacted\]/); + assert.match(event.contexts.custom.coord, /\[redacted\]/); + assert.equal(event.breadcrumbs[0].data.url, "https://x.example"); + assert.match(event.breadcrumbs[0].message, /\[redacted\]/); + assert.match(event.spans[0].description, /\[redacted\]/); + assert.match(event.spans[0].data.extra, /\[redacted\]/); +}); + +test("scrubBreadcrumb reduces http URL to host only", () => { + const crumb = { + category: "http", + data: { url: "https://tiles.example.com/v1/12/34?key=abc" }, + }; + scrubBreadcrumb(crumb); + assert.equal(crumb.data.url, "https://tiles.example.com"); +}); + +test("scrubBreadcrumb does not stack-overflow on circular data", () => { + const data = { a: 1 }; + data.self = data; // cycle + const crumb = { category: "custom", data }; + assert.doesNotThrow(() => scrubBreadcrumb(crumb)); + assert.equal(crumb.data.self, "[Circular]"); +}); + +test("scrubLog scrubs the message and attributes of a structured log", () => { + const log = { + message: "rootKey=supersecretvalue", + attributes: { note: "latitude: 12.3", ok: "fine" }, + }; + scrubLog(log); + assert.match(log.message, /\[redacted\]/); + assert.equal(log.attributes.note, "[redacted]"); + assert.equal(log.attributes.ok, "fine"); +}); diff --git a/backend/lib/metrics.js b/backend/lib/metrics.js new file mode 100644 index 00000000..89dd16d2 --- /dev/null +++ b/backend/lib/metrics.js @@ -0,0 +1,274 @@ +// Backend Sentry metrics layer. +// +// Thin wrappers around `Sentry.metrics.*` that: +// - inject the shared `platform` attribute on every metric so a call +// site can never forget it; +// - attach the low-cardinality `device_class` / `os_major` tags to the +// duration metrics (Sentry Application Metrics bills by volume, not +// cardinality, so one metric with all the attributes you'd slice by +// beats a primary + `.by_device` split — group-by does the slicing at +// query time). `method` and the sync buckets stay usage-gated for +// privacy, not cardinality; +// - no-op entirely when Sentry is off (`init` never ran); +// - run a defensive `before_metric_send` filter that drops any +// emission carrying a forbidden attribute. +// +// Populated by `sentry.js`'s `init()`, which has the live SDK + the +// resolved device tags from argv. No static dep on `@sentry/node-core` +// so the chunk stays unloaded when Sentry is off. + +import { isForbiddenMetric } from "../before-send.js"; + +/** @type {typeof import("@sentry/node-core") | null} */ +let Sentry = null; +/** + * @type {{ + * platform: string, + * deviceClass: string, + * osMajor: string, + * applicationUsageData: boolean, + * } | null} + */ +let config = null; + +/** + * @param {{ + * Sentry: typeof import("@sentry/node-core"), + * platform: string, + * deviceClass: string, + * osMajor: string, + * applicationUsageData: boolean, + * }} args + */ +export function init(args) { + Sentry = args.Sentry; + config = { + platform: args.platform, + deviceClass: args.deviceClass, + osMajor: args.osMajor, + applicationUsageData: args.applicationUsageData, + }; +} + +/** Test seam — reset the singletons so a no-op assertion is clean. */ +export function resetForTests() { + Sentry = null; + config = null; +} + +/** Whether `init` ran — lets callers skip work whose only output is a + * metric that would otherwise be silently dropped when Sentry is off. */ +export function isEnabled() { + return Sentry !== null; +} + +/** The only tag cheap enough to ride on every metric. */ +function defaultTags() { + return { platform: config?.platform ?? "unknown" }; +} + +function deviceTags() { + return { + device_class: config?.deviceClass ?? "unknown", + os_major: config?.osMajor ?? "unknown", + }; +} + +/** @returns {any} */ +function api() { + if (!Sentry) return null; + return /** @type {any} */ (Sentry).metrics ?? null; +} + +/** + * @param {string} name + * @param {number} value + * @param {string} unit + * @param {Record} attributes + */ +function distribution(name, value, unit, attributes) { + const metrics = api(); + if (!metrics) return; + const attrs = { ...defaultTags(), ...attributes }; + if (isForbiddenMetric(name, attrs)) return; + metrics.distribution?.(name, value, { unit, attributes: attrs }); +} + +/** + * @param {string} name + * @param {Record} attributes + */ +function count(name, attributes) { + const metrics = api(); + if (!metrics) return; + const attrs = { ...defaultTags(), ...attributes }; + if (isForbiddenMetric(name, attrs)) return; + metrics.count?.(name, 1, { attributes: attrs }); +} + +/** + * @param {string} name + * @param {number} value + * @param {string} unit + * @param {Record} attributes + */ +function gauge(name, value, unit, attributes) { + const metrics = api(); + if (!metrics) return; + const attrs = { ...defaultTags(), ...attributes }; + if (isForbiddenMetric(name, attrs)) return; + metrics.gauge?.(name, value, { unit, attributes: attrs }); +} + +// ── RPC ───────────────────────────────────────────────────────── + +/** + * Server-side handler latency. One distribution carrying `status` + device + * tags, plus `method` when the usage tier is on. Sentry Application Metrics + * bills by volume, not cardinality, so a single high-cardinality series is + * cheaper and more queryable than a primary + `.by_device` split — slice by + * `method` or `device_class` (or both) with group-by at query time. `method` + * stays usage-gated for privacy, not cardinality. + * + * @param {string} method + * @param {string} status + * @param {number} ms + */ +export function rpcServer(method, status, ms) { + const attrs = { status, ...deviceTags() }; + if (config?.applicationUsageData) attrs.method = method; + distribution("comapeo.rpc.server.duration_ms", ms, "millisecond", attrs); +} + +// ── Boot / shutdown ───────────────────────────────────────────── + +/** + * @param {string} phase + * @param {number} ms + */ +export function bootPhase(phase, ms) { + distribution("comapeo.boot.phase_duration_ms", ms, "millisecond", { + phase, + ...deviceTags(), + }); +} + +/** + * @param {"started" | "error"} outcome + * @param {string} [errorPhase] + */ +export function bootOutcome(outcome, errorPhase) { + /** @type {Record} */ + const attrs = { outcome }; + if (errorPhase) attrs.error_phase = errorPhase; + count("comapeo.boot.outcome", attrs); +} + +/** + * @param {string} phase + * @param {number} ms + */ +export function shutdownPhase(phase, ms) { + distribution("comapeo.shutdown.phase_duration_ms", ms, "millisecond", { + phase, + }); +} + +// ── Sync session ──────────────────────────────────────────────── + +/** + * One duration distribution (`outcome` + device tags), plus the usage-gated + * peers/bytes bucket counters. + * + * @param {string} outcome + * @param {number} ms + * @param {string} peersBucket + * @param {string} bytesBucket + */ +export function syncSession(outcome, ms, peersBucket, bytesBucket) { + distribution("comapeo.sync.session.duration_ms", ms, "millisecond", { + outcome, + ...deviceTags(), + }); + // collaboration-scale + data-volume buckets are usage-gated (privacy). + if (config?.applicationUsageData) { + count("comapeo.sync.session.peers_bucket", { bucket: peersBucket }); + count("comapeo.sync.bytes_bucket", { bucket: bytesBucket }); + } +} + +// ── Backend health (60s sampler) ──────────────────────────────── + +/** + * Heap-used gauge. `rss` is intentionally omitted: node's `rss` is the whole + * OS process, which on iOS is the entire app (node runs in-process), so it + * wouldn't measure "the backend". `heapUsed` is the V8 JS heap and is + * meaningful on both platforms. + */ +export function backendMemorySample() { + const metrics = api(); + if (!metrics) return; + const mem = process.memoryUsage(); + gauge("comapeo.backend.heap_used_bytes", mem.heapUsed, "byte", {}); +} + +/** + * Per-window worst event-loop stall, in ms. A distribution (not a gauge) so + * Sentry can compute fleet-level percentiles of the per-minute worst stall, + * sliced by device class — "the loop stalls on device X". Carries device tags. + * + * @param {number} maxMs Worst event-loop delay in the sampling window (ms). + */ +export function eventLoopDelaySample(maxMs) { + distribution( + "comapeo.backend.event_loop_delay_ms", + maxMs, + "millisecond", + deviceTags(), + ); +} + +// ── Storage / IPC ─────────────────────────────────────────────── + +/** @param {string} bucket `<10MB` / `10-100MB` / `100MB-1GB` / `>1GB` */ +export function storageSizeBucket(bucket) { + count("comapeo.storage.size_bucket", { bucket }); +} + +/** @param {string} [errorClass] */ +export function ipcError(errorClass) { + count("comapeo.ipc.errors", { error_class: errorClass ?? "Error" }); +} + +/** Telemetry-forwarding failure (envelope sink threw / dropped). */ +export function telemetryForwardingFailure() { + count("comapeo.telemetry.forwarding_failures", {}); +} + +// ── Bucketing helpers (shared so RN + Node bucket identically) ─── + +/** @param {number} peers @returns {string} */ +export function peersBucket(peers) { + if (peers <= 3) return "1-3"; + if (peers <= 10) return "4-10"; + return "10+"; +} + +/** @param {number} bytes @returns {string} */ +export function bytesBucket(bytes) { + if (bytes < 1_000_000) return "<1M"; + if (bytes < 10_000_000) return "1-10M"; + if (bytes < 100_000_000) return "10-100M"; + return "100M+"; +} + +/** @param {number} bytes @returns {string} */ +export function storageBucket(bytes) { + if (bytes < 10_000_000) return "<10MB"; + if (bytes < 100_000_000) return "10-100MB"; + if (bytes < 1_000_000_000) return "100MB-1GB"; + return ">1GB"; +} + +/** Test-only seam: drive a forbidden NAME through the wrappers. */ +export const __testInternals = { count, distribution, gauge }; diff --git a/backend/lib/metrics.test.mjs b/backend/lib/metrics.test.mjs new file mode 100644 index 00000000..17981612 --- /dev/null +++ b/backend/lib/metrics.test.mjs @@ -0,0 +1,162 @@ +import { test, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +import * as metrics from "./metrics.js"; + +/** + * Unit tests for the backend metrics layer. A fake + * `Sentry.metrics` records every call so we can assert on metric names, + * units, shared-attribute injection, the cardinality split, no-op when + * off, and the forbidden-tag filter — without standing up the real SDK. + */ + +function fakeSentry() { + const calls = { distribution: [], count: [], gauge: [] }; + return { + calls, + sdk: { + metrics: { + distribution: (name, value, data) => + calls.distribution.push({ name, value, ...data }), + count: (name, value, data) => + calls.count.push({ name, value, ...data }), + gauge: (name, value, data) => + calls.gauge.push({ name, value, ...data }), + }, + }, + }; +} + +function initWith(sdk, overrides = {}) { + metrics.init({ + Sentry: sdk, + platform: "android", + deviceClass: "mid", + osMajor: "android.14", + applicationUsageData: true, + ...overrides, + }); +} + +beforeEach(() => metrics.resetForTests()); + +test("no-ops entirely when Sentry is off (init never ran)", () => { + // No init → no SDK. Calls must not throw and record nothing. + metrics.rpcServer("read.doc", "ok", 12); + metrics.backendMemorySample(); + metrics.storageSizeBucket("<10MB"); + // Nothing to assert beyond "did not throw"; the absence of an SDK is + // the whole point. + assert.ok(true); +}); + +test("rpcServer emits one metric with status + device tags; method is usage-gated", () => { + // Usage on: `method` rides on the single metric. + const on = fakeSentry(); + initWith(on.sdk, { applicationUsageData: true }); + metrics.rpcServer("observation.create", "ok", 42); + + assert.equal(on.calls.distribution.length, 1); + const [m] = on.calls.distribution; + assert.equal(m.name, "comapeo.rpc.server.duration_ms"); + assert.equal(m.unit, "millisecond"); + assert.equal(m.attributes.status, "ok"); + assert.equal(m.attributes.platform, "android"); + assert.equal(m.attributes.device_class, "mid"); + assert.equal(m.attributes.os_major, "android.14"); + assert.equal(m.attributes.method, "observation.create"); + + // Usage off: same single metric, but `method` is dropped (privacy gate). + metrics.resetForTests(); + const off = fakeSentry(); + initWith(off.sdk, { applicationUsageData: false }); + metrics.rpcServer("observation.create", "ok", 42); + + assert.equal(off.calls.distribution.length, 1); + assert.equal(off.calls.distribution[0].name, "comapeo.rpc.server.duration_ms"); + assert.equal(off.calls.distribution[0].attributes.method, undefined); + assert.equal(off.calls.distribution[0].attributes.device_class, "mid"); +}); + +test("syncSession emits one duration metric; peers/bytes buckets are usage-gated", () => { + const off = fakeSentry(); + initWith(off.sdk, { applicationUsageData: false }); + metrics.syncSession("complete", 1200, "1-3", "1-10M"); + // One duration distribution (with device tags); no buckets when usage off. + assert.equal(off.calls.distribution.length, 1); + assert.equal( + off.calls.distribution[0].name, + "comapeo.sync.session.duration_ms", + ); + assert.equal(off.calls.distribution[0].attributes.device_class, "mid"); + assert.equal(off.calls.count.length, 0); + + metrics.resetForTests(); + const on = fakeSentry(); + initWith(on.sdk, { applicationUsageData: true }); + metrics.syncSession("complete", 1200, "1-3", "1-10M"); + assert.equal(on.calls.distribution.length, 1); + assert.deepEqual( + on.calls.count.map((c) => c.name), + ["comapeo.sync.session.peers_bucket", "comapeo.sync.bytes_bucket"], + ); +}); + +test("backendMemorySample emits the heap-used gauge in bytes", () => { + const { sdk, calls } = fakeSentry(); + initWith(sdk); + metrics.backendMemorySample(); + const names = calls.gauge.map((g) => g.name); + // rss is intentionally omitted (it measures the whole process, misleading + // on iOS where node runs in-process); uptime was dropped (a sampled + // monotonic gauge has no actionable aggregate). + assert.deepEqual(names, ["comapeo.backend.heap_used_bytes"]); + assert.equal(calls.gauge[0].unit, "byte"); +}); + +test("before_metric_send filter drops a forbidden tag name routed through count()", () => { + const { sdk, calls } = fakeSentry(); + initWith(sdk); + // No public call site accepts a forbidden tag name, so drive the + // wrapper directly: this fails if the tagName branch of + // isForbiddenMetric (before-send.js) is removed. + metrics.__testInternals.count("comapeo.x", { project_id: "p" }); + assert.equal( + calls.count.length, + 0, + "metric carrying a forbidden tag NAME must be dropped", + ); + // A forbidden metric NAME is dropped too. + metrics.__testInternals.count("project_id", { method: "read.doc" }); + assert.equal(calls.count.length, 0, "forbidden metric NAME must be dropped"); + // A clean metric still records. + metrics.__testInternals.count("comapeo.x", { method: "read.doc" }); + assert.equal(calls.count.length, 1, "a clean metric still records"); +}); + +test("before_metric_send drops forbidden tag VALUES (lat/lng shape)", () => { + const { sdk, calls } = fakeSentry(); + initWith(sdk); + // A lat/lng-shaped value must be dropped by the forbidden-value filter + // even though `bucket` is an allowed tag name. + metrics.storageSizeBucket("lat=12.34"); + assert.equal( + calls.count.length, + 0, + "metric carrying a lat/lng tag value must be dropped", + ); + // A normal bucket value passes. (The broad base64-22 value rule is disabled + // pending a narrower design — a bare token would also pass now.) + metrics.storageSizeBucket("<10MB"); + assert.equal(calls.count.length, 1); +}); + +test("bucketing helpers match the spec thresholds", () => { + assert.equal(metrics.peersBucket(1), "1-3"); + assert.equal(metrics.peersBucket(4), "4-10"); + assert.equal(metrics.peersBucket(50), "10+"); + assert.equal(metrics.bytesBucket(500_000), "<1M"); + assert.equal(metrics.bytesBucket(5_000_000), "1-10M"); + assert.equal(metrics.storageBucket(5_000_000), "<10MB"); + assert.equal(metrics.storageBucket(2_000_000_000), ">1GB"); +}); diff --git a/backend/lib/sentry.js b/backend/lib/sentry.js index a67c0858..05e26490 100644 --- a/backend/lib/sentry.js +++ b/backend/lib/sentry.js @@ -6,6 +6,9 @@ /** @typedef {import("./sentry-frame.js").SentryFrame} SentryFrame */ +import { scrubEvent, scrubLog } from "../before-send.js"; +import * as metrics from "./metrics.js"; + /** * parseArgs spec for the Sentry-related CLI flags. loader.mjs uses * this as the `options` for its single `parseArgs` call and forwards @@ -21,9 +24,14 @@ export const argSpec = { sentryTracesSampleRate: { type: "string" }, sentryRpcArgsBytes: { type: "string", default: "0" }, sentryEnableLogs: { type: "boolean", default: false }, + sentryUserId: { type: "string" }, sentryTrace: { type: "string" }, sentryBaggage: { type: "string", default: "" }, - captureApplicationData: { type: "boolean", default: false }, + applicationUsageData: { type: "boolean", default: false }, + debug: { type: "boolean", default: false }, + deviceClass: { type: "string", default: "unknown" }, + osMajor: { type: "string" }, + platformTag: { type: "string" }, }; /** @@ -35,16 +43,26 @@ export const argSpec = { * sentryTracesSampleRate?: string, * sentryRpcArgsBytes: string, * sentryEnableLogs: boolean, + * sentryUserId?: string, * sentryTrace?: string, * sentryBaggage: string, - * captureApplicationData: boolean, + * applicationUsageData: boolean, + * debug: boolean, + * deviceClass: string, + * osMajor?: string, + * platformTag?: string, * }} Argv */ /** @type {typeof import("@sentry/node-core") | null} */ let Sentry = null; -/** @type {{ rpcArgsBytes: number, captureApplicationData: boolean } | null} */ +/** @type {{ rpcArgsBytes: number, applicationUsageData: boolean, debug: boolean, deviceClass: string, osMajor: string, platformTag: string } | null} */ let config = null; + +/** Read-only view of the resolved config for the metrics layer. */ +export function getConfig() { + return config; +} /** @type {((envelope: any) => SentryFrame) | null} */ let envelopeToFrame = null; /** @type {((frame: SentryFrame) => void) | null} */ @@ -78,9 +96,6 @@ const forwardingTransport = () => ({ flush: async () => true, }); -// Keep in sync with `src/sentry.ts`'s DEFAULT_TRACES_SAMPLE_RATE. -const DEFAULT_TRACES_SAMPLE_RATE = 0.1; - /** * Coerce a numeric CLI arg, throwing if native passed a non-finite * value. Loud-fail symmetric with `strict: true`. @@ -109,7 +124,11 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { Sentry = sdk; config = { rpcArgsBytes: numericArg(argv.sentryRpcArgsBytes), - captureApplicationData: argv.captureApplicationData, + applicationUsageData: argv.applicationUsageData === true, + debug: argv.debug === true, + deviceClass: argv.deviceClass ?? "unknown", + osMajor: argv.osMajor ?? `${argv.platformTag ?? "unknown"}.0`, + platformTag: argv.platformTag ?? "unknown", }; envelopeToFrame = toFrame; @@ -118,12 +137,18 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { environment: argv.sentryEnvironment, release: argv.sentryRelease, sampleRate: numericArg(argv.sentrySampleRate), - tracesSampleRate: argv.captureApplicationData - ? numericArg(argv.sentryTracesSampleRate ?? DEFAULT_TRACES_SAMPLE_RATE) + // Native (Kotlin/Swift) owns the trace-sampling decision and forwards the + // already-resolved rate — it folds in the `debug` window (full sampling + // while on) and any plugin-configured cap. Mirror it verbatim here; absent + // means 0. Day-to-day perf signal rides the always-on metrics layer. + tracesSampleRate: argv.sentryTracesSampleRate + ? numericArg(argv.sentryTracesSampleRate) : 0, // v9 moved this out of `_experiments` — keep the CLI flag name so // native doesn't have to change. enableLogs: argv.sentryEnableLogs, + // Structured logs bypass the scrubEvent processor, so scrub them here. + beforeSendLog: scrubLog, // We register no OTel auto-instrumentations, so the iitm loader // thread that `@sentry/node-core`'s `initializeEsmLoader` would // spin up has nothing to hook. Disabling it lets us drop iitm @@ -133,9 +158,15 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { transport: forwardingTransport, // Function form preserves SDK defaults (inboundFilters, linkedErrors, // nodeContext, etc.) — the array form would replace them. - integrations: (defaults) => [...defaults, Sentry.consoleIntegration()], + // `consoleIntegration` is debug-only: console-log capture is + // privacy-expensive and only useful during an investigation window. + integrations: (defaults) => + config?.debug ? [...defaults, Sentry.consoleIntegration()] : defaults, initialScope: { tags: { proc: "fgs", layer: "node" }, + // Native-derived user.id (monthly/permanent hash) — same value the + // FGS and RN layers set, so one launch reports one user. + ...(argv.sentryUserId ? { user: { id: argv.sentryUserId } } : {}), }, }); @@ -148,6 +179,22 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { delete event.contexts.culture; return event; }); + + // PII scrubber — symmetric with the RN side's `beforeSend`. + // Registered as an event processor so it runs on the way out, after + // all scope merges. Drops or redacts before envelopes leave Node. + Sentry.addEventProcessor(scrubEvent); + + // Wire the metrics layer with the live SDK + resolved device tags so + // every emission carries `platform` and the duration metrics carry + // `device_class` / `os_major`. + metrics.init({ + Sentry, + platform: config.platformTag, + deviceClass: config.deviceClass, + osMajor: config.osMajor, + applicationUsageData: config.applicationUsageData, + }); } /** @@ -218,7 +265,19 @@ export async function withBootTrace(args, loadIndex) { * @returns {Promise} */ export async function withSpan(op, fn) { - if (!Sentry) return fn(); + // Always record the boot-phase duration as a metric, even + // when traces are off (`debug=false`). The span only materialises + // under `debug` via `tracesSampleRate`, but the metric is always-on. + const phase = op.startsWith("boot.") ? op.slice("boot.".length) : op; + const start = performance.now(); + const recordPhase = () => metrics.bootPhase(phase, performance.now() - start); + if (!Sentry) { + try { + return await fn(); + } finally { + recordPhase(); + } + } // op:boot.* is the Discover filter; name=op so span.name renders. return Sentry.startSpan({ name: op, op }, async (span) => { try { @@ -228,6 +287,8 @@ export async function withSpan(op, fn) { } catch (e) { span.setStatus({ code: 2, message: "internal_error" }); throw e; + } finally { + recordPhase(); } }); } @@ -280,9 +341,12 @@ export function setSink(realSink) { } /** - * `onRequestHook` for ComapeoRpcServer. Returns `undefined` when off - * so the RPC server skips middleware entirely. RPC args capture is - * opt-in (PII) via `--sentryRpcArgsBytes`. Errors are not rethrown: + * `onRequestHook` for ComapeoRpcServer. Returns `undefined` when Sentry + * is off (RPC server skips middleware). When on, ALWAYS records the + * `comapeo.rpc.server.*` metric; only creates a Sentry span + * when `--debug` is set. The metric is recorded while the span + * is active so it links to the trace. RPC args capture is opt-in (PII) + * via `--sentryRpcArgsBytes` AND `--debug`. Errors are not rethrown: * rpc-reflector already responds, and rethrowing would route routine * RPC errors into `handleFatal`. * @@ -292,10 +356,36 @@ export function rpcHook() { if (!Sentry) return undefined; const sentryRef = Sentry; const rpcArgsBytes = config?.rpcArgsBytes ?? 0; + const debug = config?.debug ?? false; return (request, next) => { + const method = request.method.join("."); + + // Metrics/tracing only — the hook never captures exceptions. An RPC + // rejection is often expected control flow (e.g. NotFound) that should + // not auto-create a Sentry issue; deciding what's report-worthy is the + // caller's job, at the call site. Records duration + status regardless of + // `debug`; the span block below adds per-RPC tracing under `debug`. The + // trailing `.catch` keeps a throw from a metrics call out of the + // unhandledRejection → handleFatal path. + if (!debug) { + const start = performance.now(); + Promise.resolve(next(request)) + .then( + () => metrics.rpcServer(method, "ok", performance.now() - start), + (error) => { + metrics.rpcServer( + method, + statusFor(error), + performance.now() - start, + ); + }, + ) + .catch(() => {}); + return; + } + const sentryTrace = request.metadata?.["sentry-trace"]; const baggage = request.metadata?.baggage; - const method = request.method.join("."); // `rpc.system` / `rpc.method` follow OpenTelemetry's RPC semantic // conventions (https://opentelemetry.io/docs/specs/semconv/rpc/), // which Sentry defers to for RPC ops. Pair with `op: "rpc.server"` @@ -325,17 +415,34 @@ export function rpcHook() { attributes, }, async (span) => { + const start = performance.now(); try { await next(request); span.setStatus({ code: 1, message: "ok" }); + metrics.rpcServer(method, "ok", performance.now() - start); } catch (error) { + // Mark the span errored for tracing, but do not capture an issue + // — see the metrics-only note on the non-debug path above. span.setStatus({ code: 2, message: "internal_error" }); - sentryRef.captureException(error, { - tags: { layer: "node", op: "rpc.server" }, - }); + metrics.rpcServer( + method, + statusFor(error), + performance.now() - start, + ); } }, ); }); }; } + +/** + * Bounded `status` tag for a thrown RPC error. + * @param {any} error + */ +function statusFor(error) { + const name = error && error.name; + if (typeof name === "string" && /timeout/i.test(name)) return "timeout"; + return "error"; +} + diff --git a/backend/lib/sentry.test.mjs b/backend/lib/sentry.test.mjs index 9e894877..c6f9a0ee 100644 --- a/backend/lib/sentry.test.mjs +++ b/backend/lib/sentry.test.mjs @@ -5,43 +5,57 @@ import * as Sentry from "@sentry/node-core"; import { initSentry } from "./sentry-init.js"; import { rpcHook, setSink, flush } from "./sentry.js"; +import * as metrics from "./metrics.js"; /** - * End-to-end "is the Node Sentry layer alive" check. Drives the real - * `@sentry/node-core` SDK + OpenTelemetry SDK through `sentry.js`'s - * `forwardingTransport` and asserts that an envelope frame reaches - * the sink. If init silently skips, if the OTel SDK isn't wired so - * `startSpan` produces nothing, if the transport doesn't get connected, - * if rpcHook returns undefined when it shouldn't, or if the sink stops - * draining — this test fails. + * debug-on / debug-off branching of `rpcHook`: + * - debug OFF ⇒ no span (no envelope reaches the sink), but the metric + * IS recorded. + * - debug ON ⇒ span created (envelope reaches the sink) AND metric + * recorded while the span is active. * - * Deliberately asserts on **presence**, not on op-name strings or - * attribute shapes. Renaming `rpc.server` or adding new attributes - * is a legitimate refactor that should not break this test; the - * regression class to catch is "no envelope at all". + * `sentry.js`'s `init` wires the metrics layer with the real SDK; we + * immediately re-`init` the metrics layer with a fake recorder SDK so a + * metric emission records into an array instead of producing its own + * envelope. That keeps the sink-frame count attributable to spans only. + * + * Presence-not-shape on the span side: assert "an envelope reached the + * sink", never on op-name strings. */ -test("rpcHook produces an envelope frame end-to-end via real @sentry/node-core", async () => { - initSentry({ - sentryDsn: "https://x@sentry.io/1", - sentryEnvironment: "test", - sentryRelease: "0.0.0+test", - sentrySampleRate: "1.0", - sentryTracesSampleRate: "1.0", - sentryRpcArgsBytes: "0", - sentryEnableLogs: false, - sentryBaggage: "", - captureApplicationData: true, - }); - const captured = []; - setSink((frame) => captured.push(frame)); +const baseArgv = { + sentryDsn: "https://x@sentry.io/1", + sentryEnvironment: "test", + sentryRelease: "0.0.0+test", + sentrySampleRate: "1.0", + sentryTracesSampleRate: "1.0", + sentryRpcArgsBytes: "0", + sentryEnableLogs: false, + sentryBaggage: "", + applicationUsageData: true, + deviceClass: "mid", + osMajor: "android.14", + platformTag: "android", +}; - const hook = rpcHook(); - assert.ok(hook, "rpcHook returned undefined — Sentry didn't initialise"); +/** Fake metrics SDK that records distribution calls instead of emitting. */ +function recordingMetricsSdk() { + const distributions = []; + return { + distributions, + sdk: { + metrics: { + distribution: (name, value, data) => + distributions.push({ name, value, ...data }), + count: () => {}, + gauge: () => {}, + }, + }, + }; +} - // Wait for next() to be called from inside startSpan's async callback. - // `hook` itself returns undefined, but the span only ends after the - // inner async callback resolves — gate test resolution on that. +/** Drive one RPC through the hook; resolves once `next()` has been called. */ +async function driveHook(hook) { let nextCalled = false; await new Promise((resolve) => { hook( @@ -56,18 +70,147 @@ test("rpcHook produces an envelope frame end-to-end via real @sentry/node-core", }, async () => { nextCalled = true; - // setImmediate so startSpan has a tick after next() resolves - // to call span.setStatus, end the span, and queue the envelope. setImmediate(resolve); }, ); }); + return nextCalled; +} + +test("debug ON: rpcHook produces an envelope AND records the rpc metric", async () => { + initSentry({ ...baseArgv, debug: true }); + const rec = recordingMetricsSdk(); + metrics.init({ + Sentry: rec.sdk, + platform: "android", + deviceClass: "mid", + osMajor: "android.14", + applicationUsageData: true, + }); + + const captured = []; + setSink((frame) => captured.push(frame)); + + const hook = rpcHook(); + assert.ok(hook, "rpcHook returned undefined — Sentry didn't initialise"); + + const nextCalled = await driveHook(hook); await flush(2000); assert.ok(nextCalled, "rpcHook did not invoke next()"); assert.ok( captured.length > 0, - "no envelope frame reached the sink — Node Sentry layer is silent", + "no envelope frame reached the sink — debug span not created", + ); + assert.ok( + rec.distributions.some( + (d) => d.name === "comapeo.rpc.server.duration_ms", + ), + "rpc.server metric not recorded while the debug span was active", + ); + + await Sentry.close(); +}); + +test("debug OFF: rpcHook records the metric but creates no span/envelope", async () => { + initSentry({ ...baseArgv, debug: false }); + const rec = recordingMetricsSdk(); + metrics.init({ + Sentry: rec.sdk, + platform: "android", + deviceClass: "mid", + osMajor: "android.14", + applicationUsageData: true, + }); + + const captured = []; + setSink((frame) => captured.push(frame)); + + const hook = rpcHook(); + assert.ok( + hook, + "rpcHook returned undefined — should still wrap for the metric path", + ); + + const nextCalled = await driveHook(hook); + await flush(500); + + assert.ok(nextCalled, "rpcHook did not invoke next()"); + assert.equal( + captured.length, + 0, + "debug-off must not create an rpc.server transaction envelope", + ); + assert.ok( + rec.distributions.some( + (d) => d.name === "comapeo.rpc.server.duration_ms", + ), + "rpc.server metric must be recorded on the debug-off path", + ); + + await Sentry.close(); +}); + +test("debug OFF: a rejecting RPC records the duration metric but captures no issue", async () => { + initSentry({ ...baseArgv, debug: false }); + const rec = recordingMetricsSdk(); + metrics.init({ + Sentry: rec.sdk, + platform: "android", + deviceClass: "mid", + osMajor: "android.14", + applicationUsageData: true, + }); + + const captured = []; + setSink((frame) => captured.push(frame)); + + const hook = rpcHook(); + assert.ok(hook, "rpcHook returned undefined — Sentry didn't initialise"); + + // The hook observes errors for metrics only; capturing an issue is the + // caller's decision, so a rejection must NOT produce an envelope. + await new Promise((resolve) => { + hook( + { method: ["read", "doc"], args: [], metadata: {} }, + async () => { + setImmediate(resolve); + throw new Error("boom"); + }, + ); + }); + await flush(500); + + assert.equal( + captured.length, + 0, + "the hook must not capture RPC errors as Sentry issues", + ); + assert.ok( + rec.distributions.some( + (d) => d.name === "comapeo.rpc.server.duration_ms", + ), + "the error path must still record the duration metric", + ); + + await Sentry.close(); +}); + +test("initialScope carries the native-derived user.id on outgoing events", async () => { + initSentry({ ...baseArgv, debug: false, sentryUserId: "e15e7255ae360358" }); + + const captured = []; + setSink((frame) => captured.push(frame)); + + Sentry.captureMessage("user id smoke"); + await flush(2000); + + const eventFrame = captured.find((f) => f.type === "sentry-event"); + assert.ok(eventFrame, "no event frame reached the sink"); + assert.equal( + eventFrame.payload.user?.id, + "e15e7255ae360358", + "event must carry the --sentryUserId value as user.id", ); await Sentry.close(); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f4d16d16..e030cc89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -674,7 +674,7 @@ rates. At `expo prebuild` it writes: - **Android**: meta-data on the main `` tag (`com.comapeo.core.sentry.dsn`, `…environment`, `…release`, `…sampleRate`, `…tracesSampleRate`, `…rpcArgsBytes`, - `…captureApplicationDataDefault`). meta-data is shared across + `…applicationUsageDataDefault`, `…debugDefault`). meta-data is shared across processes within the package, so both the main process and the `:ComapeoCore` FGS process read the same values. - **iOS**: keys in `Info.plist` with the `ComapeoCore` prefix @@ -702,7 +702,7 @@ The same plugin-baked subset is also exported as `sentryConfig` for read-only inspection — empty `{}` when the plugin isn't registered. It is NOT meant to be spread into a separate `Sentry.init` call; `initSentry` is the supported entrypoint. Plugin-internal fields -(`rpcArgsBytes`, `captureApplicationDataDefault`) stay on the +(`rpcArgsBytes`, `applicationUsageDataDefault`, `debugDefault`) stay on the native-side `SentryConfig` only. The FGS process's Sentry SDK is initialised in @@ -759,7 +759,7 @@ classpath, and the two halves were merged back into a single - **PII capture**. Per the plan §7.4.9, observation contents, precise location, peer identities, raw project IDs, and user-entered text are never captured even with the - capture-application-data toggle on. + application-usage-data toggle on. ### 7.5 Metadata reference @@ -840,9 +840,99 @@ single timeline in Sentry's Trace view. Cross-layer with the RN-side The plan's §7.4.9 never-capture list is enforced at every emit site (no observation contents, precise location, peer -identities, raw project IDs, or user-entered text). Phase 5's -`before_send` processor will add a defensive substring scrub -on top. +identities, raw project IDs, or user-entered text). The +`before_send`/`beforeBreadcrumb`/`beforeSendLog` scrubbers +(`src/sentry-scrub.ts` + the mirrored `backend/before-send.js`) +add a defensive substring scrub on top. + +#### Metrics + +The metrics layer (`src/sentry-metrics.ts` on RN, the mirrored +`backend/lib/metrics.js` on Node) emits aggregate counters / +distributions / gauges via `Sentry.metrics.*` (Sentry Application +Metrics). It is the always-on day-to-day performance signal, +distinct from the per-RPC and boot **traces**, which are +investigation-only and gated behind the `debug` toggle. + +**One metric per measurement, sliced by attributes.** Application +Metrics bills by ingested volume, not cardinality (high cardinality +is the default, not a limit), so each measurement is a single +high-cardinality metric carrying every dimension you'd slice by — +`status`, `phase`, and the device tags all ride together, and you +break down with group-by / filter at query time (e.g. `p95 group by +device_class`, or `group by method, device_class`). There is no +primary + `.by_device` split: that would double the emitted volume +for no cardinality saving and make the method × device query +impossible. + +**Privacy tiers.** `diagnosticsEnabled` (default on) gates whether +anything ships. The opt-in `applicationUsageData` toggle (default +off) gates the dimensions that reveal *what* the user does — the RPC +`method` attribute and the sync collaboration/volume bucket counters. +Those are gated for privacy, not cardinality. + +Every metric carries `platform` (`ios`/`android`). Latency/duration +metrics also carry the low-cardinality `device_class` +(`low`/`mid`/`high`) and `os_major` (`.`) tags, +attached only inside the metrics layer so a hot call site can't emit +raw device data. The forbidden-tag list (`device.*`, `locale`, +`project_id`, `peer_id`, raw coordinates, …) is dropped defensively +by `isForbiddenMetric`. + +| Metric | Type · unit | Attributes | Emitted by / when | What it tells us | +|---|---|---|---|---| +| `comapeo.rpc.client.duration_ms` | distribution · ms | `status`, `device_class`, `os_major`, `method` (usage-gated) | RN request hook — end-to-end client-observed RPC latency (IPC + serialize + handler + response delivery) | The primary UX-latency signal: how slow core operations feel end-to-end, which device classes are sluggish, and (usage tier) which methods are slowest. | +| `comapeo.rpc.server.duration_ms` | distribution · ms | `status`, `device_class`, `os_major`, `method` (usage-gated) | Node request hook — server-side handler latency (pairs with the client metric to separate handler vs IPC) | Handler-only cost. Paired with the client metric it separates "the core operation is slow" from "IPC / serialization / JS-thread overhead". | +| `comapeo.boot.phase_duration_ms` | distribution · ms | `phase`, `device_class`, `os_major` | Node — per boot phase | Where boot time goes (which phase dominates), per device class — the input to boot- and ANR-margin optimisation. | +| `comapeo.boot.outcome` | count | `outcome` (`started`/`error`), `error_phase?` | Node — once per boot | Boot success-vs-failure rate and which phase fails — the backend reliability signal. | +| `comapeo.storage.size_bucket` | count | `bucket` (`<10MB`/`10-100MB`/`100MB-1GB`/`>1GB`) | Node — one-shot at STARTED (recursive size of the private storage dir) | The population's storage-footprint distribution — context for correlating large DBs with slow sync/query. Coarse, one-shot: context, not an alerting signal. | +| `comapeo.backend.heap_used_bytes` | gauge · byte | — | Node — 60s sampler (V8 JS heap; `rss` is omitted, it measures the whole process) | Backend JS-heap ceiling, and across a session's samples a heap-growth (leak) signal. Coarse on iOS, where the runtime suspends in the background. | +| `comapeo.backend.event_loop_delay_ms` | distribution · ms | `device_class`, `os_major` | Node — 60s sampler; each emission is the window **max** from `monitorEventLoopDelay` | The worst event-loop stall per minute (sync work blocking async I/O). As a distribution, Sentry gives fleet percentiles of the per-minute worst stall, sliceable by device class. | +| `comapeo.sync.session.duration_ms` | distribution · ms | `outcome`, `device_class`, `os_major` | Node — **scaffolding, not yet wired** | (When wired) sync-session latency by outcome and device — core sync performance. | +| `comapeo.sync.session.peers_bucket` | count | `bucket` (usage-gated) | Node — **scaffolding, not yet wired** | (When wired) collaboration scale — how many peers took part in a sync. | +| `comapeo.sync.bytes_bucket` | count | `bucket` (usage-gated) | Node — **scaffolding, not yet wired** | (When wired) data volume moved per sync. | +| `comapeo.shutdown.phase_duration_ms` | distribution · ms | `phase` | Node — **scaffolding, not yet wired** | (When wired) shutdown timing — guards the stop-path ANR / timeout margin. | +| `comapeo.ipc.errors` | count | `error_class` | Node — **scaffolding, not yet wired** | (When wired) IPC failure rate by class — a transport-reliability signal distinct from backend errors. | +| `comapeo.telemetry.forwarding_failures` | count | — | Node — **scaffolding, not yet wired** | (When wired) whether our own telemetry sink is dropping/failing — a self-health check on the metrics pipeline. | + +Rows marked *scaffolding, not yet wired* have a metrics-layer +function and tests but no production call site yet — the meaning +is fixed so the emit point can be added without a dashboard +change. + +Three earlier metrics were dropped for carrying no actionable signal: +`comapeo.fgs.uptime_s` (a 60s-sampled monotonic gauge — average +meaningless, max ≈ session length and better derived from boot +frequency); `comapeo.rpc.client.send_ms` (redundant with the +`rn.send.syncMs` attribute already on the debug client span); and +`comapeo.state.transitions` (started/error overlapped `boot.outcome`, +the rest was routine lifecycle noise). + +`comapeo.backend.event_loop_delay_ms` reports the per-window **max** +(worst stall per minute) rather than the mean, which buried brief +stalls. `monitorEventLoopDelay` is pure libuv (no addon / worker / +inspector), so it's the one mechanism that definitely runs on device. + +Capturing a stall as an *event with a stack trace* (Sentry's ANR / +event-loop-block integrations) isn't available off the shelf. The +deprecated `anrIntegration` captures the stack via `node:inspector`, +which [nodejs-mobile doesn't ship](https://nodejs-mobile.github.io/docs/api/differences/) +("The V8 inspector is not available … due to a dependency on the `intl` +module"), so it's out. The current `eventLoopBlockIntegration` is a +native `@sentry/node-native` addon: no prebuilt binary targets the +nodejs-mobile ABIs, but this repo already builds addons for those +targets, so it's buildable *in principle* — gated on two unverified +questions (does its C++ compile against nodejs-mobile's headers, and +does its own stack capture avoid the absent inspector?). A stack-on-stall +event is a viable follow-up if that spike pans out; the max metric is the +always-on signal in the meantime. + +**Viewing.** In Sentry, open **Metrics** (Explore → Metrics), +filter by the metric name, and group by an attribute (`method`, +`status`, `phase`, `device_class`, …) — one metric can be sliced +along any dimension it carries. The per-RPC and boot **traces** +(spans above) live under Explore → Traces and only exist while +`debug` is on. ### 7.6 When to log, when to breadcrumb, when to capture diff --git a/docs/sentry-integration-history.md b/docs/sentry-integration-history.md index b6ee7b42..89803608 100644 --- a/docs/sentry-integration-history.md +++ b/docs/sentry-integration-history.md @@ -544,3 +544,56 @@ on iOS. - `ios/Tests/AppExitMetricsCollectorTests.swift` — 11 decoder tests (duplication semantics, tier gating, level/cause-class mapping, unknown buckets, window-id stability). + +## Phase 11 — toggle rework, metrics layer, PII scrubbers + +Landed the three-toggle model (§11.1), the always-on metrics layer +(§11.2/§11.3), and the symmetric PII scrubbers (§9b.1/§9b.5). + +Toggle rework (#75): +- Renamed `captureApplicationData` → `applicationUsageData` across the JS + API, native bridge methods, the on-device stored key, the Expo plugin + field, and the Node CLI flags. Deprecated aliases (`getCaptureApplicationData` + / `setCaptureApplicationData`, native `setCaptureApplicationData`, the + `--captureApplicationData` argv flag, and the plugin field) forward to + the new names for one minor release. A one-shot stored-key migration runs + on first `ComapeoPrefs.open` on both platforms. +- New `debug` toggle: `get/setDebugEnabled` (JS), `setDebugEnabled` (native), + `sentry.debug` + `sentry.debugEnabledAtMs` prefs slots, `--debug` argv, + `debugDefault` plugin field. Auto-off (§11.5; shipped as 72h, up from the + planned 24h) implemented in the `readDebugEnabled` reader on both + platforms; re-enable refreshes the window. +- Device classification (§11.2.b): new `DeviceTags.{kt,swift}` bucket the + device low/mid/high by RAM + cores and compute `.`. + Plumbed to RN via the `sentryConfig.deviceTags` constant and to Node via + `--deviceClass` / `--osMajor` / `--platformTag`. +- `tracesSampleRate` now derives from `debug` (1.0 while on, else the + plugin-configured rate, 0 when unset), not from the usage toggle. + +Metrics layer (#76): +- New `backend/lib/metrics.js` + `src/sentry-metrics.ts`: wrappers around + `Sentry.metrics.*` that inject `platform` on every metric, attach + `device_class` / `os_major` only on the `.by_device` mirrors, no-op when + Sentry is off, and run a `before_metric_send` forbidden-tag filter. +- RPC hooks split on both sides: always record the metric; only create a + Sentry span when `debug` is on, recording the metric while the span is + active so it links to the trace. +- Backend `consoleIntegration` moved behind `debug`. 60s memory / + event-loop sampler, boot-phase durations, state transitions, and a + bucketed storage-size counter wired in `index.js` / `withSpan`. + +PII scrubbers (#77): +- Shared regex list (rootKey markers, 22-char base64, lat/lng markers) in + `src/sentry-scrub.ts` (RN) and the hand-mirrored `backend/before-send.js` + (Node). RN wires the real scrubber as `beforeSend` ahead of the host's + chain and a host-only URL `beforeBreadcrumb`; Node registers the same + scrub as an `addEventProcessor`. Walks message, exception text, extra, + contexts, breadcrumb message + data, and span description + data; HTTP + breadcrumb URLs reduce to host-only. + +Tests: `backend/lib/metrics.test.mjs`, `backend/lib/before-send.test.mjs`, +extended `backend/lib/sentry.test.mjs` (debug on/off branching) and +`src/__tests__/sentry.test.js` (scrubber + traces gating); native +migration / debug-auto-off / device-boundary tests in +`ComapeoPrefs{Test,Tests}` and new `DeviceTags{Test,Tests}` on both +platforms (run on CI emulator/Xcode). diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md index dcfb14f8..b7742431 100644 --- a/docs/sentry-integration-plan.md +++ b/docs/sentry-integration-plan.md @@ -18,7 +18,7 @@ git history stay valid. | Phase 5 — capture-application-data opt-in surface | Per-RPC method spans, sync session transaction, bg/fg breadcrumbs, memory checkpoints, storage size sample, and the `before_send` privacy processor. The toggle plumbing itself is already done in Phase 9a. | | Phase 7b — iOS killed-in-background heuristic (optional) | `UserDefaults`-anchored per-event "killed in background" inference layered on top of the landed Phase 7a MetricKit forwarding (which is 24h-aggregate only). | | Phase 8 — refinements | Sample-rate tuning from real data; optional dual-bundle if size matters. | -| Phase 9b — PII scrubber, user.id rotation, context reclassification | Substring scrubber; installation UUID with monthly hash at diagnostic tier; native-scope field split; consoleIntegration gating; network-URL scrubbing. | +| Phase 9b — PII scrubber, user.id rotation, context reclassification | Scrubber (9b.1), user.id rotation (9b.2), network-URL scrubbing (9b.5), and consoleIntegration gating (9b.7, now debug-gated) landed with the Phase 11 branch. Remaining: native-scope field split (9b.3), boot-transaction slimming (9b.4), backend free-mem refresh (9b.6), toggle anchor resets (9b.9). | | Phase 11 — Metrics-first observability + `debug` tier | Shift day-to-day performance signal from per-RPC tracing to Sentry metrics (with bucketed device tags so "Samsung A52 is slow at sync" is a dashboard query). Rename `captureApplicationData` → `applicationUsageData` (now: stable `user.id` + usage events). New user-facing `debug` toggle enables per-RPC tracing for investigation. | --- @@ -185,34 +185,25 @@ and every span's `description` + `attributes`. Trade-off between false-positive aggressiveness and signal preservation documented inline with example matches. -### 9b.2 `user.id` — installation UUID + monthly rotation - -A stable per-install UUID owned by native (because the FGS process -needs it before RN starts): - -- **Storage**: `ComapeoPrefs` adds a `sentry.installationId` key. - Generated lazily on first read as `UUID.randomUUID().toString()` on - Android / `UUID().uuidString` on iOS. Persisted in `SharedPreferences` - (cleared on uninstall) — explicitly **not** Keychain; we want - uninstall to genuinely reset identity. -- **Computation**: - - Diagnostic tier: - `user.id = sha256(installationId + utc_year_month).slice(0, 16)` - where `utc_year_month` is `YYYY-MM` (current UTC). Hash rotates - monthly so cross-month traces don't link. - - App-usage tier: `user.id = installationId` (raw stable ID). - - When a user shares their `installationId` (e.g. for a bug report), - we can recover the diagnostic hashes back to them. -- **Distribution**: native computes once at process start, exposes on - the existing `sentryConfig` Expo constant as `userId`. Backend - loader receives it via `--sentryUserId=...` argv. All three SDKs use - the same value via `Sentry.setUser({ id })` (locked — host can't - override). -- **On toggle-flip**: the `installationId` itself doesn't rotate on - `diagnosticsEnabled` toggle (that would defeat bug-report - recoverability). When the user goes `app-usage on → off`, the next - launch's `user.id` changes (raw → monthly hash); that's the intended - boundary. +### 9b.2 `user.id` — root user ID + monthly rotation + +**Landed** (with the Phase 11 branch) — see +[`sentry-integration.md` §9.2](./sentry-integration.md#92-the-applicationusagedata-toggle) +"Sentry `user.id`" for the as-built design. Two deltas from this +section's original spec: + +- The stored ID is named **`sentry.rootUserId`** and is *never* sent + raw. The usage tier uses `sha256(root + "|permanent")` instead of the + raw ID, so the root ID is only ever shared by explicit user action + (via the `getRootUserId()` API, for support cases). +- Both tiers hash with the same shape: + `sha256("|").slice(0, 16)` where salt is UTC `YYYY-MM` + (diagnostic, monthly rotation) or `"permanent"` (usage opt-in). + +Distribution matches the spec: native derives once per process start, +exposes `userId` on the `sentryConfig` Expo constant, passes +`--sentryUserId` argv to the backend, and all three SDKs set the same +`user.id`. ### 9b.3 Context field reclassification @@ -586,10 +577,11 @@ Previously this gated per-RPC tracing + the perf grab bag. After this phase it gates only: 1. **Stable `user.id`** — disables the monthly hash rotation specified - in Phase 9b.2. Locks to raw `installationId`. Without - `applicationUsageData` the user.id rotates monthly across - diagnostic captures (cohort-unlinkable). With it on, stable across - launches and months (cohort analysis works). + in Phase 9b.2. Locks to the permanent hash of the root user ID + (never the raw ID). Without `applicationUsageData` the user.id + rotates monthly across diagnostic captures (cohort-unlinkable). + With it on, stable across launches and months (cohort analysis + works). 2. **Usage breadcrumbs / counters** — a module-supplied helper: ```ts diff --git a/docs/sentry-integration.md b/docs/sentry-integration.md index 5fae3df8..10fb7fec 100644 --- a/docs/sentry-integration.md +++ b/docs/sentry-integration.md @@ -112,7 +112,7 @@ its own process / runtime and has its own SDK init. │ └──────────────────────────────────────────────────┘ │ │ │ │ │ │ argv: --sentryDsn, --sentry..., │ -│ │ --captureApplicationData │ +│ │ --applicationUsageData │ │ │ comapeo.sock RPC (with sentry-trace) │ │ ▼ │ │ ┌─────────────────── Node backend ─────────────────┐ │ @@ -190,7 +190,7 @@ Three configuration vectors solve this together: | Vector | When read | Purpose | | -------------------------------------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Expo config plugin** (build-time) | At native process start, before any IPC | DSN, environment, release, sample rates. The single source of truth. | -| **Persisted native preference** (runtime, restart-to-activate) | At native process start | The `diagnosticsEnabled` and `captureApplicationData` toggles (§9). | +| **Persisted native preference** (runtime, restart-to-activate) | At native process start | The `diagnosticsEnabled` and `applicationUsageData` toggles (§9). | | **JS adapter auto-detect** (side-effect import) | When the consumer imports `@comapeo/core-react-native/sentry` | The sub-export probes `@sentry/react-native` via `require`-then-catch and attaches state listeners against it for `captureException` / breadcrumbs. Does **not** carry DSN. | ### 4.1 Build-time: Expo config plugin (primary) @@ -208,7 +208,7 @@ Plugin inputs: | `sampleRate` | no | Sentry sampling knob. | | `rpcArgsBytes` | no | RPC arg-truncation cap (developer debug builds only). | | `diagnosticsEnabledDefault` | no | Per-environment default for the diagnostics toggle (§9). | -| `captureApplicationDataDefault` | no | Per-environment default for the capture-application-data toggle (§9). | +| `applicationUsageDataDefault` | no | Per-environment default for the application-usage-data toggle (§9). | The module deliberately **does not derive `environment`** — build-environment schemes are app-specific. The consumer feeds `environment` from a build-time @@ -344,7 +344,7 @@ data class SentryConfig( val tracesSampleRate: Double?, val rpcArgsBytes: Int?, val diagnosticsEnabledDefault: Boolean? = null, - val captureApplicationDataDefault: Boolean? = null, + val applicationUsageDataDefault: Boolean? = null, ) fun loadFromManifest(ctx: Context): SentryConfig? { @@ -372,7 +372,7 @@ fun loadFromManifest(ctx: Context): SentryConfig? { sampleRate = meta.getString("com.comapeo.core.sentry.sampleRate")?.toDoubleOrNull(), tracesSampleRate = meta.getString("com.comapeo.core.sentry.tracesSampleRate")?.toDoubleOrNull(), rpcArgsBytes = meta.getString("com.comapeo.core.sentry.rpcArgsBytes")?.toIntOrNull(), - // …diagnosticsEnabledDefault / captureApplicationDataDefault read similarly + // …diagnosticsEnabledDefault / applicationUsageDataDefault read similarly ) } ``` @@ -466,7 +466,7 @@ Two persisted boolean toggles in native preferences: - `diagnosticsEnabled` — gates Sentry entirely. When off, neither the FGS bridge nor the backend loader nor the RN-side `Sentry.init` run. -- `captureApplicationData` — gates the _additional_ observability surface +- `applicationUsageData` — gates the _additional_ observability surface described in §7.4 (per-RPC method spans, sync session spans, counts) but never touches DSN/environment/release and never unlocks PII fields. @@ -487,7 +487,7 @@ node loader.mjs \ --sentryRelease=1.4.2 \ --sentryTracesSampleRate=0.1 \ --sentryRpcArgsBytes=0 \ - --captureApplicationData # only when toggle is on + --applicationUsageData # only when toggle is on ``` Native picks the loader path (`loader.mjs`) as the entry script and @@ -623,7 +623,7 @@ const { values } = parseArgs({ sentryTracesSampleRate: { type: "string" }, sentrySampleRate: { type: "string" }, sentryRpcArgsBytes: { type: "string" }, - captureApplicationData: { type: "boolean", default: false }, + applicationUsageData: { type: "boolean", default: false }, }, allowPositionals: true, strict: false, @@ -636,11 +636,10 @@ if (values.sentryDsn) { environment: values.sentryEnvironment ?? "production", release: values.sentryRelease, sampleRate: Number(values.sentrySampleRate ?? 1.0), - tracesSampleRate: values.captureApplicationData - ? Number(values.sentryTracesSampleRate ?? 0.1) - : 0, + // Native resolves the effective rate (debug window folds in there) + // and forwards it; the backend mirrors it verbatim. + tracesSampleRate: Number(values.sentryTracesSampleRate ?? 0), transport: makeControlSocketTransport(), // see §5.7 - integrations: [Sentry.consoleLoggingIntegration()], initialScope: { tags: { layer: "node" } }, }); } @@ -654,7 +653,7 @@ back without re-parsing argv: ```js globalThis[SENTRY_CONFIG_GLOBAL] = { rpcArgsBytes: Number(values.sentryRpcArgsBytes ?? 0), - captureApplicationData: values.captureApplicationData, + applicationUsageData: values.applicationUsageData, }; ``` @@ -709,11 +708,11 @@ function makeSentryRequestHook() { await next(request); span.setStatus({ code: 1, message: "ok" }); } catch (error) { + // Observe for tracing + metrics only — the hook does NOT capture + // an issue. An RPC rejection is often expected control flow (e.g. + // NotFound); whether it's worth reporting is the calling + // application's decision, at the call site. span.setStatus({ code: 2, message: "internal_error" }); - Sentry.captureException(error, { - tags: { layer: "node", op: "rpc.server" }, - }); - throw error; } }, ), @@ -727,8 +726,11 @@ Notes: - The hook is only registered when Sentry is active; absent config, `createMapeoServer` is called without `onRequestHook` and there is zero overhead. -- We rethrow after `captureException` so the IPC error path still returns a - rejection to the JS caller. +- The hook is observational: it records duration/status metrics and (under + `debug`) a trace span, but never calls `captureException`. The RPC response + and its rejection flow back to the JS caller through rpc-reflector's own + channel independently of the hook, so swallowing the error here does not + affect the caller. Error *reporting* is left to the call site. - `request.args` is not serialised by default. In CoMapeo data the args can be project-scoped content (observation fields, attachments). Opt-in only via `rpcArgsBytes`. @@ -813,7 +815,7 @@ sources. - `src/sentry.ts` — public sub-export. `initSentry()`, `getDiagnosticsEnabled` / `setDiagnosticsEnabled`, - `getCaptureApplicationData` / `setCaptureApplicationData`, types, state + `getApplicationUsageData` / `setApplicationUsageData`, types, state listeners. - `src/sentry-internal.ts` — module-private state holding the auto-detected adapter (or `null`), keyed reads for the RPC wrapper. @@ -1102,7 +1104,7 @@ captures still fire (commit `6bd4852`). | IPC connection breadcrumbs | **Essential** | | Unexpected-disconnect event | **Essential** | | FGS lifecycle breadcrumbs | **Essential** | -| Per-RPC method spans (sampled) | **Opt-in** (`captureApplicationData == true`) | +| Per-RPC method spans (sampled) | **Opt-in** (`applicationUsageData == true`) | | Sync session transaction (start → ready → finish, with peer count) | **Opt-in** | | Background/foreground transitions | **Opt-in** | | Backend memory/heap snapshots (periodic) | **Opt-in** | @@ -1172,7 +1174,7 @@ killers reaching past FGS protection — plus `description`, `pss_kb`, (not usage) tier deliberately: they're aggregate, low-resolution cohort axes, not a per-user timeline. -App-usage-tier additions (only when `captureApplicationData` is on): +App-usage-tier additions (only when `applicationUsageData` is on): the exact `alive_for_ms` / `backgrounded_for_ms` values — millisecond precision over a user's backgrounding behaviour is usage-shape data, see §8. Durations derive from wall-clock anchors in @@ -1251,8 +1253,11 @@ identities). Defaults must avoid leaking it into Sentry: - **Stacktraces** are fine — they may include filenames from inside `@comapeo/core` and the bundled backend. No user data unless an `Error.message` was constructed with one. -- **`tracesSampleRate`** defaults to `0` if `captureApplicationData` is - off. The host app must opt in to RPC tracing volume explicitly. +- **`tracesSampleRate`** is `1.0` while the user-bounded `debug` window + is on, otherwise the plugin-configured rate — and `0` when the plugin + doesn't set one, which is the expected production config. The plugin + field is a build-time consumer decision (e.g. sampling internal/QA + builds), not a per-user setting. - **`sendDefaultPii: false`** is locked by `initSentry()`; the host can't override it. @@ -1276,29 +1281,51 @@ config option, not behind `rpcArgsBytes>0`, not ever: - File paths under `Application Support` or `getFilesDir()` that include the rootkey or project IDs. -A `before_send` event processor (currently an identity placeholder, full -implementation pending in Phase 9b) enforces the list defensively: it -walks the event tree for known sensitive substrings (`rootKey`, -base64-shaped 22-char strings, `lat=`, `lng=`, `latitude:`, `longitude:`) -and either redacts or drops the event. This is belt-and-suspenders — the -fix is always at the capture site, but the processor catches mistakes -before they ship. +A scrubber enforces the list defensively on both sides of the IPC +boundary (`src/sentry-scrub.ts` on RN, `backend/before-send.js` on +Node; shared test cases in `test-support/scrubber-cases.js` keep the +two copies from drifting): it walks the event tree — message, exception +values, extra, contexts, breadcrumbs, structured logs — redacting +`rootKey`-marked values and coordinate markers (`lat`, `lng`, `lon`, +`latitude`, `longitude`, in key/value and JSON-serialized forms). A +broad base64-22-char rule (to catch *bare* unmarked rootkeys) is +deliberately **not** enabled: it also matched trace IDs and exception +type names; a narrower design is pending, and until then bare unmarked +tokens pass through. This is belt-and-suspenders — the fix is always at +the capture site, but the scrubber catches mistakes before they ship. --- -## 9. Three-tier privacy model +## 9. Privacy model + +> **Three-toggle model.** The privacy contract has three toggles — +> `diagnosticsEnabled`, `applicationUsageData`, and `debug`: +> +> | Toggle | Gates | Default | +> | ---------------------- | -------------------------------------------------------------------------------------- | --------- | +> | `diagnosticsEnabled` | `Sentry.init`; errors, lifecycle, **metrics**, boot/sync/shutdown transactions. | `true` | +> | `applicationUsageData` | Permanent `user.id` hash (no monthly rotation) + the usage-tier metric dimensions (see the §9.2 table). | `false` | +> | `debug` | Per-RPC traces, `@comapeo/core` OTel spans, backend `consoleIntegration`, `rpc.args`. | `false` | +> +> Day-to-day performance signal rides an always-on **metrics** layer at +> the diagnostic tier (`comapeo.rpc.*`, `comapeo.boot.*`, etc.); per-RPC +> *traces* moved behind `debug`, which 100%-samples while on and +> auto-expires 72h after the most recent enable. `tracesSampleRate` is +> `debug ? 1.0 : ` where the plugin rate defaults to `0` +> (the expected production config — a nonzero rate is a build-time +> consumer decision for internal/QA builds). CoMapeo's host-app privacy contract has three states, not two: | Tier | What runs | When | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | **Off** | Nothing. `Sentry.init` is **not** called on RN, FGS, or Node. The module's adapter stays null; emit paths no-op. | User explicitly opts out of diagnostic data sharing in the host app settings. | -| **Diagnostic** (default-on) | Errors + lifecycle: `Sentry.init` runs in all three SDKs with `tracesSampleRate=0`, `sendDefaultPii=false`, and a PII scrubber. Boot transactions on; per-RPC spans off. | Default for fresh installs (and recommended for production). | -| **App-usage** (additional opt-in) | Diagnostic set **plus** per-RPC client/server spans, sync-session transaction, bg/fg breadcrumbs, backend memory snapshot, `privateStorageDir` size, and the full SentryNativeContext fingerprinting fields. | User opts in via a settings toggle. | +| **Diagnostic** (default-on) | Errors + lifecycle: `Sentry.init` runs in all three SDKs with the plugin-configured `tracesSampleRate` (`0` when unset — the expected production config), `sendDefaultPii=false`, and a PII scrubber. Boot transactions on; per-RPC spans off. | Default for fresh installs (and recommended for production). | +| **App-usage** (additional opt-in) | Diagnostic set **plus** the usage-tier metric dimensions (RPC `method` breakdown, sync `peers_bucket`/`bytes_bucket`, app-exit exact-ms durations — see the §9.2 table), the permanent `user.id` hash, bg/fg breadcrumbs, and the SentryNativeContext fingerprinting fields. | User opts in via a settings toggle. | Diagnostic is the _baseline_; app-usage is _additive_. App-usage without diagnostic is impossible by construction — the effective gate is -`captureApplicationData && diagnosticsEnabled`, enforced inside this +`applicationUsageData && diagnosticsEnabled`, enforced inside this module so the host UI never has to mirror that logic. ### 9.1 The `diagnosticsEnabled` toggle @@ -1330,23 +1357,52 @@ via `diagnosticsEnabledDefault`. separate native-init gate; the host's `Sentry.init` (owned by `initSentry()`) is the only init site, and it gates on the same pref. -### 9.2 The `captureApplicationData` toggle +### 9.2 The `applicationUsageData` toggle A persisted boolean, default off, that the host app's settings UI exposes -to the end user. When on, the opt-in captures from §7.3.8 are emitted. -Never unlocks anything in the §8 never-capture list — the two layers are -independent. +to the end user. When on, it adds the usage-tier metric dimensions in the +table below to the always-on diagnostic set. Never unlocks anything in the +§8 never-capture list — the two layers are independent. + +Front-end feature-usage recording (which screens/features a user opens) is +**not** part of this tier; that telemetry is handled by the host app via +PostHog, not Sentry. The Sentry metrics layer is for backend/native +performance and the operational dimensions below. - **Persistence**: same `ComapeoPrefs` file. Key: - `sentry.captureApplicationData`. + `sentry.applicationUsageData`. - **JS API**: ```ts - export function getCaptureApplicationData(): Promise; - export function setCaptureApplicationData(enabled: boolean): Promise; + export function getApplicationUsageData(): boolean; + export function setApplicationUsageData(value: boolean): Promise; ``` - **Plumbing**: read once at native process start, passed as a Node argv - flag (`--captureApplicationData`). The control-socket `init` frame - doesn't carry it. + flag (`--applicationUsageData`). The control-socket `init` frame + doesn't carry it. Android also reads it directly from `ComapeoPrefs` to + gate the app-exit exact-ms fields in the FGS/main process. + +#### What the tier gates + +The diagnostic tier carries aggregate, low-cardinality operational signal; +`applicationUsageData` adds the dimensions that would otherwise reveal +*what a specific user does* or *how much they hold*. Per-signal: + +| Signal | Tier | Why | +| --- | --- | --- | +| RPC latency, aggregate (`rpc.{client,server}.duration_ms` with `status` + device tags) | Diagnostics | Latency by status and device bucket is pure performance; no per-operation detail. | +| RPC `method` attribute on the same metrics (+ `rpc.client.send_ms{method}`) | applicationUsageData | The set and frequency of `@comapeo/core` methods a user invokes reveals what they do (create vs view vs sync) — usage behaviour, not perf. | +| Boot / shutdown phase timings + outcome | Diagnostics | Startup/teardown performance; no user-specific content. *Shutdown emitter not yet wired ([#190](https://github.com/digidem/comapeo-core-react-native/issues/190)).* | +| Backend health gauges (memory, heap, uptime, event-loop delay) | Diagnostics | Process resource health; independent of user activity. | +| Sync session duration + outcome | Diagnostics | Sync performance/reliability is core-function health; that a sync ran is inherent to a P2P app. *Not yet wired ([#80](https://github.com/digidem/comapeo-core-react-native/issues/80)).* | +| Sync `peers_bucket` | applicationUsageData | How many devices a user syncs with is a proxy for their collaboration/social-graph size. *Not yet wired ([#80](https://github.com/digidem/comapeo-core-react-native/issues/80)).* | +| Sync `bytes_bucket` | applicationUsageData | Volume of data exchanged is a proxy for how much a user collects/shares. *Not yet wired ([#80](https://github.com/digidem/comapeo-core-react-native/issues/80)).* | +| `state.transitions` | Diagnostics | App lifecycle states; no user content. | +| `storage.size_bucket` | Diagnostics | Coarse 4-bucket dataset size — kept on so crashes/OOMs can be correlated with data volume. | +| App-exit coarse buckets (`uptime_bucket`, `bg_duration_bucket`, OEM-kill flags) | Diagnostics | Aggregate stability signal ("which OEMs kill us"); low-resolution. | +| App-exit exact-ms (`alive_for_ms`, `backgrounded_for_ms`) | applicationUsageData | Millisecond session/foreground durations are fine-grained usage-shape data. | +| `device_class` / `os_major` / `platform` tags | Diagnostics | Low-cardinality device-capability buckets; not user-identifying. | +| `ipc.errors`, `telemetry.forwarding_failures` | Diagnostics | Internal transport health. *Not yet wired ([#190](https://github.com/digidem/comapeo-core-react-native/issues/190)).* | +| Per-RPC traces / OTel spans / `rpc.args` | `debug` (separate) | Investigation-only; behind the 72h auto-off `debug` toggle, not `applicationUsageData`. | #### Why restart-to-activate @@ -1363,9 +1419,9 @@ independent. #### Cross-toggle interaction Setters write their raw values independently. The _effective_ -`captureApplicationData` value is always +`applicationUsageData` value is always `stored && diagnosticsEnabled` — internal gate only. The user's stored -`captureApplicationData=true` is preserved across diagnostics off→on +`applicationUsageData=true` is preserved across diagnostics off→on cycles. #### What the toggle never unlocks @@ -1379,6 +1435,36 @@ The §8 never-capture list applies regardless: - The toggle does not start capturing precise location. - The toggle does not start capturing peer identities. +#### Sentry `user.id` + +Identity is anchored on a **root user ID** — a short random code +(`XXXX-XXXX-XXXX`, 12 chars from a base32 alphabet with no I/L/O/U, 60 +bits) generated lazily on first read and persisted in `ComapeoPrefs` +(SharedPreferences / UserDefaults, so uninstall genuinely resets +identity). The format is deliberately short and unambiguous so a user +can hand-copy it from a screen for a support case. The root ID itself +is **never sent to Sentry**; every event's `user.id` is a hash derived +from it natively (`SentryUserId.{kt,swift}`, +`sha256("|")` hex, first 16 chars): + +- `applicationUsageData` **off** → salt is the current UTC `YYYY-MM`, + so the ID rotates monthly and cross-month events can't be linked to + one install. +- `applicationUsageData` **on** → salt is the constant `"permanent"`, + so the ID is stable across launches and months (cohort analysis + works). Restart-to-activate, like the toggle itself. + +Because both derivations are recomputable from the root ID, a user can +share it (surfaced via `getRootUserId()` from the `/sentry` sub-export, +intended for a debug/about screen) and support can re-associate their +historical events — including pre-opt-in monthly IDs. + +One launch reports one user: native derives the value once per process +start and distributes it to all three SDKs — `Sentry.setUser` on the RN +side (via the `sentryConfig.userId` constant), scope user on the native +init (`SentryFgsBridge.init` / `SentryNativeBridge.initFromConfig`), and +`--sentryUserId` argv to the backend's `initialScope`. + ### 9.3 Module ownership of `Sentry.init` `@comapeo/core-react-native/sentry` owns the RN-side `Sentry.init` call. @@ -1423,11 +1509,11 @@ them — TypeScript refuses them at the call site): - `dsn`, `release`, `environment`, `sampleRate`, `enableLogs` — from the plugin's `sentryConfig`. -- `tracesSampleRate` — `0` when capture-application-data is off, the - plugin's value (default `0.1`) when on. Effective gate enforced here. +- `tracesSampleRate` — `1.0` while the `debug` window is on, otherwise + the plugin's value (`0` when unset). Effective gate enforced here. - `sendDefaultPii: false` — non-overridable. -- `user.id` — controlled by the module (planned; see Phase 9b in the - remaining work). +- `user.id` — controlled by the module: the native-derived + monthly/permanent hash (see §9.2's "Sentry `user.id`"). The `integrations` option is a function `(defaults) => Integration[]` so the host can append to (not replace) our defaults. `beforeSend` and @@ -1466,7 +1552,7 @@ Per-environment, decided by the consumer at build time via plugin fields: "dsn": "...", "environment": "development", "diagnosticsEnabledDefault": true, - "captureApplicationDataDefault": true + "applicationUsageDataDefault": true } } ] @@ -1479,7 +1565,7 @@ Recommended consumer config, wired through EAS env vars: ```js // app.config.js -captureApplicationDataDefault: +applicationUsageDataDefault: (process.env.SENTRY_ENVIRONMENT ?? "production") !== "production", ``` diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index 7971aaaa..c3174268 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -28,6 +28,16 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { return SentryConfig.loadFromMainBundle() } + /// Derived Sentry user.id for this launch (monthly hash, or permanent + /// hash under the usage opt-in). `nil` when diagnostics is off. + private static func deriveSentryUserId() -> String? { + let prefs = ComapeoPrefs.open() + guard prefs.readDiagnosticsEnabled() else { return nil } + return prefs.deriveSentryUserId( + applicationUsageData: prefs.readApplicationUsageData() + ) + } + static let nodeService = NodeJSService( socketDir: AppLifecycleDelegate.resolveSocketDir(), privateStorageDir: AppLifecycleDelegate.resolvePrivateStorageDir(), @@ -63,7 +73,10 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { try AppLifecycleDelegate.rootKeyStore.loadOrInitialize() }, sentryConfig: AppLifecycleDelegate.resolveEffectiveSentryConfig(), - captureApplicationData: ComapeoPrefs.open().readCaptureApplicationData() + applicationUsageData: ComapeoPrefs.open().readApplicationUsageData(), + debug: ComapeoPrefs.open().readDebugEnabled(), + deviceTags: DeviceTags.compute(), + sentryUserId: AppLifecycleDelegate.deriveSentryUserId() ) /// Directory for the Unix-domain socket files. The 104-byte @@ -116,7 +129,21 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { // so `nodeService.start()` finds a live hub. JS-side `Sentry.init` // runs later with `autoInitializeNativeSdk: false`. if let cfg = Self.resolveEffectiveSentryConfig() { - SentryNativeBridge.initFromConfig(cfg) + // Run the auto-off check before init so any queue() from + // readDebugEnabled() precedes the consume() drain below. Otherwise + // the only readDebugEnabled() calls this launch run after + // didFinishLaunching (lazy nodeService / Expo constants), and the + // crumb is lost on the launch that performed the auto-off. + _ = ComapeoPrefs.open().readDebugEnabled() + SentryNativeBridge.initFromConfig(cfg, userId: Self.deriveSentryUserId()) + // Drain a `debug` 72h auto-off queued by the prefs + // reader, which runs before the SDK is up. + if DebugAutoOff.consume() { + SentryNativeBridge.addBreadcrumb( + category: "comapeo.debug.auto_disabled", + message: "debug auto-disabled after 72h" + ) + } // MXAppExitMetric needs iOS 14+; the podspec floor (15.1) // guarantees it, so no availability guard. #if canImport(MetricKit) diff --git a/ios/ComapeoCoreModule.swift b/ios/ComapeoCoreModule.swift index 39d3f52d..1f671c40 100644 --- a/ios/ComapeoCoreModule.swift +++ b/ios/ComapeoCoreModule.swift @@ -85,17 +85,51 @@ public class ComapeoCoreModule: Module { // Plist-baked Sentry options, re-exported by the JS `/sentry` // sub-export. Empty map when DSN absent so spreading is safe. + // `userId` is derived with the same launch snapshot the native init + // uses, so all layers report the same Sentry user.id. Constant("sentryConfig") { () -> [String: Any] in - SentryConfig.loadFromMainBundle()?.toSentryInitMap() ?? [:] + let prefs = ComapeoPrefs.open() + return SentryConfig.loadFromMainBundle()?.toSentryInitMap( + deviceTags: DeviceTags.compute(), + userId: prefs.deriveSentryUserId( + applicationUsageData: prefs.readApplicationUsageData() + ) + ) ?? [:] + } + + // The permanent root user ID (lazily generated on first read). Local + // debugging aid only — Sentry sees derived hashes, never this value. + // The host app may show it in a debug/about screen so a user can + // share it and support can recompute their historical monthly + // user.ids. + Function("getSentryRootUserId") { () -> String in + ComapeoPrefs.open().readRootUserId() + } + + // The snapshot in effect this session. Toggle changes take effect on + // next launch (see `setDiagnosticsEnabled` / `setApplicationUsageData` + // / `setDebugEnabled`). For the current saved value use + // `getCurrentSentryPreferences`. + Constant("sentryPreferencesAtLaunch") { () -> [String: Any] in + let prefs = ComapeoPrefs.open() + return [ + "diagnosticsEnabled": prefs.readDiagnosticsEnabled(), + "applicationUsageData": prefs.readApplicationUsageData(), + "debug": prefs.readDebugEnabled(), + ] } - // Snapshot-at-launch. Toggle changes take effect on next launch - // (see `setDiagnosticsEnabled` / `setCaptureApplicationData`). - Constant("sentryPreferences") { () -> [String: Any] in + // Live read of the current persisted values — reflects a `setX` made + // this session and survives a JS reload (unlike the + // `sentryPreferencesAtLaunch` Constant), so a settings screen can read + // the user's choice without keeping its own copy. Raw `debug` (no 72h + // auto-off side effect — that's applied by readDebugEnabled at launch). + Function("getCurrentSentryPreferences") { () -> [String: Any] in let prefs = ComapeoPrefs.open() return [ "diagnosticsEnabled": prefs.readDiagnosticsEnabled(), - "captureApplicationData": prefs.readCaptureApplicationData(), + "applicationUsageData": prefs.readApplicationUsageData(), + "debug": prefs.readDebugStored(), ] } @@ -107,8 +141,13 @@ public class ComapeoCoreModule: Module { if !value { ComapeoPrefs.wipeSentryOutbox() } } - AsyncFunction("setCaptureApplicationData") { (value: Bool) in - ComapeoPrefs.open().writeCaptureApplicationData(value) + AsyncFunction("setApplicationUsageData") { (value: Bool) in + ComapeoPrefs.open().writeApplicationUsageData(value) + if !value { ComapeoPrefs.wipeSentryOutbox() } + } + + AsyncFunction("setDebugEnabled") { (value: Bool) in + ComapeoPrefs.open().writeDebugEnabled(value) if !value { ComapeoPrefs.wipeSentryOutbox() } } diff --git a/ios/ComapeoPrefs.swift b/ios/ComapeoPrefs.swift index 8f7a4a92..b5037e43 100644 --- a/ios/ComapeoPrefs.swift +++ b/ios/ComapeoPrefs.swift @@ -1,81 +1,181 @@ import Foundation /// Persistent storage for sentry-related user preferences. Snapshot- -/// at-launch: toggle changes take effect on next launch; flipping to -/// `false` also wipes the sentry-cocoa cache so queued events don't -/// ship. Mirrors `ComapeoPrefs.kt`. +/// at-launch: toggle changes take effect on next launch; flipping a +/// toggle to `false` also wipes the sentry-cocoa cache so queued events +/// don't ship. Mirrors `ComapeoPrefs.kt`. /// -/// Constructor takes read/write closures so unit tests don't need a -/// real `UserDefaults`. +/// Persistence goes through a `Store` so unit tests can back it with a +/// plain dictionary instead of a real `UserDefaults`. final class ComapeoPrefs { + /// Minimal persistence surface. `nil` from a getter means "key + /// absent" (so the caller falls back to a default). Production wraps + /// `UserDefaults`; tests use an in-memory dictionary. + protocol Store { + func getBool(_ key: String) -> Bool? + func setBool(_ key: String, _ value: Bool) + func getDouble(_ key: String) -> Double? + func setDouble(_ key: String, _ value: Double) + func getString(_ key: String) -> String? + func setString(_ key: String, _ value: String) + func remove(_ key: String) + } + struct Defaults { let diagnosticsEnabled: Bool - let captureApplicationData: Bool + let applicationUsageData: Bool + let debug: Bool } - private let readBool: (String) -> Bool? - private let writeBool: (String, Bool) -> Void + private let store: Store private let defaults: Defaults + /// Wall clock; injectable so the debug-auto-off tests don't depend on real time. + private let now: () -> Double init( - readBool: @escaping (String) -> Bool?, - writeBool: @escaping (String, Bool) -> Void, - defaults: Defaults + store: Store, + defaults: Defaults, + now: @escaping () -> Double = { Date().timeIntervalSince1970 * 1000 } ) { - self.readBool = readBool - self.writeBool = writeBool + self.store = store self.defaults = defaults + self.now = now } func readDiagnosticsEnabled() -> Bool { - readBool(Key.diagnosticsEnabled) ?? defaults.diagnosticsEnabled + store.getBool(Key.diagnosticsEnabled) ?? defaults.diagnosticsEnabled + } + + func readApplicationUsageData() -> Bool { + store.getBool(Key.applicationUsageData) ?? defaults.applicationUsageData + } + + /// Raw stored `debug` value with no auto-off side effect — the user's saved + /// toggle for a live settings read. `readDebugEnabled()` applies the 72h + /// auto-off (and its disk mutation) at launch; this must not, so a getter + /// never writes. + func readDebugStored() -> Bool { + store.getBool(Key.debug) ?? defaults.debug } - func readCaptureApplicationData() -> Bool { - readBool(Key.captureApplicationData) ?? defaults.captureApplicationData + /// Read the `debug` toggle, applying the `debugMaxAgeMs` auto-off: if + /// debug was switched on longer ago than that, flip it off, clear the + /// timestamp, queue a `comapeo.debug.auto_disabled` breadcrumb, and + /// return `false`. A `debug=true` cell with no timestamp (e.g. enabled + /// via the configured default) is treated as "enabled now" and stamped + /// on first read. + /// + /// The window is wall-clock based (it must survive process restarts), + /// so a backward clock change is treated conservatively: an enable + /// timestamp in the future expires debug rather than extending it. This + /// is a best-effort privacy window on the user's own device, not a + /// security boundary. + func readDebugEnabled() -> Bool { + let stored = store.getBool(Key.debug) ?? defaults.debug + if !stored { return false } + guard let enabledAt = store.getDouble(Key.debugEnabledAtMs) else { + store.setDouble(Key.debugEnabledAtMs, now()) + return true + } + let age = now() - enabledAt + if age < 0 || age > Self.debugMaxAgeMs { + store.setBool(Key.debug, false) + store.remove(Key.debugEnabledAtMs) + DebugAutoOff.queueBreadcrumb() + return false + } + return true } func writeDiagnosticsEnabled(_ value: Bool) { - writeBool(Key.diagnosticsEnabled, value) + store.setBool(Key.diagnosticsEnabled, value) + } + + func writeApplicationUsageData(_ value: Bool) { + store.setBool(Key.applicationUsageData, value) } - func writeCaptureApplicationData(_ value: Bool) { - writeBool(Key.captureApplicationData, value) + /// The permanent per-install root user ID (a short `XXXX-XXXX-XXXX` + /// code — see `SentryUserId.generateRootId`), generated lazily on first + /// read. Never sent to Sentry — Sentry `user.id` values are derived from + /// it via `SentryUserId.derive`. Exposed to the host app (via + /// `getSentryRootUserId`) so a user can share it for debugging and we can + /// recompute their historical monthly IDs. Lives in `UserDefaults` (not + /// Keychain) deliberately: uninstall should genuinely reset identity. + func readRootUserId() -> String { + if let existing = store.getString(Key.rootUserId) { return existing } + let generated = SentryUserId.generateRootId() + store.setString(Key.rootUserId, generated) + return generated + } + + /// The Sentry `user.id` for this launch: permanent when the user opted + /// in to application-usage data, otherwise rotating monthly (UTC). + func deriveSentryUserId(applicationUsageData: Bool) -> String { + SentryUserId.derive( + rootUserId: readRootUserId(), + permanent: applicationUsageData, + nowMs: now() + ) + } + + /// Write `debug`, stamping (true) or clearing (false) the enable + /// timestamp synchronously. Re-writing `true` refreshes the window. + func writeDebugEnabled(_ value: Bool) { + store.setBool(Key.debug, value) + if value { + store.setDouble(Key.debugEnabledAtMs, now()) + } else { + store.remove(Key.debugEnabledAtMs) + } } enum Key { static let diagnosticsEnabled = "sentry.diagnosticsEnabled" - static let captureApplicationData = "sentry.captureApplicationData" + static let applicationUsageData = "sentry.applicationUsageData" + static let debug = "sentry.debug" + static let debugEnabledAtMs = "sentry.debugEnabledAtMs" + static let rootUserId = "sentry.rootUserId" } + /// 72h in milliseconds — debug mode auto-disables this long after enable. + static let debugMaxAgeMs: Double = 72 * 60 * 60 * 1000 + /// Privacy model treats baseline error visibility as on. static let defaultDiagnosticsEnabled: Bool = true /// Off until user opts in. - static let defaultCaptureApplicationData: Bool = false + static let defaultApplicationUsageData: Bool = false + static let defaultDebug: Bool = false static func open() -> ComapeoPrefs { let sentryConfig = SentryConfig.loadFromMainBundle() let defaults = Defaults( diagnosticsEnabled: sentryConfig?.diagnosticsEnabledDefault ?? defaultDiagnosticsEnabled, - captureApplicationData: sentryConfig?.captureApplicationDataDefault - ?? defaultCaptureApplicationData + applicationUsageData: sentryConfig?.applicationUsageDataDefault + ?? defaultApplicationUsageData, + debug: sentryConfig?.debugDefault ?? defaultDebug ) - let store = UserDefaults.standard return ComapeoPrefs( - readBool: { key in - // `object(forKey:)` distinguishes absent from explicit - // `false`; `bool(forKey:)` collapses them, which would - // silently re-enable diagnostics on every user `false`. - store.object(forKey: key) as? Bool - }, - writeBool: { key, value in - store.set(value, forKey: key) - }, + store: UserDefaultsStore(defaults: UserDefaults.standard), defaults: defaults ) } + /// `UserDefaults`-backed [Store]. `object(forKey:)` distinguishes absent + /// from an explicit `false`; `bool(forKey:)` collapses them, which would + /// silently re-enable diagnostics on every user `false`. + private struct UserDefaultsStore: Store { + let defaults: UserDefaults + func getBool(_ key: String) -> Bool? { defaults.object(forKey: key) as? Bool } + func setBool(_ key: String, _ value: Bool) { defaults.set(value, forKey: key) } + func getDouble(_ key: String) -> Double? { defaults.object(forKey: key) as? Double } + func setDouble(_ key: String, _ value: Double) { defaults.set(value, forKey: key) } + func getString(_ key: String) -> String? { defaults.string(forKey: key) } + func setString(_ key: String, _ value: String) { defaults.set(value, forKey: key) } + func remove(_ key: String) { defaults.removeObject(forKey: key) } + } + /// Recursively delete sentry-cocoa's on-disk cache root at /// `/io.sentry/` (sentry-cocoa's documented /// default). Wipes envelopes, sessions, and scope state so a @@ -97,3 +197,27 @@ final class ComapeoPrefs { try? FileManager.default.removeItem(at: url) } } + +/// Holds the `comapeo.debug.auto_disabled` breadcrumb queued by the debug +/// auto-off. `readDebugEnabled()` runs before `SentrySDK.start`, +/// so the breadcrumb can't be added directly; it's drained once the SDK +/// is up. +enum DebugAutoOff { + private static let lock = NSLock() + private static var pendingFlag = false + + static func queueBreadcrumb() { + lock.lock() + pendingFlag = true + lock.unlock() + } + + /// Consume the pending flag; returns whether a breadcrumb is owed. + static func consume() -> Bool { + lock.lock() + defer { lock.unlock() } + let owed = pendingFlag + pendingFlag = false + return owed + } +} diff --git a/ios/DeviceTags.swift b/ios/DeviceTags.swift new file mode 100644 index 00000000..0ec28996 --- /dev/null +++ b/ios/DeviceTags.swift @@ -0,0 +1,68 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +/// Low-cardinality device classification. Buckets the device +/// into low/mid/high by RAM + CPU cores so a metric like +/// "low-end devices are 4× slower at observation.create" is a dashboard +/// query rather than a per-model cardinality explosion. Computed once at +/// process start and cached on `SentryConfig`. Mirrors `DeviceTags.kt`. +/// +/// Raw model/manufacturer stay on the event/trace scope (native SDK +/// attaches them); only the bucket rides on metrics. +struct DeviceTags: Equatable { + let platform: String + let deviceClass: String + let osMajor: String + + static let platformTag = "ios" + + static let classLow = "low" + static let classMid = "mid" + static let classHigh = "high" + + private static let gb: UInt64 = 1024 * 1024 * 1024 + + /// Thresholds: + /// low: < 3 GB RAM OR < 4 cores + /// mid: 3–6 GB AND 4–6 cores + /// high: ≥ 6 GB AND ≥ 6 cores + /// + /// A device high on one axis but low on the other falls to the lower + /// class — the slow axis dominates perceived perf. Boundaries are + /// inclusive at the lower edge of each higher band (exactly 3 GB / + /// exactly 4 cores is the floor of `mid`). + static func classify(totalMemBytes: UInt64, cores: Int) -> String { + let ramHigh = totalMemBytes >= 6 * gb + let ramMid = totalMemBytes >= 3 * gb + let coresHigh = cores >= 6 + let coresMid = cores >= 4 + if ramHigh && coresHigh { return classHigh } + if ramMid && coresMid { return classMid } + return classLow + } + + /// `ios.` from a system-version string. + static func osMajor(systemVersion: String) -> String { + let major = systemVersion.split(separator: ".").first.map(String.init) + let safe = (major?.isEmpty == false) ? major! : "0" + return "\(platformTag).\(safe)" + } + + static func compute() -> DeviceTags { + let totalMem = ProcessInfo.processInfo.physicalMemory + let cores = ProcessInfo.processInfo.processorCount + #if canImport(UIKit) + let version = UIDevice.current.systemVersion + #else + let v = ProcessInfo.processInfo.operatingSystemVersion + let version = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + #endif + return DeviceTags( + platform: platformTag, + deviceClass: classify(totalMemBytes: totalMem, cores: cores), + osMajor: osMajor(systemVersion: version) + ) + } +} diff --git a/ios/NodeJSService.swift b/ios/NodeJSService.swift index cc83f345..c925a5a5 100644 --- a/ios/NodeJSService.swift +++ b/ios/NodeJSService.swift @@ -155,7 +155,11 @@ class NodeJSService { /// Forwarded as `--sentry*` argv to `backend/loader.mjs`. /// `nil` → loader skips Sentry. private let sentryConfig: SentryConfig? - private let captureApplicationData: Bool + private let applicationUsageData: Bool + private let debug: Bool + private let deviceTags: DeviceTags? + /// Derived Sentry user.id (monthly/permanent hash) forwarded as `--sentryUserId`. + private let sentryUserId: String? init( socketDir: String, @@ -167,13 +171,19 @@ class NodeJSService { }, rootKeyProvider: @escaping RootKeyProvider, sentryConfig: SentryConfig? = SentryConfig.loadFromMainBundle(), - captureApplicationData: Bool = false, + applicationUsageData: Bool = false, + debug: Bool = false, + deviceTags: DeviceTags? = nil, + sentryUserId: String? = nil, startupTimeout: TimeInterval = 30 ) { self.socketDir = socketDir self.privateStorageDir = privateStorageDir self.sentryConfig = sentryConfig - self.captureApplicationData = captureApplicationData + self.applicationUsageData = applicationUsageData + self.debug = debug + self.deviceTags = deviceTags + self.sentryUserId = sentryUserId self.comapeoSocketPath = (socketDir as NSString).appendingPathComponent(NodeJSService.comapeoSocketFilename) self.controlSocketPath = (socketDir as NSString).appendingPathComponent(NodeJSService.controlSocketFilename) @@ -702,17 +712,30 @@ class NodeJSService { if let r = cfg.sampleRate { out.append("--sentrySampleRate=\(r)") } - if let r = cfg.tracesSampleRate { - out.append("--sentryTracesSampleRate=\(r)") - } + // Native owns the trace-sampling decision: full while the debug window + // is on, else the plugin-configured cap (0 if unset). The backend + // mirrors this value rather than re-deciding. + let effectiveTracesSampleRate = debug ? 1.0 : (cfg.tracesSampleRate ?? 0.0) + out.append("--sentryTracesSampleRate=\(effectiveTracesSampleRate)") if let b = cfg.rpcArgsBytes { out.append("--sentryRpcArgsBytes=\(b)") } if cfg.enableLogs == true { out.append("--sentryEnableLogs") } - if captureApplicationData { - out.append("--captureApplicationData") + if let userId = sentryUserId { + out.append("--sentryUserId=\(userId)") + } + if applicationUsageData { + out.append("--applicationUsageData") + } + if debug { + out.append("--debug") + } + if let tags = deviceTags { + out.append("--deviceClass=\(tags.deviceClass)") + out.append("--osMajor=\(tags.osMajor)") + out.append("--platformTag=\(tags.platform)") } // Prefer the node-spawn span over the transaction so Node-side diff --git a/ios/Package.swift b/ios/Package.swift index 52b914f7..1c8e5cf7 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -41,6 +41,8 @@ let package = Package( "SentryConfig.swift", "SentryNativeBridge.swift", "SentryTags.swift", + "SentryUserId.swift", + "DeviceTags.swift", // MetricKit subscriber is #if os(iOS); only the pure // AppExitDecoder compiles (and is tested) on macOS. "AppExitMetricsCollector.swift", diff --git a/ios/SentryConfig.swift b/ios/SentryConfig.swift index 5d68f82a..f5c4f329 100644 --- a/ios/SentryConfig.swift +++ b/ios/SentryConfig.swift @@ -14,14 +14,19 @@ struct SentryConfig: Equatable { /// write wins thereafter. let diagnosticsEnabledDefault: Bool? /// Default for fresh installs. `nil` → `false`. - let captureApplicationDataDefault: Bool? + let applicationUsageDataDefault: Bool? + /// Default for the `debug` toggle. `nil` → `false`. + let debugDefault: Bool? /// Opt in to `SentrySDK.logger.*`. `nil`/`false` → logger no-ops. let enableLogs: Bool? /// Subset that maps cleanly to JS-side `Sentry.init` options; /// plugin-internal fields excluded. Spread on the JS side as - /// `Sentry.init({ ...sentryConfig, ...mine })`. - func toSentryInitMap() -> [String: Any] { + /// `Sentry.init({ ...sentryConfig, ...mine })`. `deviceTags` + /// rides along for the `.by_device` metrics; `userId` is the derived + /// Sentry user.id (monthly or permanent hash, never the root ID) — + /// `initSentry` applies it via `Sentry.setUser`. + func toSentryInitMap(deviceTags: DeviceTags? = nil, userId: String? = nil) -> [String: Any] { var map: [String: Any] = [ "dsn": dsn, "environment": environment, @@ -30,6 +35,14 @@ struct SentryConfig: Equatable { if let sampleRate = sampleRate { map["sampleRate"] = sampleRate } if let tracesSampleRate = tracesSampleRate { map["tracesSampleRate"] = tracesSampleRate } if let enableLogs = enableLogs { map["enableLogs"] = enableLogs } + if let userId = userId { map["userId"] = userId } + if let deviceTags = deviceTags { + map["deviceTags"] = [ + "platform": deviceTags.platform, + "deviceClass": deviceTags.deviceClass, + "osMajor": deviceTags.osMajor, + ] + } return map } @@ -42,7 +55,8 @@ struct SentryConfig: Equatable { static let tracesSampleRate = "ComapeoCoreSentryTracesSampleRate" static let rpcArgsBytes = "ComapeoCoreSentryRpcArgsBytes" static let diagnosticsEnabledDefault = "ComapeoCoreSentryDiagnosticsEnabledDefault" - static let captureApplicationDataDefault = "ComapeoCoreSentryCaptureApplicationDataDefault" + static let applicationUsageDataDefault = "ComapeoCoreSentryApplicationUsageDataDefault" + static let debugDefault = "ComapeoCoreSentryDebugDefault" static let enableLogs = "ComapeoCoreSentryEnableLogs" } @@ -85,9 +99,10 @@ struct SentryConfig: Equatable { diagnosticsEnabledDefault: parseStrictBool( info[Key.diagnosticsEnabledDefault] ), - captureApplicationDataDefault: parseStrictBool( - info[Key.captureApplicationDataDefault] + applicationUsageDataDefault: parseStrictBool( + info[Key.applicationUsageDataDefault] ), + debugDefault: parseStrictBool(info[Key.debugDefault]), enableLogs: parseStrictBool(info[Key.enableLogs]) ) } diff --git a/ios/SentryNativeBridge.swift b/ios/SentryNativeBridge.swift index fe25c6a0..fb3362d5 100644 --- a/ios/SentryNativeBridge.swift +++ b/ios/SentryNativeBridge.swift @@ -22,8 +22,9 @@ enum SentryNativeBridge { /// Idempotent. Caller must pass non-nil config; skip the call when /// `loadFromMainBundle()` returns nil. Mirror of Android /// `SentryFgsBridge.init` — the iOS process IS the "FGS" since iOS - /// is single-process. - static func initFromConfig(_ config: SentryConfig) { + /// is single-process. `userId` is the derived Sentry user.id (monthly + /// or permanent hash — never the root ID). + static func initFromConfig(_ config: SentryConfig, userId: String? = nil) { if SentrySDK.isEnabled { return } let opts = Options() opts.dsn = config.dsn @@ -37,6 +38,11 @@ enum SentryNativeBridge { opts.initialScope = { scope in scope.setTag(value: SentryTags.procMain, key: SentryTags.proc) scope.setTag(value: SentryTags.layerNative, key: SentryTags.layer) + if let userId = userId { + let user = User() + user.userId = userId + scope.setUser(user) + } return scope } SentrySDK.start(options: opts) diff --git a/ios/SentryUserId.swift b/ios/SentryUserId.swift new file mode 100644 index 00000000..3f40fdf5 --- /dev/null +++ b/ios/SentryUserId.swift @@ -0,0 +1,58 @@ +import CryptoKit +import Foundation + +/// Derives the Sentry `user.id` from the permanent per-install root user ID +/// (see `ComapeoPrefs.readRootUserId`). The root ID itself never leaves the +/// device; Sentry only ever sees a truncated hash: +/// +/// - usage opt-in **off** → `sha256("|")` — rotates each +/// UTC month so cross-month events can't be linked to one install; +/// - usage opt-in **on** → `sha256("|permanent")` — stable across +/// launches and months so cohort analysis works. +/// +/// Both are recoverable from a user-shared root ID, so historical events can +/// be re-associated for a support case. Must stay in lock-step with +/// `SentryUserId.kt` (shared test vectors in both suites). +enum SentryUserId { + static let permanentSalt = "permanent" + private static let idLength = 16 + + /// Crockford-style base32: no I/L/O/U, so a root ID read off a screen + /// and typed back by hand can't be mis-transcribed. Must match + /// `SentryUserId.kt`. + static let rootIdAlphabet = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + + /// A fresh root user ID: 12 alphabet chars grouped `XXXX-XXXX-XXXX` + /// (60 bits — comfortable headroom against collisions across the + /// install base while staying short enough to copy from a screen by + /// hand). Stored and hashed exactly as formatted, hyphens included. + static func generateRootId() -> String { + var rng = SystemRandomNumberGenerator() + let chars = (0..<12).map { _ in + rootIdAlphabet[Int(rng.next(upperBound: UInt32(rootIdAlphabet.count)))] + } + return [chars[0..<4], chars[4..<8], chars[8..<12]] + .map { String($0) } + .joined(separator: "-") + } + + static func derive(rootUserId: String, permanent: Bool, nowMs: Double) -> String { + let salt = permanent ? permanentSalt : utcYearMonth(nowMs: nowMs) + return String(sha256Hex("\(rootUserId)|\(salt)").prefix(idLength)) + } + + /// `YYYY-MM` in UTC. + static func utcYearMonth(nowMs: Double) -> String { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let date = Date(timeIntervalSince1970: nowMs / 1000) + let parts = calendar.dateComponents([.year, .month], from: date) + return String(format: "%04d-%02d", parts.year!, parts.month!) + } + + private static func sha256Hex(_ input: String) -> String { + SHA256.hash(data: Data(input.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } +} diff --git a/ios/Tests/ComapeoPrefsTests.swift b/ios/Tests/ComapeoPrefsTests.swift index 9f4cd95a..7451e946 100644 --- a/ios/Tests/ComapeoPrefsTests.swift +++ b/ios/Tests/ComapeoPrefsTests.swift @@ -13,66 +13,160 @@ import XCTest /// regression). Both are silent if the tests don't catch them. final class ComapeoPrefsTests: XCTestCase { - /// Backing dictionary stand-in for the UserDefaults file. Wrapped - /// in a class so the lambdas share mutable state with `has(_:)`. - /// Box the dict in a reference holder so the lambdas can mutate - /// without capturing `self` and tripping ARC's deallocation - /// guard when the closure outlives this instance. - private final class FakeStore { - private final class Box { - var data: [String: Bool] = [:] + /// In-memory `ComapeoPrefs.Store` stand-in for the UserDefaults file. + private final class FakeStore: ComapeoPrefs.Store { + private var bools: [String: Bool] = [:] + private var doubles: [String: Double] = [:] + private var strings: [String: String] = [:] + func getBool(_ key: String) -> Bool? { bools[key] } + func setBool(_ key: String, _ value: Bool) { bools[key] = value } + func getDouble(_ key: String) -> Double? { doubles[key] } + func setDouble(_ key: String, _ value: Double) { doubles[key] = value } + func getString(_ key: String) -> String? { strings[key] } + func setString(_ key: String, _ value: String) { strings[key] = value } + func remove(_ key: String) { + bools[key] = nil; doubles[key] = nil; strings[key] = nil } - private let box = Box() - lazy var read: (String) -> Bool? = { [box] key in box.data[key] } - lazy var write: (String, Bool) -> Void = { [box] key, value in - box.data[key] = value + func has(_ key: String) -> Bool { + bools[key] != nil || doubles[key] != nil || strings[key] != nil } - func has(_ key: String) -> Bool { return box.data[key] != nil } + } + + private final class Clock { + var nowMs: Double = 0 } private func prefs( store: FakeStore, diagnosticsDefault: Bool = ComapeoPrefs.defaultDiagnosticsEnabled, - captureDefault: Bool = ComapeoPrefs.defaultCaptureApplicationData + usageDefault: Bool = ComapeoPrefs.defaultApplicationUsageData, + debugDefault: Bool = ComapeoPrefs.defaultDebug, + clock: Clock = Clock() ) -> ComapeoPrefs { return ComapeoPrefs( - readBool: store.read, - writeBool: store.write, + store: store, defaults: ComapeoPrefs.Defaults( diagnosticsEnabled: diagnosticsDefault, - captureApplicationData: captureDefault - ) + applicationUsageData: usageDefault, + debug: debugDefault + ), + now: { [clock] in clock.nowMs } ) } func testBakedDefaultWhenKeyAbsent() { // Fresh install, plugin didn't ship a default, user hasn't - // toggled anything — diagnostics on, capture-app-data off. + // toggled anything — diagnostics on, usage off, debug off. let p = prefs(store: FakeStore()) XCTAssertTrue(p.readDiagnosticsEnabled()) - XCTAssertFalse(p.readCaptureApplicationData()) + XCTAssertFalse(p.readApplicationUsageData()) + XCTAssertFalse(p.readDebugEnabled()) } func testPluginDefaultOverridesBakedWhenKeyAbsent() { - // E.g. a dev/qa plugin config with both flags on by default. + // E.g. a dev/qa plugin config with usage on by default. let p = prefs( store: FakeStore(), diagnosticsDefault: false, - captureDefault: true + usageDefault: true ) XCTAssertFalse(p.readDiagnosticsEnabled()) - XCTAssertTrue(p.readCaptureApplicationData()) + XCTAssertTrue(p.readApplicationUsageData()) } func testUserValueWinsOverDefault() { // Once written, the user's choice persists across cold // starts regardless of what the plugin default says. let store = FakeStore() - let p = prefs(store: store, diagnosticsDefault: true, captureDefault: false) + let p = prefs(store: store, diagnosticsDefault: true, usageDefault: false) p.writeDiagnosticsEnabled(false) - p.writeCaptureApplicationData(true) + p.writeApplicationUsageData(true) XCTAssertFalse(p.readDiagnosticsEnabled()) - XCTAssertTrue(p.readCaptureApplicationData()) + XCTAssertTrue(p.readApplicationUsageData()) + } + + func testDebugAutoOffBoundaries() { + // fresh enable true; just within window true; just past window false + cleared. + let store = FakeStore() + let clock = Clock() + clock.nowMs = 1_000_000 + let p = prefs(store: store, clock: clock) + p.writeDebugEnabled(true) + XCTAssertTrue(p.readDebugEnabled(), "fresh enable reads true") + + clock.nowMs += ComapeoPrefs.debugMaxAgeMs - 60_000 // one minute before expiry + XCTAssertTrue(p.readDebugEnabled(), "within window reads true") + + clock.nowMs += 120_000 // now past the window since enable + XCTAssertFalse(p.readDebugEnabled(), "past window auto-disables") + XCTAssertEqual( + store.getBool(ComapeoPrefs.Key.debug), false, + "auto-off clears the value" + ) + XCTAssertFalse( + store.has(ComapeoPrefs.Key.debugEnabledAtMs), + "auto-off clears the timestamp" + ) + XCTAssertFalse(p.readDebugEnabled(), "subsequent read is stable") + } + + func testDebugExpiresWhenClockMovesBackwardPastEnable() { + // Backward wall-clock change must not extend debug: an enable + // timestamp in the future (age < 0) auto-disables rather than + // keeping debug on indefinitely. + let store = FakeStore() + let clock = Clock() + clock.nowMs = 10_000_000 + let p = prefs(store: store, clock: clock) + p.writeDebugEnabled(true) + XCTAssertTrue(p.readDebugEnabled()) + + clock.nowMs -= 5_000_000 // clock moved back before the enable stamp + XCTAssertFalse(p.readDebugEnabled(), "backward clock past enable auto-disables") + XCTAssertFalse(store.has(ComapeoPrefs.Key.debugEnabledAtMs)) + } + + func testDebugReEnableRefreshesWindow() { + let store = FakeStore() + let clock = Clock() + let p = prefs(store: store, clock: clock) + p.writeDebugEnabled(true) + clock.nowMs += ComapeoPrefs.debugMaxAgeMs - 60_000 + p.writeDebugEnabled(true) // refresh just before expiry + clock.nowMs += ComapeoPrefs.debugMaxAgeMs - 60_000 + XCTAssertTrue(p.readDebugEnabled(), "re-enable should reset the window clock") + } + + func testReadDebugStoredIsRawWithNoAutoOff() { + // The live settings read must return the raw saved toggle and never + // mutate: past the window, readDebugEnabled auto-disables + writes, + // but readDebugStored still reads true and leaves disk untouched. + let store = FakeStore() + let clock = Clock() + clock.nowMs = 1_000 + let p = prefs(store: store, clock: clock) + p.writeDebugEnabled(true) + clock.nowMs += ComapeoPrefs.debugMaxAgeMs + 60_000 + + XCTAssertTrue(p.readDebugStored(), "raw read returns stored value") + XCTAssertEqual( + store.getBool(ComapeoPrefs.Key.debug), true, + "raw read does not clear the stored value" + ) + XCTAssertTrue( + store.has(ComapeoPrefs.Key.debugEnabledAtMs), + "raw read runs no auto-off (timestamp intact)" + ) + } + + func testDebugTrueWithoutTimestampStampsAndStaysOn() { + let store = FakeStore() + store.setBool(ComapeoPrefs.Key.debug, true) + let clock = Clock() + clock.nowMs = 500 + let p = prefs(store: store, clock: clock) + XCTAssertTrue(p.readDebugEnabled()) + XCTAssertEqual(store.getDouble(ComapeoPrefs.Key.debugEnabledAtMs), 500) } func testWriteFalsePersistsExplicitlyNotJustClears() { @@ -98,9 +192,10 @@ final class ComapeoPrefsTests: XCTestCase { "sentry.diagnosticsEnabled" ) XCTAssertEqual( - ComapeoPrefs.Key.captureApplicationData, - "sentry.captureApplicationData" + ComapeoPrefs.Key.applicationUsageData, + "sentry.applicationUsageData" ) + XCTAssertEqual(ComapeoPrefs.Key.debug, "sentry.debug") } func testWipeSentryOutboxRemovesDirectory() throws { @@ -137,6 +232,49 @@ final class ComapeoPrefsTests: XCTestCase { try? fm.removeItem(at: tempRoot) } + func testRootUserIdIsGeneratedOnceAndStable() { + // Identity anchor: regenerating on every read would silently + // rotate the "permanent" user.id and break historical + // re-association from a user-shared root ID. + let store = FakeStore() + let p = prefs(store: store) + let first = p.readRootUserId() + XCTAssertFalse(first.isEmpty) + XCTAssertEqual(first, p.readRootUserId()) + XCTAssertEqual(first, prefs(store: store).readRootUserId()) + XCTAssertTrue(store.has(ComapeoPrefs.Key.rootUserId)) + } + + func testDeriveSentryUserIdRotatesMonthlyWithoutOptIn() { + let store = FakeStore() + let clock = Clock() + clock.nowMs = 0 // 1970-01 + let p = prefs(store: store, clock: clock) + let january = p.deriveSentryUserId(applicationUsageData: false) + clock.nowMs = 32 * 24 * 60 * 60 * 1000 // 1970-02 + let february = p.deriveSentryUserId(applicationUsageData: false) + XCTAssertNotEqual(january, february) + // Same month → same id. + XCTAssertEqual(february, p.deriveSentryUserId(applicationUsageData: false)) + } + + func testDeriveSentryUserIdIsPermanentWithOptIn() { + let store = FakeStore() + let clock = Clock() + clock.nowMs = 0 + let p = prefs(store: store, clock: clock) + let a = p.deriveSentryUserId(applicationUsageData: true) + clock.nowMs = 400 * 24 * 60 * 60 * 1000 // > a year later + let b = p.deriveSentryUserId(applicationUsageData: true) + XCTAssertEqual(a, b) + // Neither derivation ever exposes the root ID itself. + XCTAssertNotEqual(a, p.readRootUserId()) + XCTAssertNotEqual( + p.deriveSentryUserId(applicationUsageData: false), + p.readRootUserId() + ) + } + func testWipeSentryOutboxIsNoOpWhenAbsent() { // First-run / already-wiped path: a missing directory is // success, not an error. The KDoc promises "best-effort" — diff --git a/ios/Tests/DeviceTagsTests.swift b/ios/Tests/DeviceTagsTests.swift new file mode 100644 index 00000000..97257ddc --- /dev/null +++ b/ios/Tests/DeviceTagsTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import ComapeoCore + +/// Classification boundary cases. Mirrors `DeviceTagsTest.kt`. +/// `classify` takes raw RAM bytes + core count so no `ProcessInfo` mock +/// is needed — pure value tests, simulator-free. +final class DeviceTagsTests: XCTestCase { + private let gb: UInt64 = 1024 * 1024 * 1024 + + func testExactly3GbAnd4CoresIsMid() { + XCTAssertEqual(DeviceTags.classify(totalMemBytes: 3 * gb, cores: 4), DeviceTags.classMid) + } + + func testJustUnder3GbIsLow() { + XCTAssertEqual(DeviceTags.classify(totalMemBytes: 3 * gb - 1, cores: 4), DeviceTags.classLow) + } + + func testThreeCoresIsLowEvenWithAmpleRam() { + XCTAssertEqual(DeviceTags.classify(totalMemBytes: 8 * gb, cores: 3), DeviceTags.classLow) + } + + func testExactly6GbAnd6CoresIsHigh() { + XCTAssertEqual(DeviceTags.classify(totalMemBytes: 6 * gb, cores: 6), DeviceTags.classHigh) + } + + func testSixGbButOnlyFiveCoresIsMid() { + XCTAssertEqual(DeviceTags.classify(totalMemBytes: 6 * gb, cores: 5), DeviceTags.classMid) + } + + func testOsMajorTakesLeadingComponent() { + XCTAssertEqual(DeviceTags.osMajor(systemVersion: "17"), "ios.17") + XCTAssertEqual(DeviceTags.osMajor(systemVersion: "16.5.1"), "ios.16") + XCTAssertEqual(DeviceTags.osMajor(systemVersion: ""), "ios.0") + } +} diff --git a/ios/Tests/SentryConfigTests.swift b/ios/Tests/SentryConfigTests.swift index d95d07ab..9293f4bc 100644 --- a/ios/Tests/SentryConfigTests.swift +++ b/ios/Tests/SentryConfigTests.swift @@ -49,14 +49,14 @@ final class SentryConfigTests: XCTestCase { XCTAssertNil(config?.tracesSampleRate) XCTAssertNil(config?.rpcArgsBytes) XCTAssertNil(config?.diagnosticsEnabledDefault) - XCTAssertNil(config?.captureApplicationDataDefault) + XCTAssertNil(config?.applicationUsageDataDefault) + XCTAssertNil(config?.debugDefault) } func testPluginReleaseOverridesDefault() { // When the consumer passes `release` to the plugin, that // value wins over CFBundleShortVersionString+CFBundleVersion. - // Used to embed git SHAs from EAS_BUILD_GIT_COMMIT_HASH - // (plan §4.1). + // Used to embed git SHAs from EAS_BUILD_GIT_COMMIT_HASH. let config = SentryConfig.load( from: [ SentryConfig.Key.dsn: "https://x@sentry.io/1", @@ -159,41 +159,41 @@ final class SentryConfigTests: XCTestCase { XCTAssertNil(stray?.diagnosticsEnabledDefault) } - func testCaptureApplicationDataDefaultParsesString() { + func testApplicationUsageDataDefaultParsesString() { let on = SentryConfig.load( from: [ SentryConfig.Key.dsn: "https://x@sentry.io/1", SentryConfig.Key.environment: "qa", - SentryConfig.Key.captureApplicationDataDefault: "true", + SentryConfig.Key.applicationUsageDataDefault: "true", ], defaultRelease: defaultRelease ) - XCTAssertEqual(on?.captureApplicationDataDefault, true) + XCTAssertEqual(on?.applicationUsageDataDefault, true) let off = SentryConfig.load( from: [ SentryConfig.Key.dsn: "https://x@sentry.io/1", SentryConfig.Key.environment: "production", - SentryConfig.Key.captureApplicationDataDefault: "false", + SentryConfig.Key.applicationUsageDataDefault: "false", ], defaultRelease: defaultRelease ) - XCTAssertEqual(off?.captureApplicationDataDefault, false) + XCTAssertEqual(off?.applicationUsageDataDefault, false) } - func testCaptureApplicationDataDefaultParsesNativeBool() { + func testDebugDefaultParses() { let config = SentryConfig.load( from: [ SentryConfig.Key.dsn: "https://x@sentry.io/1", - SentryConfig.Key.environment: "production", - SentryConfig.Key.captureApplicationDataDefault: true, + SentryConfig.Key.environment: "qa", + SentryConfig.Key.debugDefault: "true", ], defaultRelease: defaultRelease ) - XCTAssertEqual(config?.captureApplicationDataDefault, true) + XCTAssertEqual(config?.debugDefault, true) } - func testCaptureApplicationDataDefaultStrictness() { + func testApplicationUsageDataDefaultStrictness() { // Only "true"/"false" (or a real Bool) parse. A stray // "1"/"yes" returns nil → native treats absence as false. // Defensive against hand-written plists silently flipping @@ -202,15 +202,15 @@ final class SentryConfigTests: XCTestCase { from: [ SentryConfig.Key.dsn: "https://x@sentry.io/1", SentryConfig.Key.environment: "qa", - SentryConfig.Key.captureApplicationDataDefault: "yes", + SentryConfig.Key.applicationUsageDataDefault: "yes", ], defaultRelease: defaultRelease ) - XCTAssertNil(config?.captureApplicationDataDefault) + XCTAssertNil(config?.applicationUsageDataDefault) } func testMissingEnvironmentReturnsNilNotFatal() { - // The plugin refuses to prebuild without environment (§4.1), + // The plugin refuses to prebuild without environment, // but a stale prebuild from before that validation was added // would still ship. The original `fatalError` behaviour // crashed every cold start with no way to recover. Now we diff --git a/ios/Tests/SentryUserIdTests.swift b/ios/Tests/SentryUserIdTests.swift new file mode 100644 index 00000000..b5ef5e6d --- /dev/null +++ b/ios/Tests/SentryUserIdTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import ComapeoCore + +/// Pinned vectors for the user.id derivation. The same vectors live in +/// `SentryUserIdTest.kt` — both platforms must derive identical IDs +/// from identical inputs so support can recompute a user's historical +/// monthly IDs from a shared root ID regardless of platform. +/// +/// Vectors are `sha256("|")` hex, first 16 chars. +final class SentryUserIdTests: XCTestCase { + + // 2026-07-01T00:00:00Z + private let july2026Ms: Double = 1_782_864_000_000 + + func testMonthlyVector() { + XCTAssertEqual( + "e15e7255ae360358", + SentryUserId.derive(rootUserId: "test-root", permanent: false, nowMs: july2026Ms) + ) + } + + func testPermanentVector() { + XCTAssertEqual( + "cbd8388dc87b1a9c", + SentryUserId.derive(rootUserId: "test-root", permanent: true, nowMs: july2026Ms) + ) + } + + func testGenerateRootIdFormat() { + // XXXX-XXXX-XXXX from the no-I/L/O/U alphabet — a user must be able + // to hand-copy this from a screen without ambiguous characters. + let format = "^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$" + for _ in 0..<20 { + let id = SentryUserId.generateRootId() + XCTAssertNotNil( + id.range(of: format, options: .regularExpression), + "bad root id format: \(id)" + ) + } + XCTAssertNotEqual(SentryUserId.generateRootId(), SentryUserId.generateRootId()) + } + + func testUtcYearMonthPadsAndUsesUtc() { + XCTAssertEqual("1970-01", SentryUserId.utcYearMonth(nowMs: 0)) + // 1ms before the month boundary vs 1ms after — the rotation + // boundary is UTC midnight, not device-local. + let feb1970Ms: Double = 31 * 24 * 60 * 60 * 1000 + XCTAssertEqual("1970-01", SentryUserId.utcYearMonth(nowMs: feb1970Ms - 1)) + XCTAssertEqual("1970-02", SentryUserId.utcYearMonth(nowMs: feb1970Ms)) + XCTAssertEqual("2026-07", SentryUserId.utcYearMonth(nowMs: july2026Ms)) + } +} diff --git a/src/ComapeoCoreModule.ts b/src/ComapeoCoreModule.ts index a5665b24..2e376610 100644 --- a/src/ComapeoCoreModule.ts +++ b/src/ComapeoCoreModule.ts @@ -22,6 +22,7 @@ import * as Sentry from "@sentry/react-native"; // the import is safe. import { getTraceData, startNewTrace } from "@sentry/core"; import type { SentryInitConfig } from "./sentry"; +import { rpcClientMetric, rpcStatusFor } from "./sentry-metrics"; // `onRequestHook` request type derived from `createComapeoCoreClient` so // any hook-signature change up-stream is a compile error here. The @@ -37,13 +38,15 @@ type IpcRequestWithMetadata = IpcHookRequest & { /** * User-persisted sentry preferences (snapshot at module construction). - * Diagnostics on by default; capture-app-data off by default. Plugin - * `diagnosticsEnabledDefault` / `captureApplicationDataDefault` change - * the fresh-install defaults but not the user's saved choice. + * Diagnostics on by default; application-usage-data and debug off by + * default. Plugin `diagnosticsEnabledDefault` / + * `applicationUsageDataDefault` / `debugDefault` change the + * fresh-install defaults but not the user's saved choice. */ export type SentryPreferences = { diagnosticsEnabled: boolean; - captureApplicationData: boolean; + applicationUsageData: boolean; + debug: boolean; }; declare class ComapeoCoreModule extends NativeModule { @@ -56,11 +59,23 @@ declare class ComapeoCoreModule extends NativeModule { */ readonly sentryConfig: SentryInitConfig; /** - * User-persisted preferences, read at module construction. - * Snapshot-at-boot — `setDiagnosticsEnabled` / `setCaptureApplicationData` - * writes only take effect on the next launch. + * User-persisted preferences as read at native module construction — + * the **launch snapshot** that governs this session's Sentry behaviour + * (whether `initSentry` emits, the metrics usage tier, debug tracing). + * Immutable this session; `setX` writes only take effect on the next + * launch. For the current saved value (e.g. a settings screen reading + * back a just-made toggle) use `getCurrentSentryPreferences()`. */ - readonly sentryPreferences: SentryPreferences; + readonly sentryPreferencesAtLaunch: SentryPreferences; + /** + * Live read of the current persisted preferences — reflects a `setX` + * made this session and survives a JS reload (unlike the + * `sentryPreferencesAtLaunch` snapshot). `debug` is the raw stored value, + * without the launch-time 72h auto-off side effect. Backs the public + * `getDiagnosticsEnabled` / `getApplicationUsageData` / `getDebugEnabled` + * getters. + */ + getCurrentSentryPreferences(): SentryPreferences; /** * Persist `diagnosticsEnabled` and (on a transition to false) wipe * the on-disk Sentry envelope cache so queued events from the @@ -70,11 +85,23 @@ declare class ComapeoCoreModule extends NativeModule { setDiagnosticsEnabled(value: boolean): Promise; /** * Same shape as `setDiagnosticsEnabled` but for the - * `captureApplicationData` toggle. Outbox wipe on false is full + * `applicationUsageData` toggle. Outbox wipe on false is full * (not just trace envelopes) — selective wipe would be a lot of * code for the same effect when an outbox is mixed. */ - setCaptureApplicationData(value: boolean): Promise; + setApplicationUsageData(value: boolean): Promise; + /** + * Persist the `debug` toggle and (on a transition to true) stamp the + * enable time so the 72h auto-off can fire on a later launch. + * Restart-to-activate. + */ + setDebugEnabled(value: boolean): Promise; + /** + * The permanent per-install root user ID (lazily generated on first + * read). Never sent to Sentry — the Sentry `user.id` is a hash derived + * from it natively. For debug/about screens so a user can share it. + */ + getSentryRootUserId(): string; /** * Current notification-permission status without prompting. On Android * < 13 (API 33) and on iOS this resolves as `granted` (no-op). @@ -106,19 +133,48 @@ export function readSentryConfig(): SentryInitConfig { } /** - * User-persisted sentry preferences. Snapshot-at-boot: the values are - * read at native module construction, so `setDiagnosticsEnabled` / - * `setCaptureApplicationData` writes only take effect on the next - * launch. Falls back to safe defaults (diagnostics on, capture-app- - * data off) when the native module isn't available (test contexts). + * The launch snapshot of the sentry preferences: read once at native module + * construction, so `setDiagnosticsEnabled` / `setApplicationUsageData` / + * `setDebugEnabled` writes only take effect on the next launch. This is what + * the module's own session behaviour (initSentry, metrics tier, debug tracing) + * is pinned to. Falls back to safe defaults (diagnostics on, usage off, debug + * off) when the native module isn't available (test contexts). */ -export function readSentryPreferences(): SentryPreferences { - return ( - nativeModule.sentryPreferences ?? { +export function readSentryPreferencesAtLaunch(): SentryPreferences { + const raw = nativeModule.sentryPreferencesAtLaunch as + | SentryPreferences + | undefined; + if (!raw) { + return { diagnosticsEnabled: true, - captureApplicationData: false, - } - ); + applicationUsageData: false, + debug: false, + }; + } + return normalizePreferences(raw); +} + +/** + * The current saved preferences — reflects a `setX` made this session and + * survives a JS reload, so a settings screen can read back the user's choice + * without maintaining its own copy. Distinct from + * [readSentryPreferencesAtLaunch] (the launch snapshot the session is pinned + * to); this is the current on-disk value. Falls back to the snapshot when the + * native live getter isn't available (older native / test contexts). + */ +export function readCurrentSentryPreferences(): SentryPreferences { + const live = nativeModule.getCurrentSentryPreferences?.() as + | SentryPreferences + | undefined; + return live ? normalizePreferences(live) : readSentryPreferencesAtLaunch(); +} + +function normalizePreferences(raw: SentryPreferences): SentryPreferences { + return { + diagnosticsEnabled: raw.diagnosticsEnabled, + applicationUsageData: raw.applicationUsageData ?? false, + debug: raw.debug ?? false, + }; } /** Persist `diagnosticsEnabled`. See `setDiagnosticsEnabled` JSDoc. */ @@ -126,9 +182,22 @@ export function setDiagnosticsEnabledNative(value: boolean): Promise { return nativeModule.setDiagnosticsEnabled(value); } -/** Persist `captureApplicationData`. See `setCaptureApplicationData` JSDoc. */ -export function setCaptureApplicationDataNative(value: boolean): Promise { - return nativeModule.setCaptureApplicationData(value); +/** Persist `applicationUsageData`. See `setApplicationUsageData` JSDoc. */ +export function setApplicationUsageDataNative(value: boolean): Promise { + return nativeModule.setApplicationUsageData(value); +} + +/** Persist `debug`. See `setDebugEnabled` JSDoc. */ +export function setDebugEnabledNative(value: boolean): Promise { + return nativeModule.setDebugEnabled(value); +} + +/** + * The permanent root user ID from native prefs. Empty string when the + * native module isn't available (test contexts). + */ +export function readRootUserIdNative(): string { + return nativeModule.getSentryRootUserId?.() ?? ""; } const GRANTED_PERMISSION: NotificationPermissionResponse = { @@ -267,26 +336,54 @@ function hasInheritableActiveSpan(): boolean { return rootOp !== "ui.load" && !rootOp.startsWith("app.start."); } +/** + * `true` when per-RPC tracing is active — `diagnosticsEnabled && debug` + * with the SDK actually initialised. Read once at module construction + * (snapshot-at-boot, like the rest of the preferences) so a per-call + * branch stays cheap. + */ +const debugTracingEnabled = (() => { + const prefs = readSentryPreferencesAtLaunch(); + return prefs.diagnosticsEnabled && prefs.debug; +})(); + export const comapeo: ComapeoCoreClientApi = createComapeoCoreClient(messagePort, { timeout: RPC_TIMEOUT_MS, onRequestHook: (request, next) => { // Sentry-not-initialised guard. `isInitialized` lives in `@sentry/core` // and is reachable through the namespace at runtime but isn't on the // public type surface — defensive accessor in case the helper isn't - // wired through in older SDK releases. Don't gate on `getActiveSpan`: - // it's undefined whenever no transaction is in progress (e.g. after - // App Start ends), which is exactly when we still want to create the - // span and propagate the trace to the backend. + // wired through in older SDK releases. const isInitialized = ( Sentry as unknown as { isInitialized?: () => boolean; } ).isInitialized; - if (typeof isInitialized === "function" && !isInitialized()) { - next(request).catch(noop); + const sentryUp = + typeof isInitialized !== "function" || isInitialized(); + const method = request.method.join("."); + + // Metrics/tracing only — the hook never captures exceptions. An RPC + // rejection is often expected control flow (e.g. NotFound) that the + // caller may not want reported; deciding what's report-worthy is the + // caller's job, at the call site. The metric layer no-ops when Sentry is + // off. Per-RPC traces (below) only run under `debug`. + const recordMetric = (start: number, status: string) => { + rpcClientMetric(method, status, performance.now() - start); + }; + + if (!sentryUp || !debugTracingEnabled) { + const start = performance.now(); + const responsePromise = next(request); + responsePromise + .then( + () => recordMetric(start, "ok"), + (error: unknown) => recordMetric(start, rpcStatusFor(error)), + ) + .catch(noop); return; } - const method = request.method.join("."); + const runSpan = () => Sentry.startSpan( { @@ -311,6 +408,10 @@ export const comapeo: ComapeoCoreClientApi = createComapeoCoreClient(messagePort }, } : request; + // Record the metric while the span is active so it links to the + // trace. Duration is measured around the same round-trip the + // span brackets. + const start = performance.now(); try { // Split the span duration into "sync send" (JSI hop + UDS write // to Node) and "await" (entire round-trip incl. response delivery @@ -319,15 +420,16 @@ export const comapeo: ComapeoCoreClientApi = createComapeoCoreClient(messagePort // cold boot, `rn.send.syncMs` stays small while total stays high. const sendStart = performance.now(); const responsePromise = next(tracedRequest); - span.setAttribute?.( - "rn.send.syncMs", - performance.now() - sendStart, - ); + const sendMs = performance.now() - sendStart; + span.setAttribute?.("rn.send.syncMs", sendMs); await responsePromise; span.setStatus?.({ code: 1, message: "ok" }); + recordMetric(start, "ok"); } catch (error) { + // Mark the span errored for tracing, but do not capture an issue + // — see the metrics-only note on the non-debug path above. span.setStatus?.({ code: 2, message: "internal_error" }); - Sentry.captureException(error); + recordMetric(start, rpcStatusFor(error)); } }, ); diff --git a/src/__tests__/notification-permissions.test.js b/src/__tests__/notification-permissions.test.js index 9f34a931..759f642f 100644 --- a/src/__tests__/notification-permissions.test.js +++ b/src/__tests__/notification-permissions.test.js @@ -47,6 +47,13 @@ describe("notification permission wrappers", () => { getTraceData: jest.fn(() => ({})), startNewTrace: jest.fn(), })); + // The metrics layer pulls `./sentry` (→ `react-native`) and forms an + // eager import cycle back into this module. The notification wrappers + // don't touch it, so stub it out like the other transitive deps. + jest.doMock("../sentry-metrics", () => ({ + rpcClientMetric: jest.fn(), + rpcStatusFor: jest.fn(() => "ok"), + })); return require("../ComapeoCoreModule"); } diff --git a/src/__tests__/rpc-client-hook.test.js b/src/__tests__/rpc-client-hook.test.js new file mode 100644 index 00000000..a6d74dc1 --- /dev/null +++ b/src/__tests__/rpc-client-hook.test.js @@ -0,0 +1,143 @@ +/** + * RN-side onRequestHook split, mirroring backend's + * `sentry.test.mjs` debug-on/debug-off cases. The metric is recorded on + * EVERY call; the per-RPC span only runs under `diagnosticsEnabled && + * debug` with Sentry initialised. + * + * `debugTracingEnabled` and the `createComapeoCoreClient` call are both + * evaluated at module construction, so each case uses `jest.resetModules` + * + per-case `jest.doMock` to capture a fresh hook. + */ + +function flushMicrotasks() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function setup({ debug, diagnosticsEnabled = true, sentryInitialized = true }) { + let capturedHook; + const startSpan = jest.fn((_opts, cb) => + cb({ setAttribute: jest.fn(), setStatus: jest.fn() }), + ); + const rpcClientMetric = jest.fn(); + const rpcStatusFor = jest.fn((error) => (error ? "error" : "ok")); + const captureException = jest.fn(); + + jest.resetModules(); + + jest.doMock("expo", () => { + class NativeModule {} + class EventEmitter { + addListener() {} + removeListener() {} + emit() {} + } + return { + NativeModule, + EventEmitter, + requireNativeModule: () => ({ + sentryConfig: {}, + sentryPreferencesAtLaunch: { + diagnosticsEnabled, + applicationUsageData: false, + debug, + }, + postMessage: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + }), + }; + }); + + jest.doMock("@comapeo/ipc/client.js", () => ({ + createComapeoCoreClient: (_port, opts) => { + capturedHook = opts.onRequestHook; + return {}; + }, + createComapeoServicesClient: () => ({}), + })); + + jest.doMock("@sentry/react-native", () => ({ + isInitialized: () => sentryInitialized, + getActiveSpan: () => null, + startSpan, + captureException, + })); + + jest.doMock("@sentry/core", () => ({ + getTraceData: () => ({}), + startNewTrace: (cb) => cb(), + })); + + jest.doMock("../sentry-metrics", () => ({ + rpcClientMetric, + rpcStatusFor, + })); + + require("../ComapeoCoreModule"); + return { + capturedHook: () => capturedHook, + startSpan, + rpcClientMetric, + rpcStatusFor, + captureException, + }; +} + +describe("onRequestHook", () => { + test("debug=false: records the metric, never starts a span", async () => { + const { capturedHook, startSpan, rpcClientMetric } = setup({ debug: false }); + const next = jest.fn(() => Promise.resolve("response")); + capturedHook()({ method: ["someMethod"] }, next); + await flushMicrotasks(); + + expect(next).toHaveBeenCalledTimes(1); + expect(startSpan).not.toHaveBeenCalled(); + expect(rpcClientMetric).toHaveBeenCalledTimes(1); + expect(rpcClientMetric.mock.calls[0][0]).toBe("someMethod"); + expect(rpcClientMetric.mock.calls[0][1]).toBe("ok"); + }); + + test("debug=true + Sentry up: starts a span AND records the metric", async () => { + const { capturedHook, startSpan, rpcClientMetric } = setup({ debug: true }); + const next = jest.fn(() => Promise.resolve("response")); + capturedHook()({ method: ["someMethod"] }, next); + await flushMicrotasks(); + + expect(startSpan).toHaveBeenCalledTimes(1); + expect(rpcClientMetric).toHaveBeenCalledTimes(1); + expect(rpcClientMetric.mock.calls[0][0]).toBe("someMethod"); + }); + + test("error path passes rpcStatusFor(error) through to the metric", async () => { + const { capturedHook, rpcClientMetric, rpcStatusFor } = setup({ debug: false }); + const err = new Error("boom"); + const next = jest.fn(() => Promise.reject(err)); + capturedHook()({ method: ["someMethod"] }, next); + await flushMicrotasks(); + + expect(rpcStatusFor).toHaveBeenCalledWith(err); + expect(rpcClientMetric.mock.calls[0][1]).toBe("error"); + }); + + test("debug=false: a rejecting RPC records the error metric but is never captured", async () => { + const { capturedHook, captureException, rpcClientMetric } = setup({ debug: false }); + const next = jest.fn(() => Promise.reject(new Error("boom"))); + capturedHook()({ method: ["someMethod"] }, next); + await flushMicrotasks(); + + expect(rpcClientMetric).toHaveBeenCalledTimes(1); + expect(rpcClientMetric.mock.calls[0][1]).toBe("error"); + // Capture is the caller's decision, not the hook's. + expect(captureException).not.toHaveBeenCalled(); + }); + + test("debug=true: a rejecting RPC marks the span errored but is never captured", async () => { + const { capturedHook, captureException, startSpan } = setup({ debug: true }); + const next = jest.fn(() => Promise.reject(new Error("boom"))); + capturedHook()({ method: ["someMethod"] }, next); + await flushMicrotasks(); + + expect(startSpan).toHaveBeenCalledTimes(1); + expect(captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/sentry-metrics.test.js b/src/__tests__/sentry-metrics.test.js new file mode 100644 index 00000000..97321fdb --- /dev/null +++ b/src/__tests__/sentry-metrics.test.js @@ -0,0 +1,124 @@ +/** + * RN-side metrics layer (`src/sentry-metrics.ts`) + * — the mirror of the tested backend `metrics.js`. A fake + * `Sentry.metrics` records every emission so we can assert on the shared + * `platform` injection, the single-metric device tags + usage-gated + * `method` attribute, the forbidden-tag filter (exercising the REAL + * `isForbiddenMetric`), and the off-switch. + * + * Plain JS so expo-module-scripts' babel-jest picks it up. Per-test + * module reset because the layer reads `Platform.OS` and `sentryConfig` + * at module-construction time. + */ + +describe("sentry-metrics", () => { + let calls; + let isInitializedFlag; + let prefs; + + beforeEach(() => { + calls = []; + isInitializedFlag = true; + prefs = { diagnosticsEnabled: true, applicationUsageData: false }; + + jest.resetModules(); + + const recordCall = (name, value, data) => + calls.push({ name, value, ...(data?.attributes ? data.attributes : {}) }); + + jest.doMock("react-native", () => ({ Platform: { OS: "android" } })); + + jest.doMock("@sentry/react-native", () => ({ + isInitialized: () => isInitializedFlag, + metrics: { + distribution: recordCall, + count: recordCall, + gauge: recordCall, + }, + })); + + jest.doMock("../ComapeoCoreModule", () => ({ + readSentryPreferencesAtLaunch: () => prefs, + readSentryConfig: () => ({ + deviceTags: { deviceClass: "mid", osMajor: "android.14" }, + }), + })); + }); + + test("rpcClientMetric: usage on → one metric with method + status + device tags", () => { + prefs.applicationUsageData = true; + const { rpcClientMetric } = require("../sentry-metrics"); + rpcClientMetric("read.doc", "ok", 42); + + // One high-cardinality metric — no primary/mirror split. Slice by method + // OR device_class (or both) with group-by at query time. + expect(calls).toHaveLength(1); + const [m] = calls; + expect(m.name).toBe("comapeo.rpc.client.duration_ms"); + expect(m.platform).toBe("android"); + expect(m.status).toBe("ok"); + expect(m.method).toBe("read.doc"); + expect(m.device_class).toBe("mid"); + expect(m.os_major).toBe("android.14"); + }); + + test("rpcClientMetric: usage off → same metric, method dropped (privacy gate)", () => { + const { rpcClientMetric } = require("../sentry-metrics"); + rpcClientMetric("read.doc", "ok", 42); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("comapeo.rpc.client.duration_ms"); + expect(calls[0].method).toBeUndefined(); + expect(calls[0].status).toBe("ok"); + expect(calls[0].device_class).toBe("mid"); + expect(calls[0].os_major).toBe("android.14"); + }); + + test("platform is injected on every primitive", () => { + const { __metricsInternals } = require("../sentry-metrics"); + __metricsInternals.count("comapeo.x", { a: "1" }); + __metricsInternals.distribution("comapeo.y", 1, "millisecond", { b: "2" }); + __metricsInternals.gauge("comapeo.z", 1, "byte", { c: "3" }); + expect(calls).toHaveLength(3); + for (const call of calls) expect(call.platform).toBe("android"); + }); + + test("forbidden tag VALUE and NAME are dropped", () => { + const { __metricsInternals } = require("../sentry-metrics"); + __metricsInternals.count("comapeo.x", { coord: "lat=12.34" }); // forbidden value shape + expect(calls).toHaveLength(0); + __metricsInternals.count("comapeo.x", { project_id: "p" }); // forbidden name + expect(calls).toHaveLength(0); + __metricsInternals.count("comapeo.x", { method: "read.doc" }); // allowed + expect(calls).toHaveLength(1); + }); + + // The broad base64-22 value rule is disabled pending a narrower design + // (see sentry-scrub.ts), so a bare token tag no longer drops the metric. + test("bare base64 tag values pass through while the broad rule is disabled", () => { + const { __metricsInternals } = require("../sentry-metrics"); + __metricsInternals.count("comapeo.x", { bucket: "bm90LWEtcmVhbC1rZXktMQ" }); + expect(calls).toHaveLength(1); + }); + + test("all helpers no-op when Sentry is not initialized", () => { + isInitializedFlag = false; + const { rpcClientMetric, __metricsInternals } = require("../sentry-metrics"); + rpcClientMetric("read.doc", "ok", 1); + __metricsInternals.count("comapeo.x", { method: "read.doc" }); + expect(calls).toHaveLength(0); + }); + + test("rpcStatusFor classifies failures (never ok, even for a falsy reason)", () => { + const { rpcStatusFor } = require("../sentry-metrics"); + // Only the reject path calls this; the success path records "ok" directly. + expect(rpcStatusFor(Object.assign(new Error("x"), { name: "TimeoutError" }))).toBe( + "timeout", + ); + expect(rpcStatusFor(new Error("boom"))).toBe("error"); + // A falsy rejection reason is still a failure, not a silent "ok". + expect(rpcStatusFor(null)).toBe("error"); + expect(rpcStatusFor(0)).toBe("error"); + expect(rpcStatusFor("")).toBe("error"); + }); +}); diff --git a/src/__tests__/sentry-scrub.test.js b/src/__tests__/sentry-scrub.test.js new file mode 100644 index 00000000..6962a9e3 --- /dev/null +++ b/src/__tests__/sentry-scrub.test.js @@ -0,0 +1,113 @@ +/** + * RN-side scrubber + forbidden-metric filter. + * Symmetric with the Node-side `backend/before-send.js` — keep both in + * sync. The data-driven cases come from the shared + * `test-support/scrubber-cases.js` table, run against both copies so the + * two regex lists can't drift. Plain JS so expo-module-scripts' + * babel-jest picks it up. + */ + +const { + scrubString, + scrubUrlToHost, + scrubEvent, + scrubBreadcrumb, + scrubLog, + isForbiddenMetric, +} = require("../sentry-scrub"); + +const { + scrubStringCases, + scrubUrlToHostCases, + forbiddenMetricCases, +} = require("../../test-support/scrubber-cases"); + +describe("shared scrubber cases (mirror of backend/before-send.js)", () => { + test.each(scrubStringCases)("scrubString: $name", ({ input, expect: want }) => { + expect(scrubString(input)).toBe(want); + }); + + test.each(scrubUrlToHostCases)("scrubUrlToHost: $name", ({ input, expect: want }) => { + expect(scrubUrlToHost(input)).toBe(want); + }); + + test.each(forbiddenMetricCases)( + "isForbiddenMetric: $name", + ({ metricName, attributes, expect: want }) => { + expect(isForbiddenMetric(metricName, attributes)).toBe(want); + }, + ); +}); + +describe("scrubEvent", () => { + test("redacts numeric lat/lng stored as object fields", () => { + const event = { + extra: { coords: { latitude: 12.3456, longitude: -56.78 } }, + contexts: { geo: { lat: 1.0, lng: 2.0 } }, + }; + scrubEvent(event); + expect(event.extra.coords.latitude).toBe("[redacted]"); + expect(event.extra.coords.longitude).toBe("[redacted]"); + expect(event.contexts.geo.lat).toBe("[redacted]"); + expect(event.contexts.geo.lng).toBe("[redacted]"); + }); + + test("reduces breadcrumb HTTP URLs to host-only", () => { + const event = { + breadcrumbs: [ + { + category: "http", + data: { url: "https://cloud.comapeo.app/projects/abc?token=x" }, + }, + ], + }; + scrubEvent(event); + expect(event.breadcrumbs[0].data.url).toBe("https://cloud.comapeo.app"); + }); + + test("reduces request.url to host-only and scrubs marked query/headers", () => { + const event = { + request: { + url: "https://cloud.comapeo.app/projects/abc?token=x", + query_string: "rootKey=supersecretvalue", + headers: { "x-secret": "root_key: supersecretvalue" }, + }, + }; + scrubEvent(event); + expect(event.request.url).toBe("https://cloud.comapeo.app"); + expect(event.request.query_string).toMatch(/\[redacted\]/); + expect(event.request.headers["x-secret"]).toMatch(/\[redacted\]/); + }); +}); + +describe("scrubBreadcrumb", () => { + test("reduces http URL to host only", () => { + const crumb = { + category: "http", + data: { url: "https://tiles.example.com/v1/12/34?key=abc" }, + }; + scrubBreadcrumb(crumb); + expect(crumb.data.url).toBe("https://tiles.example.com"); + }); + + test("does not stack-overflow on circular breadcrumb data", () => { + const data = { a: 1 }; + data.self = data; // cycle + const crumb = { category: "custom", data }; + expect(() => scrubBreadcrumb(crumb)).not.toThrow(); + expect(crumb.data.self).toBe("[Circular]"); + }); +}); + +describe("scrubLog", () => { + test("scrubs the message and attributes of a structured log", () => { + const log = { + message: "rootKey=supersecretvalue", + attributes: { note: "latitude: 12.3", ok: "fine" }, + }; + scrubLog(log); + expect(log.message).toMatch(/\[redacted\]/); + expect(log.attributes.note).toBe("[redacted]"); + expect(log.attributes.ok).toBe("fine"); + }); +}); diff --git a/src/__tests__/sentry.test.js b/src/__tests__/sentry.test.js index 77dc0b8d..be0c269c 100644 --- a/src/__tests__/sentry.test.js +++ b/src/__tests__/sentry.test.js @@ -21,21 +21,35 @@ const { SENTRY_OWNED_GLOBAL_KEY } = require("../sentry-tags"); describe("initSentry", () => { let preferences; + let livePreferences; let configDsn; + let configUserId; let isInitializedFlag; let initSpy; + let setUserSpy; let setTagSpy; let setContextSpy; let addEventProcessorSpy; + let setDiagnosticsEnabledNativeSpy; beforeEach(() => { - preferences = { diagnosticsEnabled: true, captureApplicationData: false }; + preferences = { + diagnosticsEnabled: true, + applicationUsageData: false, + debug: false, + }; + // The live view starts equal to the boot snapshot; tests mutate it to + // simulate a `setX` made after launch. + livePreferences = { ...preferences }; configDsn = "https://x@sentry.io/1"; + configUserId = undefined; isInitializedFlag = false; initSpy = jest.fn(); + setUserSpy = jest.fn(); setTagSpy = jest.fn(); setContextSpy = jest.fn(); addEventProcessorSpy = jest.fn(); + setDiagnosticsEnabledNativeSpy = jest.fn(() => Promise.resolve()); jest.resetModules(); @@ -56,10 +70,21 @@ describe("initSentry", () => { // plugin value and falls back to 0.1 would fail this test. tracesSampleRate: 0.5, enableLogs: true, + ...(configUserId ? { userId: configUserId } : {}), }), - readSentryPreferences: () => preferences, - setDiagnosticsEnabledNative: jest.fn(), - setCaptureApplicationDataNative: jest.fn(), + readSentryPreferencesAtLaunch: () => preferences, + readCurrentSentryPreferences: () => livePreferences, + readRootUserIdNative: () => "AB3D-EF9H-J2K3", + setDiagnosticsEnabledNative: setDiagnosticsEnabledNativeSpy, + setApplicationUsageDataNative: jest.fn(() => Promise.resolve()), + setDebugEnabledNative: jest.fn(() => Promise.resolve()), + })); + + // Stub the metrics layer so this unit test doesn't pull it in (and + // the circular import back to `./sentry`). + jest.doMock("../sentry-metrics", () => ({ + rpcClientMetric: jest.fn(), + rpcStatusFor: jest.fn(() => "ok"), })); const globalScope = { @@ -71,6 +96,7 @@ describe("initSentry", () => { jest.doMock("@sentry/react-native", () => ({ init: initSpy, isInitialized: () => isInitializedFlag, + setUser: setUserSpy, getGlobalScope: () => globalScope, captureException: jest.fn(), captureMessage: jest.fn(), @@ -128,16 +154,43 @@ describe("initSentry", () => { expect(opts.environment).toBe("test"); expect(opts.release).toBe("1.0+1"); expect(opts.sendDefaultPii).toBe(false); - // captureApplicationData=false → traces forced to 0 regardless - // of the plugin's configured 0.5. - expect(opts.tracesSampleRate).toBe(0); + // debug=false → the plugin-configured rate (0.5) applies. Native folds the + // debug window into the backend's rate; RN applies the same formula. + expect(opts.tracesSampleRate).toBe(0.5); expect(opts.enableLogs).toBe(true); }); - test("tracesSampleRate uses plugin value when captureApplicationData is on", () => { - preferences.captureApplicationData = true; + test("applies the native-derived user.id via Sentry.setUser", () => { + configUserId = "e15e7255ae360358"; + const { initSentry } = require("../sentry"); + initSentry(); + expect(setUserSpy).toHaveBeenCalledWith({ id: "e15e7255ae360358" }); + }); + + test("skips Sentry.setUser when native provided no userId", () => { + const { initSentry } = require("../sentry"); + initSentry(); + expect(setUserSpy).not.toHaveBeenCalled(); + }); + + test("getRootUserId returns the native root ID (never sent to Sentry)", () => { + const { getRootUserId } = require("../sentry"); + expect(getRootUserId()).toBe("AB3D-EF9H-J2K3"); + }); + + test("tracesSampleRate is 1.0 when debug is on, else the configured rate", () => { + preferences.debug = true; + const { initSentry } = require("../sentry"); + initSentry(); + expect(initSpy.mock.calls[0][0].tracesSampleRate).toBe(1.0); + }); + + test("applicationUsageData does NOT drive tracesSampleRate (debug + config do)", () => { + preferences.applicationUsageData = true; + preferences.debug = false; const { initSentry } = require("../sentry"); initSentry(); + // Usage tier is irrelevant to tracing; the non-debug rate is the config (0.5). expect(initSpy.mock.calls[0][0].tracesSampleRate).toBe(0.5); }); @@ -220,9 +273,7 @@ describe("initSentry", () => { }); test("beforeSend chains: our scrubber runs first, host's second", () => { - // Our scrubber is currently identity (PII implementation lands - // in Phase 9b) — but the chain order itself is load-bearing: - // the host's hook must see only post-scrub payloads, never raw + // The host's hook must see only post-scrub payloads, never raw // ones. Test by passing a host hook that observes the input. const hostBeforeSend = jest.fn((event) => ({ ...event, @@ -236,6 +287,47 @@ describe("initSentry", () => { expect(result).toEqual({ original: true, hostMarker: true }); }); + test("beforeSend scrubber redacts lat/lng and rootKey markers before the host sees them", () => { + const seen = []; + const hostBeforeSend = jest.fn((event) => { + seen.push(JSON.stringify(event)); + return event; + }); + const { initSentry } = require("../sentry"); + initSentry({ beforeSend: hostBeforeSend }); + const chain = initSpy.mock.calls[0][0].beforeSend; + chain( + { + message: "latitude: -12.34", + exception: { + values: [{ type: "Error", value: "rootKey=aGVsbG8td29ybGQtMTIzNA" }], + }, + extra: { token: "bm90LWEtcmVhbC1rZXktMQ" }, + }, + undefined, + ); + const payload = seen[0]; + expect(payload).toContain("[redacted]"); + expect(payload).not.toContain("aGVsbG8td29ybGQtMTIzNA"); // rootKey value gone + expect(payload).not.toContain("-12.34"); // lat/lng gone + // Broad base64-22 rule disabled: a bare token in `extra` currently survives. + expect(payload).toContain("bm90LWEtcmVhbC1rZXktMQ"); + }); + + test("beforeBreadcrumb reduces HTTP URLs to host-only", () => { + const { initSentry } = require("../sentry"); + initSentry(); + const beforeBreadcrumb = initSpy.mock.calls[0][0].beforeBreadcrumb; + const result = beforeBreadcrumb( + { + category: "http", + data: { url: "https://cloud.comapeo.app/projects/abc?token=x" }, + }, + undefined, + ); + expect(result.data.url).toBe("https://cloud.comapeo.app"); + }); + test("beforeSend drops the event when host returns null", () => { const hostBeforeSend = jest.fn(() => null); const { initSentry } = require("../sentry"); @@ -273,4 +365,52 @@ describe("initSentry", () => { ]), ); }); + + test("toggle getters read the live value, not the boot snapshot", () => { + const { + getDiagnosticsEnabled, + getApplicationUsageData, + getDebugEnabled, + } = require("../sentry"); + // Simulate a setX made after launch: the live view diverges from the + // boot snapshot. The getters must reflect the live view so a settings + // screen reads back the user's just-made choice. + livePreferences.diagnosticsEnabled = false; + livePreferences.applicationUsageData = true; + livePreferences.debug = true; + expect(getDiagnosticsEnabled()).toBe(false); + expect(getApplicationUsageData()).toBe(true); + expect(getDebugEnabled()).toBe(true); + // The boot snapshot (what governs this session) is untouched. + expect(preferences.diagnosticsEnabled).toBe(true); + }); + + test("setter updates the live view only after the native write resolves", async () => { + const { setDiagnosticsEnabled, getDiagnosticsEnabled } = + require("../sentry"); + let resolveNative; + setDiagnosticsEnabledNativeSpy.mockImplementation( + () => new Promise((r) => { resolveNative = r; }), + ); + const pending = setDiagnosticsEnabled(false); + // Not yet persisted — the getter must still show the old value. + expect(getDiagnosticsEnabled()).toBe(true); + resolveNative(); + await pending; + expect(getDiagnosticsEnabled()).toBe(false); + }); + + test("setter leaves the live view unchanged when the native write rejects", async () => { + const { setDiagnosticsEnabled, getDiagnosticsEnabled } = + require("../sentry"); + setDiagnosticsEnabledNativeSpy.mockImplementation(() => + Promise.reject(new Error("native context not attached")), + ); + await expect(setDiagnosticsEnabled(false)).rejects.toThrow( + "native context not attached", + ); + // The failed opt-out must not be reported as done — the on-disk + // value is still true, so the getter must agree. + expect(getDiagnosticsEnabled()).toBe(true); + }); }); diff --git a/src/sentry-metrics.ts b/src/sentry-metrics.ts new file mode 100644 index 00000000..fb204120 --- /dev/null +++ b/src/sentry-metrics.ts @@ -0,0 +1,154 @@ +/** + * RN-side Sentry metrics layer. + * + * Thin wrappers around `Sentry.metrics.*` that: + * - inject the shared `platform` attribute on every metric so a call + * site can never forget it; + * - attach the low-cardinality `device_class` / `os_major` tags to the + * duration metrics (Application Metrics bills by volume, not + * cardinality, so one metric with all the attributes you'd slice by + * beats a primary + `.by_device` split — group-by slices at query + * time). `method` stays usage-gated for privacy, not cardinality; + * - no-op entirely when Sentry is off; + * - run a defensive `beforeSendMetric` filter that drops any emission + * carrying a forbidden attribute. + */ +import { Platform } from "react-native"; +import * as Sentry from "@sentry/react-native"; + +import { + readSentryConfig, + readSentryPreferencesAtLaunch, +} from "./ComapeoCoreModule"; +import type { SentryDeviceTags } from "./sentry"; +import { isForbiddenMetric } from "./sentry-scrub"; + +type MetricAttributes = Record; + +const platformTag = Platform.OS; + +// Device tags + usage tier are snapshot-at-boot (native Constants that can't +// change in-process), memoised lazily on first metric so nothing reads the +// native module at import time — this file is imported by ComapeoCoreModule +// before its `nativeModule` binding is initialised. +let deviceTagsSnapshot: SentryDeviceTags | null | undefined; +let usageDataSnapshot: boolean | undefined; + +/** + * Opt-in tier for usage-revealing dimensions (the RPC `method` breakdown). + * Snapshot-at-boot; restart-to-activate. + */ +function usageDataEnabled(): boolean { + return (usageDataSnapshot ??= + readSentryPreferencesAtLaunch().applicationUsageData); +} + +function deviceTags(): { device_class: string; os_major: string } { + if (deviceTagsSnapshot === undefined) { + deviceTagsSnapshot = readSentryConfig().deviceTags ?? null; + } + const tags = deviceTagsSnapshot; + return { + device_class: tags?.deviceClass ?? "unknown", + os_major: tags?.osMajor ?? `${platformTag}.0`, + }; +} + +function sentryUp(): boolean { + const isInitialized = ( + Sentry as unknown as { isInitialized?: () => boolean } + ).isInitialized; + return typeof isInitialized !== "function" || isInitialized(); +} + +const metricsApi = (): { + distribution?: ( + name: string, + value: number, + data?: { unit?: string; attributes?: MetricAttributes }, + ) => void; + count?: ( + name: string, + value: number, + data?: { attributes?: MetricAttributes }, + ) => void; + gauge?: ( + name: string, + value: number, + data?: { unit?: string; attributes?: MetricAttributes }, + ) => void; +} | null => { + const api = (Sentry as unknown as { metrics?: unknown }).metrics; + return (api as ReturnType) ?? null; +}; + +function withPlatform(attributes: MetricAttributes): MetricAttributes { + return { platform: platformTag, ...attributes }; +} + +/** No-op when Sentry is off or the metric trips the forbidden filter. */ +function distribution( + name: string, + value: number, + unit: string, + attributes: MetricAttributes, +): void { + if (!sentryUp()) return; + const attrs = withPlatform(attributes); + if (isForbiddenMetric(name, attrs)) return; + metricsApi()?.distribution?.(name, value, { unit, attributes: attrs }); +} + +function count(name: string, attributes: MetricAttributes): void { + if (!sentryUp()) return; + const attrs = withPlatform(attributes); + if (isForbiddenMetric(name, attrs)) return; + metricsApi()?.count?.(name, 1, { attributes: attrs }); +} + +function gauge( + name: string, + value: number, + unit: string, + attributes: MetricAttributes, +): void { + if (!sentryUp()) return; + const attrs = withPlatform(attributes); + if (isForbiddenMetric(name, attrs)) return; + metricsApi()?.gauge?.(name, value, { unit, attributes: attrs }); +} + +/** + * Classify an RPC *failure* into the bounded `status` tag (`error` / + * `timeout`) — a timeout is distinguished by name so the dashboard can + * separate "slow path" from "failed path". Only call this on the reject + * path; the success path records `"ok"` directly. A falsy rejection + * reason still counts as a failure (never `ok`). + */ +export function rpcStatusFor(error: unknown): string { + const name = + error instanceof Error ? error.name : String((error as { name?: string })?.name); + if (/timeout/i.test(name)) return "timeout"; + return "error"; +} + +/** + * Record an RPC client call — end-to-end client-observed latency. One + * distribution carrying `status` + device tags, plus `method` when the usage + * tier is on. Sentry Application Metrics bills by volume, not cardinality, so a + * single high-cardinality series beats a primary + `.by_device` split: slice by + * `method` or `device_class` (or both) with group-by at query time. `method` + * stays usage-gated for privacy, not cardinality. + */ +export function rpcClientMetric( + method: string, + status: string, + ms: number, +): void { + const attrs: MetricAttributes = { status, ...deviceTags() }; + if (usageDataEnabled()) attrs.method = method; + distribution("comapeo.rpc.client.duration_ms", ms, "millisecond", attrs); +} + +/** Exposed for tests + the gauge/count primitives if a host needs them. */ +export const __metricsInternals = { distribution, count, gauge }; diff --git a/src/sentry-scrub.ts b/src/sentry-scrub.ts new file mode 100644 index 00000000..86f6a4f4 --- /dev/null +++ b/src/sentry-scrub.ts @@ -0,0 +1,267 @@ +/** + * Shared PII scrubber and forbidden-metric filter + * for the RN side. The Node side keeps a hand-mirrored copy in + * `backend/before-send.js` (the two run in different module systems — + * ESM-via-rollup vs the RN bundle — so a build-time copy isn't + * practical). Keep the two regex lists in lock-step; each file points + * at the other. + * + * Scrubbing is defence-in-depth: the real fix is to never capture + * sensitive data at the call site. This net catches a malicious or + * buggy host (and our own mistakes) before a payload leaves the device. + * + * What it redacts (and the false-positive trade-off): + * - Explicit `rootKey=…` markers (key=value / json / prose). A broad + * "any 22+-char base64url run" rule is deliberately NOT enabled — see + * the SCRUB_PATTERNS note below — so bare rootKeys/keys/project-ids + * with no marker are currently unscrubbed. + * - Object fields whose KEY is lat/lng/latitude/longitude are redacted + * regardless of value type — a numeric `{latitude: 12.3}` is the most + * likely capture shape and value-only scrubbing would miss it. + * - `lat=`/`lng=`/`latitude:`/`longitude:` markers redact the numeric + * value that follows. A sentence like "latitude: unknown" is + * redacted to "latitude: [redacted]" — harmless over-redaction. + * - HTTP breadcrumb URLs are reduced to host-only, dropping path + + * query: "https://cloud.comapeo.app/projects/abc?token=x" + * → "https://cloud.comapeo.app". We lose the path detail but keep + * "all requests to host X are failing" diagnosability. + */ + +const REDACTED = "[redacted]"; + +/** + * Patterns that, when matched anywhere in a string, get the match + * replaced with `[redacted]`. Global so every occurrence in a field is + * scrubbed. + */ +const SCRUB_PATTERNS: RegExp[] = [ + // Explicit rootKey markers (key=value, json, prose). The value stops at a + // field delimiter (whitespace, `,;&`, quote) so co-located fields in a + // compact string like `rootKey=abc,method=x` survive. + /\broot[_-]?key\b\s*["']?\s*[:=]\s*[^\s,;&"']+/gi, + // NOTE: a broad 22+-char URL-safe base64 rule (to catch bare rootKeys at + // 22, public keys at 43, project ids at ~52) is intentionally NOT enabled + // — it also matched Sentry's own 32-hex trace_ids, PascalCase exception + // type names, and error_class metric tags, redacting data we need. Pending + // a narrower design agreed with the team; bare tokens are unscrubbed until + // then. + // Latitude / longitude markers followed by a number. `lon` is the field + // name @comapeo/schema observations actually use. Optional quote between + // key and separator so JSON-serialized coordinates (`"lat":-12.3`) match. + /\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?/gi, +]; + +/** Object keys whose value is a raw coordinate — redacted regardless of type. */ +const SENSITIVE_KEY_PATTERN = /^(lat|lng|lon|latitude|longitude)$/i; + +/** Tag names/values that must never ride on a metric. */ +const FORBIDDEN_METRIC_TAG_NAMES = new Set([ + "device.model", + "device.id", + "device.manufacturer", + "os.version", + "screen.resolution", + "screen.density", + "screen.dpi", + "locale", + "timezone", + "project_id", + "peer_id", + "peer_count", + "rootkey", +]); + +/** Forbidden tag *values* — lat/lng shapes. (The broad base64-22 rule is + * held back here too; see the SCRUB_PATTERNS note above.) */ +const FORBIDDEN_METRIC_VALUE_PATTERNS: RegExp[] = [ + /\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?/i, +]; + +export function scrubString(input: string): string { + let out = input; + for (const pattern of SCRUB_PATTERNS) { + out = out.replace(pattern, REDACTED); + } + return out; +} + +/** Reduce an HTTP(S) URL to scheme + host, dropping path/query/fragment. */ +export function scrubUrlToHost(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + // Relative/opaque URL (no scheme/host) — new URL() throws. Drop the query + // + fragment (where tokens ride), then string-scrub what remains. A plain + // non-URL string has neither and passes through the string scrubber. + return scrubString(url.replace(/[?#].*$/s, "")); + } +} + +type Json = unknown; + +/** Max nesting depth walked before we stop (backstop against deep/hostile data). */ +const MAX_SCRUB_DEPTH = 20; + +function scrubValue(value: Json): Json { + return scrubValueInner(value, new WeakSet(), 0); +} + +function scrubValueInner( + value: Json, + seen: WeakSet, + depth: number, +): Json { + if (typeof value === "string") return scrubString(value); + if (value === null || typeof value !== "object") return value; + // Breadcrumb data is scrubbed at add-time, before Sentry normalises cycles, + // so guard against self-referential/over-deep data ourselves. + if (seen.has(value)) return "[Circular]"; + if (depth >= MAX_SCRUB_DEPTH) return "[Truncated]"; + seen.add(value); + let out: Json; + if (Array.isArray(value)) { + out = value.map((v) => scrubValueInner(v, seen, depth + 1)); + } else { + const record: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + record[k] = SENSITIVE_KEY_PATTERN.test(k) + ? REDACTED + : scrubValueInner(v, seen, depth + 1); + } + out = record; + } + seen.delete(value); + return out; +} + +type AnyRecord = Record; + +/** + * Walk every text field of a Sentry event and scrub it: + * message, exception values, extra, contexts, request, breadcrumb + * messages + data, span descriptions + attributes. HTTP breadcrumb and + * request URLs reduce to host-only. Mutates and returns the + * event. + */ +export function scrubEvent(event: AnyRecord): AnyRecord { + if (typeof event.message === "string") { + event.message = scrubString(event.message); + } + + const exception = event.exception as + | { values?: AnyRecord[] } + | undefined; + if (exception?.values) { + for (const ex of exception.values) { + if (typeof ex.value === "string") ex.value = scrubString(ex.value); + if (typeof ex.type === "string") ex.type = scrubString(ex.type); + } + } + + if (event.extra && typeof event.extra === "object") { + event.extra = scrubValue(event.extra) as AnyRecord; + } + if (event.contexts && typeof event.contexts === "object") { + event.contexts = scrubValue(event.contexts) as AnyRecord; + } + + if (event.request && typeof event.request === "object") { + const req = event.request as AnyRecord; + if (typeof req.url === "string") req.url = scrubUrlToHost(req.url); + if (req.query_string != null) req.query_string = scrubValue(req.query_string); + if (req.headers && typeof req.headers === "object") { + req.headers = scrubValue(req.headers); + } + if (req.cookies && typeof req.cookies === "object") { + req.cookies = scrubValue(req.cookies); + } + if (req.data != null) req.data = scrubValue(req.data); + } + + const breadcrumbs = event.breadcrumbs as AnyRecord[] | undefined; + if (Array.isArray(breadcrumbs)) { + for (const crumb of breadcrumbs) { + scrubBreadcrumb(crumb); + } + } + + const spans = event.spans as AnyRecord[] | undefined; + if (Array.isArray(spans)) { + for (const span of spans) { + if (typeof span.description === "string") { + span.description = scrubString(span.description); + } + if (span.data && typeof span.data === "object") { + span.data = scrubValue(span.data) as AnyRecord; + } + } + } + + return event; +} + +/** + * Scrub an HTTP breadcrumb's URL down to host-only. Other + * breadcrumb categories pass through unchanged. Returns the (mutated) + * breadcrumb, or `null` to drop — we never drop here, the host's chain + * may. + */ +export function scrubBreadcrumb(crumb: AnyRecord): AnyRecord { + if ( + (crumb.category === "http" || crumb.category === "xhr" || crumb.category === "fetch") && + crumb.data && + typeof crumb.data === "object" + ) { + const data = crumb.data as AnyRecord; + if (typeof data.url === "string") { + data.url = scrubUrlToHost(data.url); + } + } + if (typeof crumb.message === "string") { + crumb.message = scrubString(crumb.message); + } + if (crumb.data && typeof crumb.data === "object") { + crumb.data = scrubValue(crumb.data) as AnyRecord; + } + return crumb; +} + +/** + * Scrub a structured log (the `Sentry.logger.*` channel): message + * string-scrubbed, attributes recursively scrubbed. Wired as + * `beforeSendLog` so logs get the same net as events/breadcrumbs. + * Mutates and returns the log. + */ +export function scrubLog( + log: T, +): T { + if (typeof log.message === "string") { + log.message = scrubString(log.message); + } + if (log.attributes && typeof log.attributes === "object") { + log.attributes = scrubValue(log.attributes); + } + return log; +} + +/** + * `true` when a metric should be dropped: its name or any tag name is on + * the forbidden list, or any tag value matches a forbidden pattern + * (defensive gate). + */ +export function isForbiddenMetric( + name: string, + attributes: Record, +): boolean { + if (FORBIDDEN_METRIC_TAG_NAMES.has(name)) return true; + for (const [tagName, tagValue] of Object.entries(attributes)) { + if (FORBIDDEN_METRIC_TAG_NAMES.has(tagName)) return true; + if (typeof tagValue === "string") { + for (const pattern of FORBIDDEN_METRIC_VALUE_PATTERNS) { + if (pattern.test(tagValue)) return true; + } + } + } + return false; +} diff --git a/src/sentry.ts b/src/sentry.ts index 6a7bd8f7..6d27277e 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -4,7 +4,7 @@ * This module owns the RN-side `Sentry.init` call — the host calls * [initSentry] once at app entry and the module decides DSN, release, * sample rates, and user-tier gating based on the persisted - * `diagnosticsEnabled` / `captureApplicationData` preferences. The host + * `diagnosticsEnabled` / `applicationUsageData` / `debug` preferences. The host * cannot override DSN or sample rates; it can append integrations and * chain its own `beforeSend` / `beforeBreadcrumb` after our scrubber. * @@ -18,12 +18,17 @@ import * as Sentry from "@sentry/react-native"; import { state, readSentryConfig, - readSentryPreferences, + readSentryPreferencesAtLaunch, + readCurrentSentryPreferences, + readRootUserIdNative, setDiagnosticsEnabledNative, - setCaptureApplicationDataNative, + setApplicationUsageDataNative, + setDebugEnabledNative, + type SentryPreferences, } from "./ComapeoCoreModule"; import type { ComapeoErrorInfo, ComapeoState } from "./ComapeoCore.types"; import { SentryTags, SENTRY_OWNED_GLOBAL_KEY } from "./sentry-tags"; +import { scrubEvent, scrubBreadcrumb, scrubLog } from "./sentry-scrub"; import { BACKEND_MODULES, COMAPEO_MODULE_VERSION_LABEL, @@ -40,6 +45,31 @@ export type SentryInitConfig = { sampleRate?: number; tracesSampleRate?: number; enableLogs?: boolean; + /** + * Device-classification tags computed once at native process start. + * Attached to the duration metrics as low-cardinality attributes — see + * `sentry-metrics.ts`. Absent in test contexts / pre-attach. + */ + deviceTags?: SentryDeviceTags; + /** + * Derived Sentry `user.id` for this launch, computed natively from the + * permanent root user ID: a monthly-rotating hash by default, a + * permanent hash when the user opted in to application-usage data. + * Never the root ID itself. Applied via `Sentry.setUser` by + * [initSentry]; locked (the host can't override it). + */ + userId?: string; +}; + +/** + * Low-cardinality device classification. `deviceClass` + * buckets RAM + CPU cores into low/mid/high; `osMajor` is + * `.`; `platform` is `ios` / `android`. + */ +export type SentryDeviceTags = { + platform: string; + deviceClass: string; + osMajor: string; }; /** @@ -52,13 +82,6 @@ export type SentryInitConfig = { */ export const sentryConfig: SentryInitConfig = readSentryConfig(); -/** - * Fallback `tracesSampleRate` when the plugin doesn't configure one - * and `captureApplicationData` is on. Keep in sync with - * `backend/loader.mjs`'s `DEFAULT_TRACES_SAMPLE_RATE`. - */ -const DEFAULT_TRACES_SAMPLE_RATE = 0.1; - // ── Host extension API ────────────────────────────────────────── /** @@ -104,12 +127,29 @@ export interface InitSentryOptions { // ── Public toggle API ─────────────────────────────────────────── +// In-memory view of the current saved preferences. Seeded lazily from the +// native current-value read (one synchronous native call, not once per +// render), then kept in sync by the setters so a settings screen can read a +// toggle back mid-session without its own state. Module-level, so a JS reload +// drops it and it re-seeds from the native read — reflecting any change made +// since the native process launched. Distinct from the launch snapshot the +// module's own behaviour is pinned to (see [initSentry]). +let currentPreferences: SentryPreferences | undefined; + +function livePreferences(): SentryPreferences { + return (currentPreferences ??= readCurrentSentryPreferences()); +} + /** - * User's saved value (or the plugin/baked default if unset). - * Restart-to-activate — see [setDiagnosticsEnabled]. + * The user's current saved value (or the plugin/baked default if unset). + * Reflects a [setDiagnosticsEnabled] made earlier this session and survives a + * JS reload, so a settings screen can read it back without keeping its own + * copy. This is the *saved* value, which is restart-to-activate: the value + * governing whether Sentry actually emits this session is fixed at launch + * (see [initSentry]). */ export function getDiagnosticsEnabled(): boolean { - return readSentryPreferences().diagnosticsEnabled; + return livePreferences().diagnosticsEnabled; } /** @@ -121,24 +161,65 @@ export function getDiagnosticsEnabled(): boolean { * upload. */ export function setDiagnosticsEnabled(value: boolean): Promise { - return setDiagnosticsEnabledNative(value); + // Update the in-memory view only after the native write lands — a + // rejected write (e.g. native context not attached yet) must not + // leave the getters reporting a value that was never persisted. + return setDiagnosticsEnabledNative(value).then(() => { + livePreferences().diagnosticsEnabled = value; + }); } /** - * User's saved value (or the plugin/baked default if unset). Note - * that the *effective* value (what actually gates per-RPC traces - * etc.) is `getCaptureApplicationData() && getDiagnosticsEnabled()` - * — but the getter returns the saved value so a settings UI can - * render the toggle's stored state regardless of the diagnostics - * setting. + * The user's current saved application-usage-data preference (or the + * plugin/baked default if unset). See [getDiagnosticsEnabled] for the + * saved-vs-active distinction. */ -export function getCaptureApplicationData(): boolean { - return readSentryPreferences().captureApplicationData; +export function getApplicationUsageData(): boolean { + return livePreferences().applicationUsageData; } /** Persist the toggle. See [setDiagnosticsEnabled] for semantics. */ -export function setCaptureApplicationData(value: boolean): Promise { - return setCaptureApplicationDataNative(value); +export function setApplicationUsageData(value: boolean): Promise { + return setApplicationUsageDataNative(value).then(() => { + livePreferences().applicationUsageData = value; + }); +} + +/** + * The user's current saved `debug` value (or the plugin/baked default if + * unset). See [getDiagnosticsEnabled] for the saved-vs-active distinction. + * This is the raw saved toggle; the 72h auto-off is applied natively at + * launch, so a still-`true` value here means "on, pending the next-launch + * expiry check". `debug` gates per-RPC traces, `@comapeo/core` OTel spans, + * backend `consoleIntegration`, and `rpc.args` capture. + */ +export function getDebugEnabled(): boolean { + return livePreferences().debug; +} + +/** + * Persist the `debug` toggle. Writing `true` (re)starts the 72h + * auto-off window. See [setDiagnosticsEnabled] for the + * restart-to-activate semantics. + */ +export function setDebugEnabled(value: boolean): Promise { + return setDebugEnabledNative(value).then(() => { + livePreferences().debug = value; + }); +} + +/** + * The permanent per-install root user ID (lazily generated on first + * read; reset by uninstall). A short `XXXX-XXXX-XXXX` code with no + * ambiguous characters, so a user can hand-copy it from a screen. + * Sentry never sees this value — the `user.id` on events is a hash + * derived from it (monthly-rotating by default, permanent under the + * usage opt-in), so both derivations can be recomputed from a + * user-shared root ID to re-associate historical events for a support + * case. Intended for a debug/about screen. + */ +export function getRootUserId(): string { + return readRootUserIdNative(); } // ── initSentry ────────────────────────────────────────────────── @@ -195,10 +276,9 @@ function markSentryOwned(): void { * - `dsn`, `release`, `environment`, `sampleRate`, `enableLogs` — * from `sentryConfig` (the Expo plugin's prebuild output). * - `sendDefaultPii: false` — privacy default; not overridable. - * - `tracesSampleRate` — 0 when capture-application-data is off, the - * plugin's configured value (default 0.1) when on. - * - PII scrubber (currently an identity no-op; full implementation - * lands in the subsequent phase) runs before any host `beforeSend`. + * - `tracesSampleRate` — 1.0 when `debug` is on, 0 otherwise. Per-RPC + * traces are investigation-only; metrics carry the day-to-day signal. + * - PII scrubber runs before any host `beforeSend`. */ export function initSentry(options: InitSentryOptions = {}): void { // `isInitialized` isn't on `@sentry/react-native`'s public type @@ -245,7 +325,7 @@ export function initSentry(options: InitSentryOptions = {}): void { initialized = true; - const preferences = readSentryPreferences(); + const preferences = readSentryPreferencesAtLaunch(); if (!preferences.diagnosticsEnabled) { // User opted out. Skip Sentry.init; state listeners (attached at // module load below) stay no-op via `sentryReady`. @@ -260,17 +340,22 @@ export function initSentry(options: InitSentryOptions = {}): void { return; } - // 0 when off; plugin value (or DEFAULT_TRACES_SAMPLE_RATE) when on. + // Same trace-sampling decision the native side folds into the backend's + // rate: full while the `debug` window is on, else the plugin-configured cap + // (0 if unset). Day-to-day perf signal rides the always-on metrics layer. // Locked — the host extension API can't override this. - const effectiveTracesSampleRate = preferences.captureApplicationData - ? sentryConfig.tracesSampleRate ?? DEFAULT_TRACES_SAMPLE_RATE - : 0; - - // PII scrubber — currently identity. Substring scan (rootKey, - // base64-22-char, lat/lng) TBD; chain shape is wired now so the - // host contract doesn't have to change later. - const ourBeforeSend: BeforeSendHook = (event) => event; - const ourBeforeBreadcrumb: BeforeBreadcrumbHook = (crumb) => crumb; + const effectiveTracesSampleRate = preferences.debug + ? 1.0 + : (sentryConfig.tracesSampleRate ?? 0); + + // PII scrubber — substring scan for rootKey + lat/lng markers across + // every text field. Runs BEFORE any host `beforeSend` so a buggy or + // malicious host never sees a raw payload. + const ourBeforeSend: BeforeSendHook = (event) => scrubEvent(event); + // URL-scrubbing breadcrumb hook: HTTP breadcrumbs reduced to + // host-only so request paths/queries don't leak. + const ourBeforeBreadcrumb: BeforeBreadcrumbHook = (crumb) => + scrubBreadcrumb(crumb); const chainedBeforeSend = chainHook(ourBeforeSend, options.beforeSend); const chainedBeforeBreadcrumb = chainHook( @@ -302,11 +387,22 @@ export function initSentry(options: InitSentryOptions = {}): void { options.integrations ? options.integrations(defaults) : defaults, beforeSend: chainedBeforeSend as never, beforeBreadcrumb: chainedBeforeBreadcrumb as never, + // Structured logs (`Sentry.logger.*`) bypass beforeSend/beforeBreadcrumb, + // so scrub them on their own hook — our state/message-error logs and any + // host logs. + beforeSendLog: (log: { message?: unknown; attributes?: unknown }) => + scrubLog(log), } as never); markSentryOwned(); sentryReady = true; + // Locked user.id — the native-derived monthly/permanent hash, shared + // with the FGS and backend layers so one launch reports one user. + if (sentryConfig.userId) { + Sentry.setUser({ id: sentryConfig.userId }); + } + // Scope-default tags via global scope (survives later forks). const globalScope = Sentry.getGlobalScope(); globalScope.setTag(SentryTags.proc, SentryTags.procMain); diff --git a/test-support/scrubber-cases.js b/test-support/scrubber-cases.js new file mode 100644 index 00000000..14031e61 --- /dev/null +++ b/test-support/scrubber-cases.js @@ -0,0 +1,56 @@ +/** + * Shared scrubber test cases, exercised against BOTH copies of the mirrored + * scrubber — `src/sentry-scrub.ts` (RN, via jest) and `backend/before-send.js` + * (Node, via node:test). Importing one table into both suites is what keeps the + * two regex lists from drifting: a change to one copy that isn't mirrored fails + * here. Pure data only (no test framework) so either runner can consume it. + */ + +// 32-byte keypair public key (43 base64url chars). +export const BASE64_43 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; +// ~52-char z-base-32 project id. +export const ZBASE32_52 = + "ybybybybybybybybybybybybybybybybybybybybybybybybybyb"; + +/** `scrubString(input)` must equal `expect`. */ +export const scrubStringCases = [ + { name: "rootKey marker (no delimiter) → whole value redacted", input: "rootKey=aGVsbG8td29ybGQtMTIzNA", expect: "[redacted]" }, + { name: "latitude marker redacted", input: "latitude: -12.345", expect: "[redacted]" }, + { name: "lng marker redacted", input: "lng=120.5", expect: "[redacted]" }, + // `lon` is the field name @comapeo/schema observations actually use. + { name: "lon marker redacted", input: "lon=-55.1234", expect: "[redacted]" }, + // JSON-stringified coordinates: the closing quote sits between key and + // separator (`"lat":`), which the pre-quote-handling regex missed. + { name: "JSON-serialized coordinates redacted", input: '{"lat":-12.345,"lon":55.2}', expect: '{"[redacted],"[redacted]}' }, + // Greedy-regex regression: the value stops at the first field delimiter, so + // co-located fields in a compact string survive. + { name: "rootKey value stops at comma delimiter", input: "rootKey=abc,method=obs.create,code=500", expect: "[redacted],method=obs.create,code=500" }, + { name: "plain sentence untouched", input: "hello world", expect: "hello world" }, + // Broad base64-22 rule is intentionally disabled — bare tokens pass through. + { name: "bare base64 token passes through", input: "token bm90LWEtcmVhbC1rZXktMQ done", expect: "token bm90LWEtcmVhbC1rZXktMQ done" }, + { name: "43-char public key passes through", input: BASE64_43, expect: BASE64_43 }, + { name: "52-char project id passes through", input: ZBASE32_52, expect: ZBASE32_52 }, +]; + +/** `scrubUrlToHost(input)` must equal `expect`. */ +export const scrubUrlToHostCases = [ + { name: "absolute URL → scheme + host only", input: "https://cloud.comapeo.app/projects/abc?token=secret", expect: "https://cloud.comapeo.app" }, + { name: "absolute tile URL → host only", input: "https://tiles.example.com/v1/12/34?key=abc", expect: "https://tiles.example.com" }, + // Relative-URL regression: new URL() throws, so drop the query (where the + // token rides) rather than falling back to a no-op. + { name: "relative URL drops query token", input: "/v1/projects?access_token=SECRET", expect: "/v1/projects" }, + { name: "relative URL drops fragment", input: "/a/b#frag", expect: "/a/b" }, + { name: "plain non-URL passes through", input: "not a url", expect: "not a url" }, +]; + +/** `isForbiddenMetric(name, attributes)` must equal `expect`. */ +export const forbiddenMetricCases = [ + { name: "forbidden tag name in attributes", metricName: "comapeo.x", attributes: { project_id: "p" }, expect: true }, + { name: "forbidden metric name", metricName: "project_id", attributes: { platform: "ios" }, expect: true }, + { name: "lat/lng-shaped tag value", metricName: "comapeo.x", attributes: { coord: "lat=12.34" }, expect: true }, + { name: "lon-shaped tag value", metricName: "comapeo.x", attributes: { coord: "lon=-55.12" }, expect: true }, + // Broad base64-22 value rule disabled — bare tokens no longer drop a metric. + { name: "43-char token value allowed (broad rule off)", metricName: "comapeo.x", attributes: { bucket: BASE64_43 }, expect: false }, + { name: "52-char token value allowed (broad rule off)", metricName: "comapeo.x", attributes: { bucket: ZBASE32_52 }, expect: false }, + { name: "ordinary rpc tags allowed", metricName: "comapeo.rpc.client.duration_ms", attributes: { method: "read.doc", status: "ok", platform: "ios" }, expect: false }, +];