From 5d16c7239b5319f27fcbe8d00eb2e235954915e7 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 8 Jul 2026 16:19:50 +0200 Subject: [PATCH 1/6] feat(sentry): trim native scope per privacy tier and filter native metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the native-scope field split (§9b.3), boot-transaction slimming (§9b.4), and backend free-memory/disk refresh (§9b.6) from issue #79, plus the forbidden-tag filter for native metric emissions from issue #191. - Android: TierScopeEventProcessor rebuilds device/os/app contexts from a per-tier allowlist, drops culture at diagnostic, buckets storage size, and slims comapeo.boot transactions (boot.kind tag + span data) at diagnostic. Wired via SentryFgsBridge.init. - iOS: SentryScopeTier does the same via beforeSend/beforeSendSpan in SentryNativeBridge.initFromConfig. - Both countMetric paths now run a hand-mirrored isForbiddenMetric check matching src/sentry-scrub.ts and backend/before-send.js. - Backend: node-resources event processor re-reads os.freemem/statfs on every capture, usage tier only, wired from loader.mjs positionals. Closes #79 Closes #191 --- .../com/comapeo/core/ComapeoCoreService.kt | 2 +- .../java/com/comapeo/core/SentryFgsBridge.kt | 17 +- .../com/comapeo/core/SentryMetricScrub.kt | 49 ++++ .../java/com/comapeo/core/SentryScopeTier.kt | 114 ++++++++ .../com/comapeo/core/SentryFgsBridgeTest.kt | 45 ++- .../com/comapeo/core/SentryMetricScrubTest.kt | 59 ++++ .../com/comapeo/core/SentryScopeTierTest.kt | 265 ++++++++++++++++++ backend/before-send.js | 3 + backend/lib/node-resources.js | 55 ++++ backend/lib/node-resources.test.mjs | 89 ++++++ backend/lib/sentry-init.js | 6 +- backend/lib/sentry.js | 18 +- backend/lib/sentry.test.mjs | 39 +++ backend/loader.mjs | 6 +- docs/sentry-integration-plan.md | 2 +- docs/sentry-integration.md | 13 +- ios/AppLifecycleDelegate.swift | 6 +- ios/Package.swift | 2 + ios/SentryMetricScrub.swift | 53 ++++ ios/SentryNativeBridge.swift | 19 +- ios/SentryScopeTier.swift | 119 ++++++++ ios/Tests/SentryMetricScrubTests.swift | 63 +++++ ios/Tests/SentryScopeTierTests.swift | 220 +++++++++++++++ src/sentry-scrub.ts | 5 +- 24 files changed, 1250 insertions(+), 19 deletions(-) create mode 100644 android/src/main/java/com/comapeo/core/SentryMetricScrub.kt create mode 100644 android/src/main/java/com/comapeo/core/SentryScopeTier.kt create mode 100644 android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt create mode 100644 android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt create mode 100644 backend/lib/node-resources.js create mode 100644 backend/lib/node-resources.test.mjs create mode 100644 ios/SentryMetricScrub.swift create mode 100644 ios/SentryScopeTier.swift create mode 100644 ios/Tests/SentryMetricScrubTests.swift create mode 100644 ios/Tests/SentryScopeTierTests.swift diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt index 0970ad92..56178e82 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreService.kt @@ -105,7 +105,7 @@ class ComapeoCoreService : Service() { // part deferred to ensureBackendInitialized() to keep off the FGS deadline. effectiveSentryConfig?.let { cfg -> sentryUserId = prefs.deriveSentryUserId(applicationUsageData) - SentryFgsBridge.init(applicationContext, cfg, sentryUserId) + SentryFgsBridge.init(applicationContext, cfg, sentryUserId, applicationUsageData) } logCrumb(SentryCategories.FGS, "ComapeoCoreService.onCreate") diff --git a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt index 5a437593..1184076f 100644 --- a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt +++ b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt @@ -54,10 +54,16 @@ object SentryFgsBridge { /** Idempotent. Caller must pass a non-null `SentryConfig`; skip the call * entirely when `loadFromManifest` returns null. `userId` is the derived - * Sentry user.id (monthly or permanent hash — never the root ID). */ + * Sentry user.id (monthly or permanent hash — never the root ID). + * `applicationUsageData` selects the scope tier (see TierScopeEventProcessor). */ @JvmStatic @JvmOverloads - fun init(context: Context, config: SentryConfig, userId: String? = null) { + fun init( + context: Context, + config: SentryConfig, + userId: String? = null, + applicationUsageData: Boolean = false, + ) { if (initialized) return try { // Parse once here rather than in the event processor on every capture. @@ -96,6 +102,9 @@ object SentryFgsBridge { // `comapeo.boot` match the main-process value rather than // splitting the dashboard. options.addEventProcessor(NormalizeDeviceFamilyProcessor) + // After NormalizeDeviceFamilyProcessor so the trimmed device + // context keeps the normalised family value. + options.addEventProcessor(TierScopeEventProcessor(applicationUsageData)) } // SentryOptions has no "set context at init" hook; ride a configureScope after init. @@ -216,6 +225,10 @@ object SentryFgsBridge { ) { if (!initialized) return try { + if (SentryMetricScrub.isForbiddenMetric(name, attributes)) { + Log.w(TAG, "countMetric($name) dropped: forbidden attribute") + return + } val attrs = attributes.entries .map { (k, v) -> SentryAttribute.stringAttribute(k, v) } .toTypedArray() diff --git a/android/src/main/java/com/comapeo/core/SentryMetricScrub.kt b/android/src/main/java/com/comapeo/core/SentryMetricScrub.kt new file mode 100644 index 00000000..cbe62f66 --- /dev/null +++ b/android/src/main/java/com/comapeo/core/SentryMetricScrub.kt @@ -0,0 +1,49 @@ +package com.comapeo.core + +/** + * Forbidden-name/value gate for metrics emitted through the native SDK, + * mirroring the `isForbiddenMetric` check the JS/Node paths run. + * Hand-mirrored from `src/sentry-scrub.ts` and `backend/before-send.js` + * (three module systems, so no build-time copy is practical) — keep the + * lists in lock-step; each file points at the others. + */ +internal object SentryMetricScrub { + private val FORBIDDEN_METRIC_TAG_NAMES = setOf( + "device.model", + "device.id", + "device.manufacturer", + "os.version", + "screen.resolution", + "screen.density", + "screen.dpi", + "locale", + "timezone", + "project_id", + "peer_id", + "peer_count", + "rootkey", + ) + + private val FORBIDDEN_METRIC_VALUE_PATTERNS = listOf( + Regex( + """\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?""", + RegexOption.IGNORE_CASE, + ), + ) + + /** + * `true` when a metric should be dropped: its name or any attribute name + * is on the forbidden list, or any attribute value matches a forbidden + * pattern (defensive gate). + */ + fun isForbiddenMetric(name: String, attributes: Map): Boolean { + if (name in FORBIDDEN_METRIC_TAG_NAMES) return true + for ((tagName, tagValue) in attributes) { + if (tagName in FORBIDDEN_METRIC_TAG_NAMES) return true + if (FORBIDDEN_METRIC_VALUE_PATTERNS.any { it.containsMatchIn(tagValue) }) { + return true + } + } + return false + } +} diff --git a/android/src/main/java/com/comapeo/core/SentryScopeTier.kt b/android/src/main/java/com/comapeo/core/SentryScopeTier.kt new file mode 100644 index 00000000..989d18b7 --- /dev/null +++ b/android/src/main/java/com/comapeo/core/SentryScopeTier.kt @@ -0,0 +1,114 @@ +package com.comapeo.core + +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.SentryBaseEvent +import io.sentry.SentryEvent +import io.sentry.protocol.App +import io.sentry.protocol.Device +import io.sentry.protocol.OperatingSystem +import io.sentry.protocol.SentryTransaction + +/** + * Trims the fields sentry-android attaches to every event down to what each + * privacy tier promises (docs/sentry-integration-plan.md §9b.3 / §9b.4). + * + * The diagnostic (default) tier keeps coarse hardware identity plus OS + * name/version and app id/version/build; the fingerprint-friendly extras + * (kernel version, `Build.DISPLAY`, app name, locale + timezone, screen + * metrics) only ship when the user opts into `applicationUsageData`. + * + * Contexts are rebuilt from an allowlist rather than nulling a denylist, so a + * field added by a future SDK version is dropped by default instead of shipped + * by accident. + */ +internal class TierScopeEventProcessor( + private val applicationUsageData: Boolean, +) : EventProcessor { + + override fun process(event: SentryEvent, hint: Hint): SentryEvent { + trimContexts(event) + return event + } + + override fun process(transaction: SentryTransaction, hint: Hint): SentryTransaction { + trimContexts(transaction) + if (!applicationUsageData && transaction.transaction == "comapeo.boot") { + slimBootTransaction(transaction) + } + return transaction + } + + private fun trimContexts(event: SentryBaseEvent) { + event.contexts.device?.let { event.contexts.setDevice(allowedDevice(it)) } + event.contexts.operatingSystem?.let { + event.contexts.setOperatingSystem(allowedOs(it)) + } + event.contexts.app?.let { event.contexts.setApp(allowedApp(it)) } + if (!applicationUsageData) { + // Locale + timezone are high-entropy fingerprint surfaces. + event.contexts.remove("culture") + } + } + + /** + * §9b.4: boot transactions stay always-on but carry only phase timings at + * the diagnostic tier — `boot.kind` reveals foreground/background state + * and span data (e.g. rootkey `generated`) is install-lifecycle shape. + */ + private fun slimBootTransaction(transaction: SentryTransaction) { + transaction.removeTag(SentryTags.BOOT_KIND) + transaction.spans.forEach { it.setData(null) } + } + + private fun allowedDevice(device: Device): Device = Device().apply { + manufacturer = device.manufacturer + brand = device.brand + model = device.model + modelId = device.modelId + family = device.family + archs = device.archs + isSimulator = device.isSimulator + processorCount = device.processorCount + memorySize = device.memorySize + storageSize = device.storageSize?.let { bucketStorageSize(it) } + if (applicationUsageData) { + screenWidthPixels = device.screenWidthPixels + screenHeightPixels = device.screenHeightPixels + screenDensity = device.screenDensity + screenDpi = device.screenDpi + timezone = device.timezone + } + } + + private fun allowedOs(os: OperatingSystem): OperatingSystem = + OperatingSystem().apply { + name = os.name + version = os.version + if (applicationUsageData) { + kernelVersion = os.kernelVersion + build = os.build + } + } + + private fun allowedApp(app: App): App = App().apply { + appIdentifier = app.appIdentifier + appVersion = app.appVersion + appBuild = app.appBuild + if (applicationUsageData) { + appName = app.appName + } + } + + companion object { + private const val GB = 1L shl 30 + + /** Round up to a standard marketed size (32/64/…/1024 GB) so exact + * formatted-capacity bytes can't fingerprint a device. */ + internal fun bucketStorageSize(bytes: Long): Long { + var bucket = 32 * GB + while (bucket < bytes && bucket < 1024 * GB) bucket = bucket shl 1 + return bucket + } + } +} diff --git a/android/src/test/java/com/comapeo/core/SentryFgsBridgeTest.kt b/android/src/test/java/com/comapeo/core/SentryFgsBridgeTest.kt index 730a7a9a..09184330 100644 --- a/android/src/test/java/com/comapeo/core/SentryFgsBridgeTest.kt +++ b/android/src/test/java/com/comapeo/core/SentryFgsBridgeTest.kt @@ -263,6 +263,37 @@ class SentryFgsBridgeTest { assertEquals(1, transport.envelopes.size) } + // ── countMetric forbidden-tag filter (issue #191) ────────────── + + @Test + fun countMetricWithForbiddenAttributeIsDropped() { + val recorded = mutableListOf() + initBridgeViaSentryOptions(onMetric = { recorded.add(it) }) + SentryFgsBridge.countMetric( + "comapeo.app.exit", + attributes = mapOf("project_id" to "abc123"), + ) + assertTrue( + "metric with a forbidden attribute must not reach the SDK", + recorded.isEmpty(), + ) + } + + @Test + fun countMetricWithOrdinaryExitAttributesReachesTheSdk() { + val recorded = mutableListOf() + initBridgeViaSentryOptions(onMetric = { recorded.add(it) }) + SentryFgsBridge.countMetric( + "comapeo.app.exit", + attributes = mapOf( + SentryTags.EXIT_REASON to "anr", + SentryTags.EXIT_SEVERITY to "error", + SentryTags.EXIT_INTENTIONAL to "false", + ), + ) + assertEquals(listOf("comapeo.app.exit"), recorded) + } + // ── NormalizeDeviceFamilyProcessor tests ─────────────────────── // // Pure-function tests against the processor singleton. They catch @@ -310,7 +341,10 @@ class SentryFgsBridgeTest { * Used by every post-init test in place of * [SentryFgsBridge.init] (which requires an Android Context). */ - private fun initBridgeViaSentryOptions(tracesSampleRate: Double = 1.0) { + private fun initBridgeViaSentryOptions( + tracesSampleRate: Double = 1.0, + onMetric: ((name: String) -> Unit)? = null, + ) { transport = RecordingTransport() Sentry.init { options: SentryOptions -> options.dsn = "https://abc@sentry.io/1" @@ -320,6 +354,15 @@ class SentryFgsBridgeTest { options.tracesSampleRate = tracesSampleRate options.setTag("proc", "fgs") options.setTag("layer", "native") + if (onMetric != null) { + // Runs synchronously inside the client's captureMetric, so a + // recorded name proves the bridge forwarded the emission — + // no need to wait out the metrics batch window. + options.metrics.setBeforeSend { metric, _ -> + onMetric(metric.name) + metric + } + } } SentryFgsBridge.markInitializedForTests() } diff --git a/android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt b/android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt new file mode 100644 index 00000000..2f5e681d --- /dev/null +++ b/android/src/test/java/com/comapeo/core/SentryMetricScrubTest.kt @@ -0,0 +1,59 @@ +package com.comapeo.core + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Mirror of the `isForbiddenMetric` cases in `src/__tests__/sentry-scrub.test.js` + * and `backend/lib/before-send.test.mjs` — the list is hand-mirrored across + * the four layers, so the assertions are too. */ +class SentryMetricScrubTest { + + @Test + fun forbiddenMetricNameIsRejected() { + assertTrue(SentryMetricScrub.isForbiddenMetric("device.model", emptyMap())) + } + + @Test + fun forbiddenAttributeNameIsRejected() { + assertTrue( + SentryMetricScrub.isForbiddenMetric( + "comapeo.app.exit", + mapOf("project_id" to "abc123"), + ), + ) + assertTrue( + SentryMetricScrub.isForbiddenMetric( + "comapeo.app.exit", + mapOf("locale" to "es_PE"), + ), + ) + } + + @Test + fun coordinateShapedAttributeValueIsRejected() { + assertTrue( + SentryMetricScrub.isForbiddenMetric( + "comapeo.app.exit", + mapOf("note" to "lat=-12.05, lng=-77.03"), + ), + ) + } + + @Test + fun ordinaryExitMetricAttributesPass() { + assertFalse( + SentryMetricScrub.isForbiddenMetric( + ExitReasonsCollector.METRIC_NAME, + mapOf( + SentryTags.EXIT_REASON to "anr", + SentryTags.EXIT_PROCESS_STATE to "foreground", + SentryTags.EXIT_SEVERITY to "error", + SentryTags.EXIT_INTENTIONAL to "false", + SentryTags.OEM_KILLER_SUSPECTED to "false", + SentryTags.UPTIME_BUCKET to "1h-6h", + ), + ), + ) + } +} diff --git a/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt new file mode 100644 index 00000000..17afb588 --- /dev/null +++ b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt @@ -0,0 +1,265 @@ +package com.comapeo.core + +import io.sentry.Hint +import io.sentry.SentryEvent +import io.sentry.SpanId +import io.sentry.SpanStatus +import io.sentry.protocol.App +import io.sentry.protocol.Device +import io.sentry.protocol.OperatingSystem +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentrySpan +import io.sentry.protocol.SentryTransaction +import io.sentry.protocol.TransactionInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.TimeZone + +/** + * Pure-function tests for [TierScopeEventProcessor] — the per-tier field + * split from docs/sentry-integration-plan.md §9b.3 and the boot-transaction + * slimming from §9b.4. + */ +class SentryScopeTierTest { + + private val diagnostic = TierScopeEventProcessor(applicationUsageData = false) + private val usage = TierScopeEventProcessor(applicationUsageData = true) + + // ── Device context ────────────────────────────────────────────── + + private fun fullDevice() = Device().apply { + manufacturer = "Google" + brand = "google" + model = "Pixel 7a" + modelId = "lynx" + family = "Android" + archs = arrayOf("arm64-v8a") + isSimulator = false + processorCount = 8 + memorySize = 8_000_000_000L + // What a "128 GB" phone actually reports after formatting. + storageSize = 119L * (1L shl 30) + // Fingerprint-friendly extras that must not ship at diagnostic. + name = "Maria's Pixel" + id = "abcdef0123456789" + screenWidthPixels = 1080 + screenHeightPixels = 2400 + screenDensity = 2.6f + screenDpi = 420 + timezone = TimeZone.getTimeZone("America/Lima") + freeMemory = 123_456_789L + connectionType = "wifi" + batteryLevel = 42f + } + + @Test + fun diagnosticKeepsCoarseDeviceIdentity() { + val event = SentryEvent().apply { contexts.setDevice(fullDevice()) } + val device = diagnostic.process(event, Hint())!!.contexts.device!! + assertEquals("Google", device.manufacturer) + assertEquals("google", device.brand) + assertEquals("Pixel 7a", device.model) + assertEquals("lynx", device.modelId) + assertEquals("Android", device.family) + assertEquals(false, device.isSimulator) + assertEquals(8, device.processorCount) + assertEquals(8_000_000_000L, device.memorySize) + } + + @Test + fun diagnosticDropsFingerprintFriendlyDeviceFields() { + val event = SentryEvent().apply { contexts.setDevice(fullDevice()) } + val device = diagnostic.process(event, Hint())!!.contexts.device!! + assertNull(device.name) + assertNull(device.id) + assertNull(device.screenWidthPixels) + assertNull(device.screenHeightPixels) + assertNull(device.screenDensity) + assertNull(device.screenDpi) + assertNull(device.timezone) + assertNull(device.freeMemory) + assertNull(device.connectionType) + assertNull(device.batteryLevel) + } + + @Test + fun usageTierAddsScreenMetricsAndTimezoneBack() { + val event = SentryEvent().apply { contexts.setDevice(fullDevice()) } + val device = usage.process(event, Hint())!!.contexts.device!! + assertEquals(1080, device.screenWidthPixels) + assertEquals(2400, device.screenHeightPixels) + assertEquals(2.6f, device.screenDensity) + assertEquals(420, device.screenDpi) + assertNotNull(device.timezone) + // Not in the usage-tier add-back list — dropped at both tiers. + assertNull(device.name) + assertNull(device.id) + assertNull(device.freeMemory) + } + + @Test + fun storageSizeIsBucketedToStandardSizesAtBothTiers() { + for (processor in listOf(diagnostic, usage)) { + val event = SentryEvent().apply { contexts.setDevice(fullDevice()) } + val device = processor.process(event, Hint())!!.contexts.device!! + assertEquals(128L * (1L shl 30), device.storageSize) + } + } + + @Test + fun bucketStorageSizeRoundsUpToStandardSizes() { + val gb = 1L shl 30 + assertEquals(32 * gb, TierScopeEventProcessor.bucketStorageSize(1)) + assertEquals(32 * gb, TierScopeEventProcessor.bucketStorageSize(32 * gb)) + assertEquals(64 * gb, TierScopeEventProcessor.bucketStorageSize(32 * gb + 1)) + assertEquals(256 * gb, TierScopeEventProcessor.bucketStorageSize(238 * gb)) + assertEquals(1024 * gb, TierScopeEventProcessor.bucketStorageSize(4096 * gb)) + } + + // ── OS context ────────────────────────────────────────────────── + + private fun fullOs() = OperatingSystem().apply { + name = "Android" + version = "14" + kernelVersion = "5.15.104-android13-9-abc" + build = "UQ1A.240205.004" + isRooted = false + } + + @Test + fun diagnosticKeepsOsNameAndVersionOnly() { + val event = SentryEvent().apply { contexts.setOperatingSystem(fullOs()) } + val os = diagnostic.process(event, Hint())!!.contexts.operatingSystem!! + assertEquals("Android", os.name) + assertEquals("14", os.version) + assertNull(os.kernelVersion) + assertNull(os.build) + assertNull(os.isRooted) + } + + @Test + fun usageTierAddsKernelAndBuildBack() { + val event = SentryEvent().apply { contexts.setOperatingSystem(fullOs()) } + val os = usage.process(event, Hint())!!.contexts.operatingSystem!! + assertEquals("5.15.104-android13-9-abc", os.kernelVersion) + assertEquals("UQ1A.240205.004", os.build) + } + + // ── App context ───────────────────────────────────────────────── + + private fun fullApp() = App().apply { + appIdentifier = "com.comapeo.app" + appVersion = "1.2.3" + appBuild = "456" + appName = "CoMapeo" + deviceAppHash = "deadbeef" + inForeground = true + } + + @Test + fun diagnosticKeepsAppIdVersionBuildOnly() { + val event = SentryEvent().apply { contexts.setApp(fullApp()) } + val app = diagnostic.process(event, Hint())!!.contexts.app!! + assertEquals("com.comapeo.app", app.appIdentifier) + assertEquals("1.2.3", app.appVersion) + assertEquals("456", app.appBuild) + assertNull(app.appName) + assertNull(app.deviceAppHash) + assertNull(app.inForeground) + } + + @Test + fun usageTierAddsAppNameBackButNotDeviceAppHash() { + val event = SentryEvent().apply { contexts.setApp(fullApp()) } + val app = usage.process(event, Hint())!!.contexts.app!! + assertEquals("CoMapeo", app.appName) + assertNull(app.deviceAppHash) + assertNull(app.inForeground) + } + + // ── Culture context ───────────────────────────────────────────── + + @Test + fun diagnosticDropsCultureEntirely() { + val event = SentryEvent() + event.contexts.put("culture", mapOf("locale" to "es_PE", "timezone" to "America/Lima")) + assertNull(diagnostic.process(event, Hint())!!.contexts.get("culture")) + } + + @Test + fun usageTierKeepsCulture() { + val event = SentryEvent() + event.contexts.put("culture", mapOf("locale" to "es_PE")) + assertNotNull(usage.process(event, Hint())!!.contexts.get("culture")) + } + + // ── Boot transaction slimming (§9b.4) ─────────────────────────── + + private fun bootTransaction(): SentryTransaction { + val span = SentrySpan( + /* startTimestamp = */ 0.0, + /* timestamp = */ 1.0, + /* traceId = */ SentryId(), + /* spanId = */ SpanId(), + /* parentSpanId = */ null, + /* op = */ "boot.rootkey-load", + /* description = */ "boot.rootkey-load", + /* status = */ SpanStatus.OK, + /* origin = */ null, + /* tags = */ emptyMap(), + /* measurements = */ emptyMap(), + /* data = */ mutableMapOf("generated" to true), + ) + return SentryTransaction( + /* transaction = */ "comapeo.boot", + /* startTimestamp = */ 0.0, + /* timestamp = */ 2.0, + /* spans = */ listOf(span), + /* measurements = */ emptyMap(), + /* transactionInfo = */ TransactionInfo("custom"), + ).apply { + setTag(SentryTags.PROC, SentryTags.PROC_FGS) + setTag(SentryTags.LAYER, SentryTags.LAYER_NATIVE) + setTag(SentryTags.BOOT_KIND, SentryTags.BOOT_KIND_USER_FOREGROUND) + } + } + + @Test + fun diagnosticSlimsBootTransactionToPhaseTimings() { + val tx = diagnostic.process(bootTransaction(), Hint())!! + assertNull("boot.kind is a foreground-state tag", tx.getTag(SentryTags.BOOT_KIND)) + assertEquals(SentryTags.PROC_FGS, tx.getTag(SentryTags.PROC)) + val span = tx.spans.single() + assertTrue("span data must be stripped", span.data.isNullOrEmpty()) + // Timings and the bare phase name survive. + assertEquals("boot.rootkey-load", span.op) + assertEquals(span.op, span.description) + assertEquals(0.0, span.startTimestamp, 0.0) + assertEquals(1.0, span.timestamp!!, 0.0) + } + + @Test + fun usageTierKeepsBootKindAndSpanData() { + val tx = usage.process(bootTransaction(), Hint())!! + assertEquals(SentryTags.BOOT_KIND_USER_FOREGROUND, tx.getTag(SentryTags.BOOT_KIND)) + assertEquals(true, tx.spans.single().data?.get("generated")) + } + + @Test + fun nonBootTransactionsKeepTheirSpanData() { + val span = SentrySpan( + 0.0, 1.0, SentryId(), SpanId(), null, + "rpc.server", "project.observation.create", SpanStatus.OK, null, + emptyMap(), emptyMap(), mutableMapOf("rpc.system" to "comapeo-ipc"), + ) + val tx = SentryTransaction( + "project.observation.create", 0.0, 2.0, listOf(span), + emptyMap(), TransactionInfo("custom"), + ) + val processed = diagnostic.process(tx, Hint())!! + assertEquals("comapeo-ipc", processed.spans.single().data?.get("rpc.system")) + } +} diff --git a/backend/before-send.js b/backend/before-send.js index e04ddb2b..552caf94 100644 --- a/backend/before-send.js +++ b/backend/before-send.js @@ -32,6 +32,9 @@ const SCRUB_PATTERNS = [ /** Object keys whose value is a raw coordinate — redacted regardless of type. */ const SENSITIVE_KEY_PATTERN = /^(lat|lng|lon|latitude|longitude)$/i; +// The native metric paths keep hand-mirrored copies of this list in +// `android/src/main/java/com/comapeo/core/SentryMetricScrub.kt` and +// `ios/SentryMetricScrub.swift` — keep all four in lock-step. const FORBIDDEN_METRIC_TAG_NAMES = new Set([ "device.model", "device.id", diff --git a/backend/lib/node-resources.js b/backend/lib/node-resources.js new file mode 100644 index 00000000..07cc7c79 --- /dev/null +++ b/backend/lib/node-resources.js @@ -0,0 +1,55 @@ +// Fresh Node-process resource numbers on every backend event +// (docs/sentry-integration-plan.md §9b.6). Registered as an event +// processor so the values are re-read at capture time instead of +// snapshotting once at init. Usage tier only — read-at-capture +// frequency is itself usage-shape data — so the processor asks for +// the live tier state on every event rather than baking the flag in +// at registration (event processors outlive re-`init()`). + +import os from "node:os"; +import fs from "node:fs"; + +/** + * Event processor that stamps a `node_resources` context with + * capture-time free/total memory and (when `storageDir` is set and + * statfs succeeds) free/total storage of the filesystem holding the + * private storage dir. A separate context key, not `device`: the + * backend strips `device`/`os`/`culture` so the native SDK fills them, + * and these are Node-process numbers, not native device scope. + * + * @param {() => { storageDir?: string } | null} getState returns the + * current storage dir when the usage tier is on, `null` when the + * processor should leave the event untouched. + * @param {{ + * freemem?: () => number, + * totalmem?: () => number, + * statfsSync?: (path: string) => { bsize: number, blocks: number, bavail: number }, + * }} [deps] test seam + * @returns {(event: any) => any} + */ +export function createNodeResourcesProcessor(getState, deps = {}) { + const freemem = deps.freemem ?? os.freemem; + const totalmem = deps.totalmem ?? os.totalmem; + const statfsSync = deps.statfsSync ?? fs.statfsSync; + return (event) => { + const state = getState(); + if (!state) return event; + /** @type {Record} */ + const resources = { + free_memory: freemem(), + memory_size: totalmem(), + }; + if (state.storageDir) { + try { + const stats = statfsSync(state.storageDir); + resources.free_storage = stats.bavail * stats.bsize; + resources.storage_size = stats.blocks * stats.bsize; + } catch { + // Best-effort: a stat failure loses only the storage numbers, never the event. + } + } + event.contexts = event.contexts || {}; + event.contexts.node_resources = resources; + return event; + }; +} diff --git a/backend/lib/node-resources.test.mjs b/backend/lib/node-resources.test.mjs new file mode 100644 index 00000000..7ae6ebed --- /dev/null +++ b/backend/lib/node-resources.test.mjs @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createNodeResourcesProcessor } from "./node-resources.js"; + +test("re-reads memory and storage on every event (fresh, not snapshotted)", () => { + let free = 1000; + const processor = createNodeResourcesProcessor( + () => ({ storageDir: "/data/storage" }), + { + freemem: () => free, + totalmem: () => 4000, + statfsSync: () => ({ bsize: 4096, blocks: 100, bavail: 25 }), + }, + ); + + const first = processor({}); + assert.equal(first.contexts.node_resources.free_memory, 1000); + assert.equal(first.contexts.node_resources.memory_size, 4000); + assert.equal(first.contexts.node_resources.free_storage, 25 * 4096); + assert.equal(first.contexts.node_resources.storage_size, 100 * 4096); + + free = 500; + const second = processor({}); + assert.equal( + second.contexts.node_resources.free_memory, + 500, + "second capture must re-read the live value", + ); +}); + +test("usage tier off (null state) leaves the event untouched", () => { + const processor = createNodeResourcesProcessor(() => null, { + freemem: () => { + throw new Error("must not be called"); + }, + }); + const event = { contexts: { app: { app_name: "x" } } }; + assert.equal(processor(event), event); + assert.equal(event.contexts.node_resources, undefined); +}); + +test("preserves existing contexts on the event", () => { + const processor = createNodeResourcesProcessor(() => ({}), { + freemem: () => 1, + totalmem: () => 2, + }); + const event = processor({ contexts: { app: { app_name: "x" } } }); + assert.equal(event.contexts.app.app_name, "x"); + assert.ok(event.contexts.node_resources); +}); + +test("statfs failure loses only the storage numbers, never the event", () => { + const processor = createNodeResourcesProcessor( + () => ({ storageDir: "/gone" }), + { + freemem: () => 1, + totalmem: () => 2, + statfsSync: () => { + throw new Error("ENOENT"); + }, + }, + ); + const event = processor({}); + assert.equal(event.contexts.node_resources.free_memory, 1); + assert.equal(event.contexts.node_resources.free_storage, undefined); +}); + +test("no storageDir skips the statfs read", () => { + const processor = createNodeResourcesProcessor(() => ({}), { + freemem: () => 1, + totalmem: () => 2, + statfsSync: () => { + throw new Error("must not be called"); + }, + }); + const event = processor({}); + assert.equal(event.contexts.node_resources.free_storage, undefined); +}); + +test("real os/fs reads produce plausible numbers", () => { + const processor = createNodeResourcesProcessor(() => ({ + storageDir: process.cwd(), + })); + const { node_resources: resources } = processor({}).contexts; + assert.ok(resources.free_memory > 0); + assert.ok(resources.memory_size >= resources.free_memory); + assert.ok(resources.storage_size > 0); +}); diff --git a/backend/lib/sentry-init.js b/backend/lib/sentry-init.js index 4af63cba..83862254 100644 --- a/backend/lib/sentry-init.js +++ b/backend/lib/sentry-init.js @@ -35,9 +35,11 @@ import * as sentry from "./sentry.js"; * import AND the SDK init. * * @param {Argv} argv + * @param {string} [storageDir] private-storage positional, for + * capture-time free-disk reads (usage tier). */ -export function initSentry(argv) { - sentry.init({ Sentry, argv, envelopeToFrame }); +export function initSentry(argv, storageDir) { + sentry.init({ Sentry, argv, envelopeToFrame, storageDir }); const client = Sentry.getClient(); if (!client) return; diff --git a/backend/lib/sentry.js b/backend/lib/sentry.js index 05e26490..93f0c875 100644 --- a/backend/lib/sentry.js +++ b/backend/lib/sentry.js @@ -8,6 +8,7 @@ import { scrubEvent, scrubLog } from "../before-send.js"; import * as metrics from "./metrics.js"; +import { createNodeResourcesProcessor } from "./node-resources.js"; /** * parseArgs spec for the Sentry-related CLI flags. loader.mjs uses @@ -112,15 +113,18 @@ function numericArg(raw) { /** * One-call setup: stores singletons AND calls `Sentry.init(...)`. - * Caller has already verified `argv.sentryDsn` is set. + * Caller has already verified `argv.sentryDsn` is set. `storageDir` + * is the private-storage positional, used for capture-time free-disk + * reads at the usage tier. * * @param {{ * Sentry: typeof import("@sentry/node-core"), * argv: Argv, * envelopeToFrame: (envelope: any) => SentryFrame, + * storageDir?: string, * }} args */ -export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { +export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame, storageDir }) { Sentry = sdk; config = { rpcArgsBytes: numericArg(argv.sentryRpcArgsBytes), @@ -185,6 +189,16 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { // all scope merges. Drops or redacts before envelopes leave Node. Sentry.addEventProcessor(scrubEvent); + // §9b.6: fresh free-memory/free-disk numbers on every backend event. + // Usage tier only; the processor re-checks the live config per capture + // (registrations outlive re-init, so gating here would leak across inits). + const resolvedStorageDir = storageDir; + Sentry.addEventProcessor( + createNodeResourcesProcessor(() => + config?.applicationUsageData ? { storageDir: resolvedStorageDir } : null, + ), + ); + // 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`. diff --git a/backend/lib/sentry.test.mjs b/backend/lib/sentry.test.mjs index c6f9a0ee..1975a4ae 100644 --- a/backend/lib/sentry.test.mjs +++ b/backend/lib/sentry.test.mjs @@ -215,3 +215,42 @@ test("initialScope carries the native-derived user.id on outgoing events", async await Sentry.close(); }); + +test("usage tier ON: events carry a fresh node_resources context", async () => { + initSentry({ ...baseArgv, applicationUsageData: true }, process.cwd()); + + const captured = []; + setSink((frame) => captured.push(frame)); + + Sentry.captureMessage("node resources smoke"); + await flush(2000); + + const eventFrame = captured.find((f) => f.type === "sentry-event"); + assert.ok(eventFrame, "no event frame reached the sink"); + const resources = eventFrame.payload.contexts?.node_resources; + assert.ok(resources, "usage tier must attach node_resources"); + assert.ok(resources.free_memory > 0, "free_memory must be a live read"); + assert.ok(resources.storage_size > 0, "storage_size must come from statfs"); + + await Sentry.close(); +}); + +test("usage tier OFF: events carry no node_resources context", async () => { + initSentry({ ...baseArgv, applicationUsageData: false }, process.cwd()); + + const captured = []; + setSink((frame) => captured.push(frame)); + + Sentry.captureMessage("node resources gated 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.contexts?.node_resources, + undefined, + "diagnostic tier must not attach node_resources", + ); + + await Sentry.close(); +}); diff --git a/backend/loader.mjs b/backend/loader.mjs index a586163b..77e2d5d3 100644 --- a/backend/loader.mjs +++ b/backend/loader.mjs @@ -10,7 +10,7 @@ import * as sentry from "./lib/sentry.js"; // process spawn through Sentry.init. const loaderStartDate = new Date(); -const { values } = parseArgs({ +const { values, positionals } = parseArgs({ options: sentry.argSpec, allowPositionals: true, }); @@ -33,7 +33,9 @@ if (values.sentryDsn) { // would have nothing to hook and is pure dead weight. importSentryNodeStartDate = new Date(); const { initSentry } = await import("./lib/sentry-init.js"); - initSentry(values); + // 3rd positional is privateStorageDir (see index.js) — used for + // capture-time free-disk reads at the usage tier. + initSentry(values, positionals[2]); importSentryNodeEndDate = new Date(); } diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md index b7742431..ece1045e 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 | 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 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; native-scope field split (9b.3), boot-transaction slimming (9b.4), and backend free-mem refresh (9b.6) landed with issue #79. Remaining: 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. | --- diff --git a/docs/sentry-integration.md b/docs/sentry-integration.md index 10fb7fec..3ea635b2 100644 --- a/docs/sentry-integration.md +++ b/docs/sentry-integration.md @@ -672,10 +672,13 @@ Three failure surfaces in `backend/index.js`: 1. **`handleFatal(phase, error)`** — the single funnel for uncaught exceptions, unhandled rejections, and boot-phase throws. Captures with - `tags: { phase, layer: "node" }` and attaches `os.freemem`, - `os.totalmem`, and `fs.statfsSync` results as extras (cheap fix for - `@sentry/node` not synthesising device context the way the RN / native - SDKs do). Flushes for 100 ms before `process.exit(1)`. + `tags: { phase, layer: "node" }`. Flushes for 100 ms before + `process.exit(1)`. At the usage tier, every backend event (fatal or + not) additionally carries a `node_resources` context with + capture-time `os.freemem`/`os.totalmem` and `fs.statfsSync` numbers + (`backend/lib/node-resources.js`, wired as an event processor in + `lib/sentry.js`); diagnostic-tier events omit it because + read-at-capture frequency is itself usage-shape data. 2. **`error-native` handler** — frames forwarded from Android FGS-local failures (rootkey, watchdog) reach `handleFatal` with the FGS-supplied phase, so they get captured by #1 automatically. Tagged @@ -1393,6 +1396,8 @@ The diagnostic tier carries aggregate, low-cardinality operational signal; | 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. | +| Backend `node_resources` event context (capture-time free memory / free disk) | applicationUsageData | Read-at-capture frequency reveals app activity. Attached by `backend/lib/node-resources.js`. | +| Native event scope extras (kernel version, OS build string, app name, locale + timezone, screen metrics, `boot.kind`, boot span data) | applicationUsageData | High-entropy fingerprint / usage-shape surfaces. The diagnostic tier keeps coarse device identity, OS name+version, and app id/version/build only (`SentryScopeTier.kt` / `SentryScopeTier.swift`). | | 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)).* | diff --git a/ios/AppLifecycleDelegate.swift b/ios/AppLifecycleDelegate.swift index c3174268..9fc7abf9 100644 --- a/ios/AppLifecycleDelegate.swift +++ b/ios/AppLifecycleDelegate.swift @@ -135,7 +135,11 @@ public class AppLifecycleDelegate: ExpoAppDelegateSubscriber { // 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()) + SentryNativeBridge.initFromConfig( + cfg, + userId: Self.deriveSentryUserId(), + applicationUsageData: ComapeoPrefs.open().readApplicationUsageData() + ) // Drain a `debug` 72h auto-off queued by the prefs // reader, which runs before the SDK is up. if DebugAutoOff.consume() { diff --git a/ios/Package.swift b/ios/Package.swift index 1c8e5cf7..bccdafae 100644 --- a/ios/Package.swift +++ b/ios/Package.swift @@ -40,6 +40,8 @@ let package = Package( "ComapeoPrefs.swift", "SentryConfig.swift", "SentryNativeBridge.swift", + "SentryScopeTier.swift", + "SentryMetricScrub.swift", "SentryTags.swift", "SentryUserId.swift", "DeviceTags.swift", diff --git a/ios/SentryMetricScrub.swift b/ios/SentryMetricScrub.swift new file mode 100644 index 00000000..ff088455 --- /dev/null +++ b/ios/SentryMetricScrub.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Forbidden-name/value gate for metrics emitted through the native SDK, +/// mirroring the `isForbiddenMetric` check the JS/Node paths run. +/// Hand-mirrored from `src/sentry-scrub.ts` and `backend/before-send.js` +/// (three module systems, so no build-time copy is practical) — keep the +/// lists in lock-step; each file points at the others. +enum SentryMetricScrub { + private static let forbiddenMetricTagNames: Set = [ + "device.model", + "device.id", + "device.manufacturer", + "os.version", + "screen.resolution", + "screen.density", + "screen.dpi", + "locale", + "timezone", + "project_id", + "peer_id", + "peer_count", + "rootkey", + ] + + private static let forbiddenMetricValuePatterns: [NSRegularExpression] = [ + // swiftlint:disable:next force_try + try! NSRegularExpression( + pattern: #"\b(?:latitude|longitude|lat|lng|lon)\b\s*["']?\s*[:=]\s*-?\d+(?:\.\d+)?"#, + options: [.caseInsensitive] + ), + ] + + /// `true` when a metric should be dropped: its name or any attribute name + /// is on the forbidden list, or any string attribute value matches a + /// forbidden pattern (defensive gate). + static func isForbiddenMetric(name: String, attributes: [String: Any]) -> Bool { + if forbiddenMetricTagNames.contains(name) { return true } + for (tagName, tagValue) in attributes { + if forbiddenMetricTagNames.contains(tagName) { return true } + if let string = tagValue as? String, matchesForbiddenPattern(string) { + return true + } + } + return false + } + + private static func matchesForbiddenPattern(_ value: String) -> Bool { + let range = NSRange(value.startIndex..., in: value) + return forbiddenMetricValuePatterns.contains { + $0.firstMatch(in: value, options: [], range: range) != nil + } + } +} diff --git a/ios/SentryNativeBridge.swift b/ios/SentryNativeBridge.swift index fb3362d5..4a942210 100644 --- a/ios/SentryNativeBridge.swift +++ b/ios/SentryNativeBridge.swift @@ -23,8 +23,13 @@ enum SentryNativeBridge { /// `loadFromMainBundle()` returns nil. Mirror of Android /// `SentryFgsBridge.init` — the iOS process IS the "FGS" since iOS /// 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) { + /// or permanent hash — never the root ID). `applicationUsageData` + /// selects the scope tier (see `SentryScopeTier`). + static func initFromConfig( + _ config: SentryConfig, + userId: String? = nil, + applicationUsageData: Bool = false + ) { if SentrySDK.isEnabled { return } let opts = Options() opts.dsn = config.dsn @@ -33,6 +38,12 @@ enum SentryNativeBridge { opts.sampleRate = NSNumber(value: config.sampleRate ?? 1.0) opts.tracesSampleRate = NSNumber(value: config.tracesSampleRate ?? 0.0) opts.sendDefaultPii = false + opts.beforeSend = { event in + SentryScopeTier.trimEvent(event, applicationUsageData: applicationUsageData) + } + opts.beforeSendSpan = { span in + SentryScopeTier.trimSpan(span, applicationUsageData: applicationUsageData) + } // initialScope runs once on init; same shape Android achieves // via `options.setTag(...)` in its `SentryAndroid.init` block. opts.initialScope = { scope in @@ -97,6 +108,10 @@ enum SentryNativeBridge { /// not started, and drops with a log when `options.enableMetrics` is /// false (it defaults to true). static func countMetric(_ key: String, value: UInt, attributes: [String: Any]) { + if SentryMetricScrub.isForbiddenMetric(name: key, attributes: attributes) { + log("countMetric(\(key)) dropped: forbidden attribute", level: .warning) + return + } var converted: [String: SentryAttributeValue] = [:] for (k, v) in attributes { switch v { diff --git a/ios/SentryScopeTier.swift b/ios/SentryScopeTier.swift new file mode 100644 index 00000000..eb36e1fa --- /dev/null +++ b/ios/SentryScopeTier.swift @@ -0,0 +1,119 @@ +import Foundation +import Sentry + +/// Trims the fields sentry-cocoa attaches to every event down to what each +/// privacy tier promises (docs/sentry-integration-plan.md §9b.3 / §9b.4). +/// Mirror of Android's `TierScopeEventProcessor`. +/// +/// The diagnostic (default) tier keeps coarse hardware identity plus OS +/// name/version and app id/version/build; the fingerprint-friendly extras +/// (kernel version, OS build string, app name, locale + timezone, screen +/// metrics) only ship when the user opts into `applicationUsageData`. +/// +/// Contexts are filtered through an allowlist rather than deleting a +/// denylist, so a field added by a future SDK version is dropped by default +/// instead of shipped by accident. +enum SentryScopeTier { + private static let deviceKeysDiagnostic: Set = [ + "manufacturer", "brand", "model", "model_id", "family", "arch", "archs", + "simulator", "processor_count", "memory_size", "storage_size", + ] + private static let deviceKeysUsageExtra: Set = [ + "screen_width_pixels", "screen_height_pixels", "screen_resolution", + "screen_density", "screen_dpi", "screen_scale", "locale", "timezone", + ] + private static let osKeysDiagnostic: Set = ["name", "version"] + private static let osKeysUsageExtra: Set = ["kernel_version", "build"] + private static let appKeysDiagnostic: Set = [ + "app_identifier", "app_version", "app_build", + ] + private static let appKeysUsageExtra: Set = ["app_name"] + + /// The global tags every native event carries; anything else riding on a + /// boot transaction is user-shape and dropped at the diagnostic tier. + private static let bootTransactionTagAllowlist: Set = [ + SentryTags.proc, SentryTags.layer, + ] + + private static let gb: Int64 = 1 << 30 + + /// `beforeSend` hook body. Mutates and returns the event. + static func trimEvent(_ event: Event, applicationUsageData: Bool) -> Event { + if var context = event.context { + trim(&context, key: "device", + keep: deviceKeysDiagnostic, usageExtra: deviceKeysUsageExtra, + applicationUsageData: applicationUsageData) + trim(&context, key: "os", + keep: osKeysDiagnostic, usageExtra: osKeysUsageExtra, + applicationUsageData: applicationUsageData) + trim(&context, key: "app", + keep: appKeysDiagnostic, usageExtra: appKeysUsageExtra, + applicationUsageData: applicationUsageData) + if !applicationUsageData { + // Locale + timezone are high-entropy fingerprint surfaces. + context["culture"] = nil + } + if var device = context["device"], + let storage = device["storage_size"] as? NSNumber { + device["storage_size"] = NSNumber( + value: bucketStorageSize(storage.int64Value) + ) + context["device"] = device + } + event.context = context + } + // §9b.4: boot transactions carry only phase timings at diagnostic. + if !applicationUsageData, + event.type == "transaction", + event.transaction == "comapeo.boot" { + event.tags = event.tags?.filter { + bootTransactionTagAllowlist.contains($0.key) + } + } + return event + } + + /// `beforeSendSpan` hook body: strip span data (e.g. rootkey `generated`) + /// from boot phase spans at the diagnostic tier; timings, op, and the bare + /// phase-name description stay. + static func trimSpan(_ span: Span, applicationUsageData: Bool) -> Span { + guard shouldStripBootSpanData( + operation: span.operation, + applicationUsageData: applicationUsageData + ) else { return span } + for key in span.data.keys { + span.removeData(key: key) + } + return span + } + + static func shouldStripBootSpanData( + operation: String, + applicationUsageData: Bool + ) -> Bool { + !applicationUsageData && operation.hasPrefix("boot.") + } + + /// Round up to a standard marketed size (32/64/…/1024 GB) so exact + /// formatted-capacity bytes can't fingerprint a device. + static func bucketStorageSize(_ bytes: Int64) -> Int64 { + var bucket = 32 * gb + while bucket < bytes && bucket < 1024 * gb { + bucket <<= 1 + } + return bucket + } + + private static func trim( + _ context: inout [String: [String: Any]], + key: String, + keep: Set, + usageExtra: Set, + applicationUsageData: Bool + ) { + guard let block = context[key] else { return } + var allowed = keep + if applicationUsageData { allowed.formUnion(usageExtra) } + context[key] = block.filter { allowed.contains($0.key) } + } +} diff --git a/ios/Tests/SentryMetricScrubTests.swift b/ios/Tests/SentryMetricScrubTests.swift new file mode 100644 index 00000000..b98cdf05 --- /dev/null +++ b/ios/Tests/SentryMetricScrubTests.swift @@ -0,0 +1,63 @@ +import XCTest + +@testable import ComapeoCore + +/// Mirror of the `isForbiddenMetric` cases in `src/__tests__/sentry-scrub.test.js` +/// and `backend/lib/before-send.test.mjs` — the list is hand-mirrored across +/// the four layers, so the assertions are too. +final class SentryMetricScrubTests: XCTestCase { + + func testForbiddenMetricNameIsRejected() { + XCTAssertTrue( + SentryMetricScrub.isForbiddenMetric(name: "device.model", attributes: [:]) + ) + } + + func testForbiddenAttributeNameIsRejected() { + XCTAssertTrue( + SentryMetricScrub.isForbiddenMetric( + name: "comapeo.app.exit", + attributes: ["project_id": "abc123"] + ) + ) + XCTAssertTrue( + SentryMetricScrub.isForbiddenMetric( + name: "comapeo.app.exit", + attributes: ["locale": "es_PE"] + ) + ) + } + + func testCoordinateShapedAttributeValueIsRejected() { + XCTAssertTrue( + SentryMetricScrub.isForbiddenMetric( + name: "comapeo.app.exit", + attributes: ["note": "lat=-12.05, lng=-77.03"] + ) + ) + } + + func testNonStringAttributeValuesAreIgnoredByThePatternCheck() { + XCTAssertFalse( + SentryMetricScrub.isForbiddenMetric( + name: "comapeo.app.exit", + attributes: ["count": 3, "flag": true] + ) + ) + } + + func testOrdinaryExitMetricAttributesPass() { + XCTAssertFalse( + SentryMetricScrub.isForbiddenMetric( + name: SentryNativeBridge.appExitMetricName, + attributes: [ + SentryTags.exitCohort: "current", + SentryTags.exitBucket: "memory-pressure", + SentryTags.exitIntentional: "false", + SentryTags.exitCauseClass: "system", + SentryTags.exitSeverity: "warning", + ] + ) + ) + } +} diff --git a/ios/Tests/SentryScopeTierTests.swift b/ios/Tests/SentryScopeTierTests.swift new file mode 100644 index 00000000..d1c64f4d --- /dev/null +++ b/ios/Tests/SentryScopeTierTests.swift @@ -0,0 +1,220 @@ +import Sentry +import XCTest + +@testable import ComapeoCore + +/// Per-tier field assertions for `SentryScopeTier` — the §9b.3 context +/// allowlists and the §9b.4 boot-transaction slimming, exercised on +/// constructed `Event`s the way the `beforeSend` hook receives them. +final class SentryScopeTierTests: XCTestCase { + + private func fullEvent() -> Event { + let event = Event() + event.context = [ + "device": [ + "manufacturer": "Apple", + "model": "iPhone14,4", + "model_id": "D16AP", + "family": "iOS", + "arch": "arm64e", + "simulator": false, + "processor_count": 6, + "memory_size": 4_000_000_000, + // What a "128 GB" phone actually reports after formatting. + "storage_size": Int64(119) * (1 << 30), + // Fingerprint-friendly extras that must not ship at diagnostic. + "free_memory": 123_456_789, + "usable_memory": 3_000_000_000, + "locale": "es_PE", + "screen_width_pixels": 1170, + "screen_height_pixels": 2532, + "thermal_state": "nominal", + "orientation": "portrait", + "battery_level": 42, + ], + "os": [ + "name": "iOS", + "version": "17.5.1", + "build": "21F90", + "kernel_version": "Darwin Kernel Version 23.5.0", + "rooted": false, + ], + "app": [ + "app_identifier": "com.comapeo.app", + "app_version": "1.2.3", + "app_build": "456", + "app_name": "CoMapeo", + "app_start_time": "2026-07-08T00:00:00Z", + "device_app_hash": "deadbeef", + "build_type": "app store", + ], + "culture": [ + "locale": "es_PE", + "timezone": "America/Lima", + ], + ] + return event + } + + // MARK: - Device context + + func testDiagnosticKeepsCoarseDeviceIdentity() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: false) + let device = event.context?["device"] + XCTAssertEqual(device?["manufacturer"] as? String, "Apple") + XCTAssertEqual(device?["model"] as? String, "iPhone14,4") + XCTAssertEqual(device?["model_id"] as? String, "D16AP") + XCTAssertEqual(device?["family"] as? String, "iOS") + XCTAssertEqual(device?["arch"] as? String, "arm64e") + XCTAssertEqual(device?["simulator"] as? Bool, false) + XCTAssertEqual(device?["processor_count"] as? Int, 6) + XCTAssertEqual(device?["memory_size"] as? Int, 4_000_000_000) + } + + func testDiagnosticDropsFingerprintFriendlyDeviceFields() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: false) + let device = event.context?["device"] + XCTAssertNil(device?["free_memory"]) + XCTAssertNil(device?["usable_memory"]) + XCTAssertNil(device?["locale"]) + XCTAssertNil(device?["screen_width_pixels"]) + XCTAssertNil(device?["screen_height_pixels"]) + XCTAssertNil(device?["thermal_state"]) + XCTAssertNil(device?["orientation"]) + XCTAssertNil(device?["battery_level"]) + } + + func testUsageTierAddsScreenMetricsAndLocaleBack() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: true) + let device = event.context?["device"] + XCTAssertEqual(device?["screen_width_pixels"] as? Int, 1170) + XCTAssertEqual(device?["screen_height_pixels"] as? Int, 2532) + XCTAssertEqual(device?["locale"] as? String, "es_PE") + // Not in the usage-tier add-back list — dropped at both tiers. + XCTAssertNil(device?["free_memory"]) + XCTAssertNil(device?["thermal_state"]) + XCTAssertNil(device?["battery_level"]) + } + + func testStorageSizeIsBucketedAtBothTiers() { + for usage in [false, true] { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: usage) + let storage = (event.context?["device"]?["storage_size"] as? NSNumber)?.int64Value + XCTAssertEqual(storage, Int64(128) * (1 << 30)) + } + } + + func testBucketStorageSizeRoundsUpToStandardSizes() { + let gb: Int64 = 1 << 30 + XCTAssertEqual(SentryScopeTier.bucketStorageSize(1), 32 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(32 * gb), 32 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(32 * gb + 1), 64 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(238 * gb), 256 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(4096 * gb), 1024 * gb) + } + + // MARK: - OS / app / culture contexts + + func testDiagnosticKeepsOsNameAndVersionOnly() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: false) + let os = event.context?["os"] + XCTAssertEqual(os?["name"] as? String, "iOS") + XCTAssertEqual(os?["version"] as? String, "17.5.1") + XCTAssertNil(os?["build"]) + XCTAssertNil(os?["kernel_version"]) + XCTAssertNil(os?["rooted"]) + } + + func testUsageTierAddsKernelAndBuildBack() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: true) + let os = event.context?["os"] + XCTAssertEqual(os?["build"] as? String, "21F90") + XCTAssertEqual(os?["kernel_version"] as? String, "Darwin Kernel Version 23.5.0") + } + + func testDiagnosticKeepsAppIdVersionBuildOnly() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: false) + let app = event.context?["app"] + XCTAssertEqual(app?["app_identifier"] as? String, "com.comapeo.app") + XCTAssertEqual(app?["app_version"] as? String, "1.2.3") + XCTAssertEqual(app?["app_build"] as? String, "456") + XCTAssertNil(app?["app_name"]) + XCTAssertNil(app?["app_start_time"]) + XCTAssertNil(app?["device_app_hash"]) + } + + func testUsageTierAddsAppNameBackButNotDeviceAppHash() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: true) + let app = event.context?["app"] + XCTAssertEqual(app?["app_name"] as? String, "CoMapeo") + XCTAssertNil(app?["device_app_hash"]) + XCTAssertNil(app?["app_start_time"]) + } + + func testDiagnosticDropsCultureEntirely() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: false) + XCTAssertNil(event.context?["culture"]) + } + + func testUsageTierKeepsCulture() { + let event = SentryScopeTier.trimEvent(fullEvent(), applicationUsageData: true) + XCTAssertEqual(event.context?["culture"]?["locale"] as? String, "es_PE") + } + + // MARK: - Boot transaction slimming (§9b.4) + + private func bootTransactionEvent() -> Event { + let event = Event() + event.type = "transaction" + event.transaction = "comapeo.boot" + event.tags = [ + SentryTags.proc: SentryTags.procMain, + SentryTags.layer: SentryTags.layerNative, + "boot.kind": "user-foreground", + ] + return event + } + + func testDiagnosticSlimsBootTransactionTagsToAllowlist() { + let event = SentryScopeTier.trimEvent( + bootTransactionEvent(), applicationUsageData: false + ) + XCTAssertEqual(event.tags?[SentryTags.proc], SentryTags.procMain) + XCTAssertEqual(event.tags?[SentryTags.layer], SentryTags.layerNative) + XCTAssertNil(event.tags?["boot.kind"]) + } + + func testUsageTierKeepsBootTransactionTags() { + let event = SentryScopeTier.trimEvent( + bootTransactionEvent(), applicationUsageData: true + ) + XCTAssertEqual(event.tags?["boot.kind"], "user-foreground") + } + + func testNonBootEventTagsAreUntouched() { + let event = Event() + event.tags = ["comapeo.phase": "rootkey"] + let processed = SentryScopeTier.trimEvent(event, applicationUsageData: false) + XCTAssertEqual(processed.tags?["comapeo.phase"], "rootkey") + } + + // MARK: - Boot span data stripping + + func testBootSpanDataIsStrippedAtDiagnosticOnly() { + XCTAssertTrue( + SentryScopeTier.shouldStripBootSpanData( + operation: "boot.rootkey-load", applicationUsageData: false + ) + ) + XCTAssertFalse( + SentryScopeTier.shouldStripBootSpanData( + operation: "boot.rootkey-load", applicationUsageData: true + ) + ) + XCTAssertFalse( + SentryScopeTier.shouldStripBootSpanData( + operation: "rpc.server", applicationUsageData: false + ) + ) + } +} diff --git a/src/sentry-scrub.ts b/src/sentry-scrub.ts index 86f6a4f4..001a2266 100644 --- a/src/sentry-scrub.ts +++ b/src/sentry-scrub.ts @@ -54,7 +54,10 @@ const SCRUB_PATTERNS: RegExp[] = [ /** 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. */ +/** Tag names/values that must never ride on a metric. The native metric + * paths keep hand-mirrored copies of this list in + * `android/src/main/java/com/comapeo/core/SentryMetricScrub.kt` and + * `ios/SentryMetricScrub.swift` — keep all four in lock-step. */ const FORBIDDEN_METRIC_TAG_NAMES = new Set([ "device.model", "device.id", From b810e082c480371d048a917caedb71ac7ae4ec17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:40:45 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix(sentry):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20errors=20keep=20full=20scope,=20lower=20buckets,=20?= =?UTF-8?q?quieter=20metric-drop=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review comments on the native-scope-tier work: - Error/fatal events now keep the SDK's full device/os/app scope instead of the per-tier allowlist — device context is most valuable exactly when something crashed. Only `culture` (locale + timezone), a fingerprint surface with no debugging value, is still dropped from them at the diagnostic tier. Transactions and non-error events are unchanged. (Android + iOS) - Storage-size buckets now start at 8 GB (8/16/32/…/1024) so devices with 8/16 GB of storage, which are still in the field, aren't rounded up to 32. - The "countMetric dropped: forbidden attribute" log drops from warn to debug on both platforms — an innocuous, expected drop that can recur often. Also inlines a redundant local in backend/lib/sentry.js and updates the per-tier field tables in the plan/integration docs. Adds per-platform tests for the error-scope exemption and the new low-end buckets. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XwJPDuhUABN1VftbXVZAb5 --- .../java/com/comapeo/core/SentryFgsBridge.kt | 3 +- .../java/com/comapeo/core/SentryScopeTier.kt | 23 +++++-- .../com/comapeo/core/SentryScopeTierTest.kt | 40 ++++++++++++- backend/lib/sentry.js | 3 +- docs/sentry-integration-plan.md | 9 ++- docs/sentry-integration.md | 2 +- ios/SentryNativeBridge.swift | 3 +- ios/SentryScopeTier.swift | 60 ++++++++++++------- ios/Tests/SentryScopeTierTests.swift | 32 +++++++++- 9 files changed, 140 insertions(+), 35 deletions(-) diff --git a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt index 1184076f..cf14c8db 100644 --- a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt +++ b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt @@ -226,7 +226,8 @@ object SentryFgsBridge { if (!initialized) return try { if (SentryMetricScrub.isForbiddenMetric(name, attributes)) { - Log.w(TAG, "countMetric($name) dropped: forbidden attribute") + // Debug, not warn: an innocuous, expected drop that can recur often. + Log.d(TAG, "countMetric($name) dropped: forbidden attribute") return } val attrs = attributes.entries diff --git a/android/src/main/java/com/comapeo/core/SentryScopeTier.kt b/android/src/main/java/com/comapeo/core/SentryScopeTier.kt index 989d18b7..16ace650 100644 --- a/android/src/main/java/com/comapeo/core/SentryScopeTier.kt +++ b/android/src/main/java/com/comapeo/core/SentryScopeTier.kt @@ -4,6 +4,7 @@ import io.sentry.EventProcessor import io.sentry.Hint import io.sentry.SentryBaseEvent import io.sentry.SentryEvent +import io.sentry.SentryLevel import io.sentry.protocol.App import io.sentry.protocol.Device import io.sentry.protocol.OperatingSystem @@ -21,13 +22,22 @@ import io.sentry.protocol.SentryTransaction * Contexts are rebuilt from an allowlist rather than nulling a denylist, so a * field added by a future SDK version is dropped by default instead of shipped * by accident. + * + * Error and fatal events are exempt: full device context is most valuable + * exactly when something crashed, so they keep the SDK's complete device/os/app + * scope. Only `culture` (locale + timezone), a fingerprint surface with no + * debugging value, is still dropped from them at the diagnostic tier. */ internal class TierScopeEventProcessor( private val applicationUsageData: Boolean, ) : EventProcessor { override fun process(event: SentryEvent, hint: Hint): SentryEvent { - trimContexts(event) + if (isError(event)) { + if (!applicationUsageData) event.contexts.remove("culture") + } else { + trimContexts(event) + } return event } @@ -39,6 +49,10 @@ internal class TierScopeEventProcessor( return transaction } + /** Error/fatal captures keep the full native scope — see the class doc. */ + private fun isError(event: SentryEvent): Boolean = + event.level == SentryLevel.ERROR || event.level == SentryLevel.FATAL + private fun trimContexts(event: SentryBaseEvent) { event.contexts.device?.let { event.contexts.setDevice(allowedDevice(it)) } event.contexts.operatingSystem?.let { @@ -103,10 +117,11 @@ internal class TierScopeEventProcessor( companion object { private const val GB = 1L shl 30 - /** Round up to a standard marketed size (32/64/…/1024 GB) so exact - * formatted-capacity bytes can't fingerprint a device. */ + /** Round up to a standard marketed size (8/16/32/…/1024 GB) so exact + * formatted-capacity bytes can't fingerprint a device. Starts at 8 GB + * because devices with 8/16 GB of storage are still in the field. */ internal fun bucketStorageSize(bytes: Long): Long { - var bucket = 32 * GB + var bucket = 8 * GB while (bucket < bytes && bucket < 1024 * GB) bucket = bucket shl 1 return bucket } diff --git a/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt index 17afb588..f4777cc9 100644 --- a/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt +++ b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt @@ -2,6 +2,7 @@ package com.comapeo.core import io.sentry.Hint import io.sentry.SentryEvent +import io.sentry.SentryLevel import io.sentry.SpanId import io.sentry.SpanStatus import io.sentry.protocol.App @@ -112,13 +113,50 @@ class SentryScopeTierTest { @Test fun bucketStorageSizeRoundsUpToStandardSizes() { val gb = 1L shl 30 - assertEquals(32 * gb, TierScopeEventProcessor.bucketStorageSize(1)) + // Buckets start at 8 GB — 8/16 GB devices are still in the field. + assertEquals(8 * gb, TierScopeEventProcessor.bucketStorageSize(1)) + assertEquals(8 * gb, TierScopeEventProcessor.bucketStorageSize(8 * gb)) + assertEquals(16 * gb, TierScopeEventProcessor.bucketStorageSize(8 * gb + 1)) assertEquals(32 * gb, TierScopeEventProcessor.bucketStorageSize(32 * gb)) assertEquals(64 * gb, TierScopeEventProcessor.bucketStorageSize(32 * gb + 1)) assertEquals(256 * gb, TierScopeEventProcessor.bucketStorageSize(238 * gb)) assertEquals(1024 * gb, TierScopeEventProcessor.bucketStorageSize(4096 * gb)) } + // ── Errors keep the full native scope ─────────────────────────── + + @Test + fun errorsKeepFullDeviceScopeButDropCultureAtDiagnostic() { + val event = SentryEvent().apply { + level = SentryLevel.ERROR + contexts.setDevice(fullDevice()) + contexts.setOperatingSystem(fullOs()) + contexts.setApp(fullApp()) + contexts.put("culture", mapOf("locale" to "es_PE")) + } + val processed = diagnostic.process(event, Hint())!! + // Fingerprint-friendly device/os/app fields survive on an error. + val device = processed.contexts.device!! + assertEquals("Maria's Pixel", device.name) + assertEquals(1080, device.screenWidthPixels) + assertNotNull(device.timezone) + // Storage stays exact (not bucketed) — full detail for debugging. + assertEquals(119L * (1L shl 30), device.storageSize) + assertEquals("5.15.104-android13-9-abc", processed.contexts.operatingSystem!!.kernelVersion) + assertEquals("CoMapeo", processed.contexts.app!!.appName) + // Culture still dropped — no debugging value. + assertNull(processed.contexts.get("culture")) + } + + @Test + fun errorsKeepCultureAtUsageTier() { + val event = SentryEvent().apply { + level = SentryLevel.FATAL + contexts.put("culture", mapOf("locale" to "es_PE")) + } + assertNotNull(usage.process(event, Hint())!!.contexts.get("culture")) + } + // ── OS context ────────────────────────────────────────────────── private fun fullOs() = OperatingSystem().apply { diff --git a/backend/lib/sentry.js b/backend/lib/sentry.js index 93f0c875..36729d42 100644 --- a/backend/lib/sentry.js +++ b/backend/lib/sentry.js @@ -192,10 +192,9 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame, storageDir } // §9b.6: fresh free-memory/free-disk numbers on every backend event. // Usage tier only; the processor re-checks the live config per capture // (registrations outlive re-init, so gating here would leak across inits). - const resolvedStorageDir = storageDir; Sentry.addEventProcessor( createNodeResourcesProcessor(() => - config?.applicationUsageData ? { storageDir: resolvedStorageDir } : null, + config?.applicationUsageData ? { storageDir } : null, ), ); diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md index ece1045e..61f93882 100644 --- a/docs/sentry-integration-plan.md +++ b/docs/sentry-integration-plan.md @@ -217,11 +217,16 @@ capture time. Re-specify against the native SDKs' `beforeSend` hook (filter scope fields per tier on the wire out), not against a Node-side context blob. The privacy goals still apply: -- **Diagnostic tier emits**: +- **Error/fatal events are exempt**: full device context is most valuable + exactly when something crashed, so error and fatal captures keep the SDK's + complete `device`/`os`/`app` scope at both tiers. Only `culture` (locale + + timezone) is still dropped from them at the diagnostic tier. The allowlist + below applies to transactions and non-error events. +- **Diagnostic tier emits** (non-error events): - `device`: `manufacturer`, `brand`, `model`, `model_id`, `family`, `arch`, `simulator`, `processor_count`, `memory_size`, `storage_size` (bucketed to standard sizes: - 32/64/128/256/512/1024 GB). + 8/16/32/64/128/256/512/1024 GB). - `os`: `name`, `version` only. **Drop** `kernel_version` (both), `build` (Android `Build.DISPLAY`). iOS `kern.osversion` redundant with `version`, drop too. diff --git a/docs/sentry-integration.md b/docs/sentry-integration.md index 3ea635b2..546bd37f 100644 --- a/docs/sentry-integration.md +++ b/docs/sentry-integration.md @@ -1397,7 +1397,7 @@ The diagnostic tier carries aggregate, low-cardinality operational signal; | 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. | | Backend `node_resources` event context (capture-time free memory / free disk) | applicationUsageData | Read-at-capture frequency reveals app activity. Attached by `backend/lib/node-resources.js`. | -| Native event scope extras (kernel version, OS build string, app name, locale + timezone, screen metrics, `boot.kind`, boot span data) | applicationUsageData | High-entropy fingerprint / usage-shape surfaces. The diagnostic tier keeps coarse device identity, OS name+version, and app id/version/build only (`SentryScopeTier.kt` / `SentryScopeTier.swift`). | +| Native event scope extras (kernel version, OS build string, app name, locale + timezone, screen metrics, `boot.kind`, boot span data) | applicationUsageData | High-entropy fingerprint / usage-shape surfaces. The diagnostic tier keeps coarse device identity, OS name+version, and app id/version/build only (`SentryScopeTier.kt` / `SentryScopeTier.swift`) — **except** error/fatal events, which keep the full device/os/app scope (device context matters most on a crash) and drop only `culture`. | | 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)).* | diff --git a/ios/SentryNativeBridge.swift b/ios/SentryNativeBridge.swift index 4a942210..99086441 100644 --- a/ios/SentryNativeBridge.swift +++ b/ios/SentryNativeBridge.swift @@ -109,7 +109,8 @@ enum SentryNativeBridge { /// false (it defaults to true). static func countMetric(_ key: String, value: UInt, attributes: [String: Any]) { if SentryMetricScrub.isForbiddenMetric(name: key, attributes: attributes) { - log("countMetric(\(key)) dropped: forbidden attribute", level: .warning) + // Debug, not warn: an innocuous, expected drop that can recur often. + log("countMetric(\(key)) dropped: forbidden attribute", level: .debug) return } var converted: [String: SentryAttributeValue] = [:] diff --git a/ios/SentryScopeTier.swift b/ios/SentryScopeTier.swift index eb36e1fa..43d6760f 100644 --- a/ios/SentryScopeTier.swift +++ b/ios/SentryScopeTier.swift @@ -13,6 +13,11 @@ import Sentry /// Contexts are filtered through an allowlist rather than deleting a /// denylist, so a field added by a future SDK version is dropped by default /// instead of shipped by accident. +/// +/// Error and fatal events are exempt: full device context is most valuable +/// exactly when something crashed, so they keep the SDK's complete +/// device/os/app scope. Only `culture` (locale + timezone), a fingerprint +/// surface with no debugging value, is still dropped from them at diagnostic. enum SentryScopeTier { private static let deviceKeysDiagnostic: Set = [ "manufacturer", "brand", "model", "model_id", "family", "arch", "archs", @@ -39,26 +44,36 @@ enum SentryScopeTier { /// `beforeSend` hook body. Mutates and returns the event. static func trimEvent(_ event: Event, applicationUsageData: Bool) -> Event { + // Transactions carry `.error`/`.none` levels inconsistently; gate on + // type so only genuine error/fatal captures skip the allowlist. + let isError = event.type != "transaction" + && (event.level == .error || event.level == .fatal) if var context = event.context { - trim(&context, key: "device", - keep: deviceKeysDiagnostic, usageExtra: deviceKeysUsageExtra, - applicationUsageData: applicationUsageData) - trim(&context, key: "os", - keep: osKeysDiagnostic, usageExtra: osKeysUsageExtra, - applicationUsageData: applicationUsageData) - trim(&context, key: "app", - keep: appKeysDiagnostic, usageExtra: appKeysUsageExtra, - applicationUsageData: applicationUsageData) - if !applicationUsageData { - // Locale + timezone are high-entropy fingerprint surfaces. - context["culture"] = nil - } - if var device = context["device"], - let storage = device["storage_size"] as? NSNumber { - device["storage_size"] = NSNumber( - value: bucketStorageSize(storage.int64Value) - ) - context["device"] = device + if isError { + // Errors keep the full native device/os/app scope; only culture + // (no debugging value) is dropped at the diagnostic tier. + if !applicationUsageData { context["culture"] = nil } + } else { + trim(&context, key: "device", + keep: deviceKeysDiagnostic, usageExtra: deviceKeysUsageExtra, + applicationUsageData: applicationUsageData) + trim(&context, key: "os", + keep: osKeysDiagnostic, usageExtra: osKeysUsageExtra, + applicationUsageData: applicationUsageData) + trim(&context, key: "app", + keep: appKeysDiagnostic, usageExtra: appKeysUsageExtra, + applicationUsageData: applicationUsageData) + if !applicationUsageData { + // Locale + timezone are high-entropy fingerprint surfaces. + context["culture"] = nil + } + if var device = context["device"], + let storage = device["storage_size"] as? NSNumber { + device["storage_size"] = NSNumber( + value: bucketStorageSize(storage.int64Value) + ) + context["device"] = device + } } event.context = context } @@ -94,10 +109,11 @@ enum SentryScopeTier { !applicationUsageData && operation.hasPrefix("boot.") } - /// Round up to a standard marketed size (32/64/…/1024 GB) so exact - /// formatted-capacity bytes can't fingerprint a device. + /// Round up to a standard marketed size (8/16/32/…/1024 GB) so exact + /// formatted-capacity bytes can't fingerprint a device. Starts at 8 GB + /// because devices with 8/16 GB of storage are still in the field. static func bucketStorageSize(_ bytes: Int64) -> Int64 { - var bucket = 32 * gb + var bucket = 8 * gb while bucket < bytes && bucket < 1024 * gb { bucket <<= 1 } diff --git a/ios/Tests/SentryScopeTierTests.swift b/ios/Tests/SentryScopeTierTests.swift index d1c64f4d..ada10519 100644 --- a/ios/Tests/SentryScopeTierTests.swift +++ b/ios/Tests/SentryScopeTierTests.swift @@ -106,13 +106,43 @@ final class SentryScopeTierTests: XCTestCase { func testBucketStorageSizeRoundsUpToStandardSizes() { let gb: Int64 = 1 << 30 - XCTAssertEqual(SentryScopeTier.bucketStorageSize(1), 32 * gb) + // Buckets start at 8 GB — 8/16 GB devices are still in the field. + XCTAssertEqual(SentryScopeTier.bucketStorageSize(1), 8 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(8 * gb), 8 * gb) + XCTAssertEqual(SentryScopeTier.bucketStorageSize(8 * gb + 1), 16 * gb) XCTAssertEqual(SentryScopeTier.bucketStorageSize(32 * gb), 32 * gb) XCTAssertEqual(SentryScopeTier.bucketStorageSize(32 * gb + 1), 64 * gb) XCTAssertEqual(SentryScopeTier.bucketStorageSize(238 * gb), 256 * gb) XCTAssertEqual(SentryScopeTier.bucketStorageSize(4096 * gb), 1024 * gb) } + // MARK: - Errors keep the full native scope + + func testErrorsKeepFullDeviceScopeButDropCultureAtDiagnostic() { + let event = fullEvent() + event.level = .error + let processed = SentryScopeTier.trimEvent(event, applicationUsageData: false) + // Fingerprint-friendly device/os/app fields survive on an error. + let device = processed.context?["device"] + XCTAssertEqual(device?["locale"] as? String, "es_PE") + XCTAssertEqual(device?["screen_width_pixels"] as? Int, 1170) + XCTAssertNotNil(device?["thermal_state"]) + // Storage stays exact (not bucketed) — full detail for debugging. + XCTAssertEqual((device?["storage_size"] as? NSNumber)?.int64Value, Int64(119) * (1 << 30)) + XCTAssertEqual(processed.context?["os"]?["kernel_version"] as? String, + "Darwin Kernel Version 23.5.0") + XCTAssertEqual(processed.context?["app"]?["app_name"] as? String, "CoMapeo") + // Culture still dropped — no debugging value. + XCTAssertNil(processed.context?["culture"]) + } + + func testErrorsKeepCultureAtUsageTier() { + let event = fullEvent() + event.level = .fatal + let processed = SentryScopeTier.trimEvent(event, applicationUsageData: true) + XCTAssertEqual(processed.context?["culture"]?["locale"] as? String, "es_PE") + } + // MARK: - OS / app / culture contexts func testDiagnosticKeepsOsNameAndVersionOnly() { From 8e198bb6668126862e4716e299e3b4b434cd4ace Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:49:11 +0000 Subject: [PATCH 3/6] fix(sentry): qualify global log() in SentryNativeBridge.countMetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inside the `SentryNativeBridge` enum an unqualified `log(...)` binds to the enum's own static `log(level:message:)` member, which shadows the global `Log.swift` helper and doesn't match the `(_ message:, level:)` labels — a pre-existing compile error in the metric-scrub drop path that broke the Swift Package Tests. Module-qualify the call as `ComapeoCore.log(...)`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XwJPDuhUABN1VftbXVZAb5 --- ios/SentryNativeBridge.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/SentryNativeBridge.swift b/ios/SentryNativeBridge.swift index 99086441..47eba346 100644 --- a/ios/SentryNativeBridge.swift +++ b/ios/SentryNativeBridge.swift @@ -110,7 +110,9 @@ enum SentryNativeBridge { static func countMetric(_ key: String, value: UInt, attributes: [String: Any]) { if SentryMetricScrub.isForbiddenMetric(name: key, attributes: attributes) { // Debug, not warn: an innocuous, expected drop that can recur often. - log("countMetric(\(key)) dropped: forbidden attribute", level: .debug) + // Module-qualified: an unqualified `log` binds to this enum's own + // static `log(level:message:)`, not the global os_log helper. + ComapeoCore.log("countMetric(\(key)) dropped: forbidden attribute", level: .debug) return } var converted: [String: SentryAttributeValue] = [:] From f736e1f6520060612c8d4820ed7b537bf4220730 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:59:11 +0000 Subject: [PATCH 4/6] fix(sentry): annotate SentrySpan test data maps to satisfy Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compileDebugUnitTestKotlin` rejected `mutableMapOf("k" to )` passed to sentry-java's `SentrySpan(..., data: Map?)` — the inferred `MutableMap` / `` don't match the platform type's invariant lower bound, so Kotlin demands an explicit type. Annotate both call sites as `mutableMapOf(...)`. Pre-existing in the new test file; surfaced once the Swift build stopped short-circuiting the run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XwJPDuhUABN1VftbXVZAb5 --- android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt index f4777cc9..56a756bb 100644 --- a/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt +++ b/android/src/test/java/com/comapeo/core/SentryScopeTierTest.kt @@ -249,7 +249,7 @@ class SentryScopeTierTest { /* origin = */ null, /* tags = */ emptyMap(), /* measurements = */ emptyMap(), - /* data = */ mutableMapOf("generated" to true), + /* data = */ mutableMapOf("generated" to true), ) return SentryTransaction( /* transaction = */ "comapeo.boot", @@ -291,7 +291,7 @@ class SentryScopeTierTest { val span = SentrySpan( 0.0, 1.0, SentryId(), SpanId(), null, "rpc.server", "project.observation.create", SpanStatus.OK, null, - emptyMap(), emptyMap(), mutableMapOf("rpc.system" to "comapeo-ipc"), + emptyMap(), emptyMap(), mutableMapOf("rpc.system" to "comapeo-ipc"), ) val tx = SentryTransaction( "project.observation.create", 0.0, 2.0, listOf(span), From 58978f48e70ba988d6581e7a19c639d94b1997b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 16:06:47 +0000 Subject: [PATCH 5/6] test(sentry): return default values for android stubs in JVM unit tests `SentryFgsBridgeTest.countMetricWithForbiddenAttributeIsDropped` throws because the forbidden-attribute drop path calls `android.util.Log`, whose android.jar stub throws in JVM unit tests (and the bridge's catch then re-throws via a second Log call). The module never set `returnDefaultValues`, so this only passed while a sibling test file's compile error kept the suite from running. Enable `testOptions.unitTests.returnDefaultValues` so Log no-ops; the real org.json / sentry-android test deps still take precedence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XwJPDuhUABN1VftbXVZAb5 --- android/build.gradle | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/android/build.gradle b/android/build.gradle index a2e11f5f..be933694 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -90,6 +90,16 @@ android { lintOptions { abortOnError false } + testOptions { + unitTests { + // android.util.Log and other android.jar stubs throw by default in + // JVM unit tests. Return no-op defaults so code paths that log on a + // non-exceptional branch (e.g. countMetric's forbidden-attribute + // drop) are testable. Real test deps (org.json, sentry-android) + // provide actual classes and still take precedence. + returnDefaultValues = true + } + } sourceSets { main { // `libnode/bin/` holds the prebuilt nodejs-mobile libnode.so per ABI. From a70b3a852e121934bef5bf6ac30ff5fa89842779 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 16:12:29 +0000 Subject: [PATCH 6/6] refactor(sentry): drop the forbidden-metric log on the native paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forbidden-attribute gate in `countMetric` fires on an expected, innocuous condition, so the drop doesn't warrant a log — and on iOS the global `log()` helper forwarded it to Sentry's log pipeline, adding noise on a path whose job is to keep data off the wire. Drop it silently on both platforms. This also removes the need for the earlier test-config change: with no `android.util.Log` call on the drop path, `SentryFgsBridgeTest`'s forbidden-metric test no longer trips the unmocked-stub throw, so the `testOptions.unitTests.returnDefaultValues` addition is reverted. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XwJPDuhUABN1VftbXVZAb5 --- android/build.gradle | 10 ---------- .../src/main/java/com/comapeo/core/SentryFgsBridge.kt | 8 +++----- ios/SentryNativeBridge.swift | 10 +++------- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index be933694..a2e11f5f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -90,16 +90,6 @@ android { lintOptions { abortOnError false } - testOptions { - unitTests { - // android.util.Log and other android.jar stubs throw by default in - // JVM unit tests. Return no-op defaults so code paths that log on a - // non-exceptional branch (e.g. countMetric's forbidden-attribute - // drop) are testable. Real test deps (org.json, sentry-android) - // provide actual classes and still take precedence. - returnDefaultValues = true - } - } sourceSets { main { // `libnode/bin/` holds the prebuilt nodejs-mobile libnode.so per ABI. diff --git a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt index cf14c8db..d71362fa 100644 --- a/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt +++ b/android/src/main/java/com/comapeo/core/SentryFgsBridge.kt @@ -225,11 +225,9 @@ object SentryFgsBridge { ) { if (!initialized) return try { - if (SentryMetricScrub.isForbiddenMetric(name, attributes)) { - // Debug, not warn: an innocuous, expected drop that can recur often. - Log.d(TAG, "countMetric($name) dropped: forbidden attribute") - return - } + // Silently drop a metric carrying a forbidden name/attribute — an + // expected, innocuous gate that isn't worth a log line. + if (SentryMetricScrub.isForbiddenMetric(name, attributes)) return val attrs = attributes.entries .map { (k, v) -> SentryAttribute.stringAttribute(k, v) } .toTypedArray() diff --git a/ios/SentryNativeBridge.swift b/ios/SentryNativeBridge.swift index 47eba346..c870ac7a 100644 --- a/ios/SentryNativeBridge.swift +++ b/ios/SentryNativeBridge.swift @@ -108,13 +108,9 @@ enum SentryNativeBridge { /// not started, and drops with a log when `options.enableMetrics` is /// false (it defaults to true). static func countMetric(_ key: String, value: UInt, attributes: [String: Any]) { - if SentryMetricScrub.isForbiddenMetric(name: key, attributes: attributes) { - // Debug, not warn: an innocuous, expected drop that can recur often. - // Module-qualified: an unqualified `log` binds to this enum's own - // static `log(level:message:)`, not the global os_log helper. - ComapeoCore.log("countMetric(\(key)) dropped: forbidden attribute", level: .debug) - return - } + // Silently drop a metric carrying a forbidden name/attribute — an + // expected, innocuous gate that isn't worth a log line. + if SentryMetricScrub.isForbiddenMetric(name: key, attributes: attributes) { return } var converted: [String: SentryAttributeValue] = [:] for (k, v) in attributes { switch v {