diff --git a/android/src/main/java/com/comapeo/core/BackgroundAnchors.kt b/android/src/main/java/com/comapeo/core/BackgroundAnchors.kt index 75855c4..f90bb53 100644 --- a/android/src/main/java/com/comapeo/core/BackgroundAnchors.kt +++ b/android/src/main/java/com/comapeo/core/BackgroundAnchors.kt @@ -15,6 +15,11 @@ import androidx.core.content.edit * process wrote since this process loaded the file). The FGS process only * READS the `main` slots, opening the file at its own cold start, which is * fresh enough: the values it needs were written before it was spawned. + * Sole exception: [resetExitTelemetryAnchors] (main process, toggle + * re-enable) also writes the `fgs` slots — the FGS only writes its own file + * in a burst at its cold start, so a reset racing that narrow window (and + * being reverted by the FGS's stale cached map) is a tolerable best-effort + * gap. * * Constructor takes read/write lambdas to keep tests free of * `SharedPreferences` (unmocked on the JVM unit-test classpath). [open] is @@ -52,6 +57,24 @@ internal class BackgroundAnchors( writeLong(proc, KEY_LAST_SEEN, wallMs) } + /** + * Reset the exit-telemetry anchors to [nowMs] on a diagnostics / + * application-usage-data off → on flip (§9b.9): the high-water marks so + * exits recorded during the opted-out window are never reported, and the + * duration anchors so post-re-enable exits can't report durations + * spanning it. `backgrounded_at` stays — the fresh `foregrounded_at` + * already neutralises it for later exits (the user is in the foreground + * to flip the toggle), and equal stamps would misread as "in background + * since the reset". + */ + fun resetExitTelemetryAnchors(nowMs: Long) { + for (proc in listOf(SentryTags.PROC_MAIN, SentryTags.PROC_FGS)) { + writeLastSeenMs(proc, nowMs) + writeProcessStartedAtMs(proc, nowMs) + } + writeForegroundedAtMs(SentryTags.PROC_MAIN, nowMs) + } + companion object { const val KEY_PROCESS_STARTED_AT = "process_started_at_wall_ms" const val KEY_BACKGROUNDED_AT = "backgrounded_at_wall_ms" diff --git a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt index c329928..59845ae 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt @@ -245,14 +245,19 @@ class ComapeoCoreModule : Module() { // Restart-to-activate: writes to disk; on `false`, wipes the sentry-android // envelope cache so queued events never ship. The current process keeps - // emitting in-memory until next launch. + // emitting in-memory until next launch. On an off → on flip the + // exit-telemetry anchors reset to "now" so exit records from the + // opted-out window are never reported after re-enable. AsyncFunction("setDiagnosticsEnabled") { value: Boolean -> val ctx = appContext.reactContext ?: throw IllegalStateException( "setDiagnosticsEnabled called before native context attached", ) - ComapeoPrefs.open(ctx).writeDiagnosticsEnabled(value) + val reEnabled = ComapeoPrefs.open(ctx).writeDiagnosticsEnabled(value) if (!value) ComapeoPrefs.wipeSentryOutbox(ctx) + if (reEnabled) { + BackgroundAnchors.open(ctx).resetExitTelemetryAnchors(System.currentTimeMillis()) + } } AsyncFunction("setApplicationUsageData") { value: Boolean -> @@ -260,8 +265,11 @@ class ComapeoCoreModule : Module() { ?: throw IllegalStateException( "setApplicationUsageData called before native context attached", ) - ComapeoPrefs.open(ctx).writeApplicationUsageData(value) + val reEnabled = ComapeoPrefs.open(ctx).writeApplicationUsageData(value) if (!value) ComapeoPrefs.wipeSentryOutbox(ctx) + if (reEnabled) { + BackgroundAnchors.open(ctx).resetExitTelemetryAnchors(System.currentTimeMillis()) + } } AsyncFunction("setDebugEnabled") { value: Boolean -> diff --git a/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt b/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt index 8379b8a..0d26e2c 100644 --- a/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt +++ b/android/src/main/java/com/comapeo/core/ComapeoPrefs.kt @@ -45,8 +45,15 @@ internal class ComapeoPrefs( fun readDiagnosticsEnabled(): Boolean = store.getBoolean(KEY_DIAGNOSTICS_ENABLED) ?: defaults.diagnosticsEnabled - fun writeDiagnosticsEnabled(value: Boolean) { + /** + * Write the toggle. Returns `true` when this flip re-enabled it + * (off → on) — the caller must then reset the exit-telemetry anchors so + * exit records from the opted-out window are never reported (§9b.9). + */ + fun writeDiagnosticsEnabled(value: Boolean): Boolean { + val reEnabled = value && !readDiagnosticsEnabled() store.putBoolean(KEY_DIAGNOSTICS_ENABLED, value) + return reEnabled } fun readApplicationUsageData(): Boolean = @@ -61,8 +68,12 @@ internal class ComapeoPrefs( fun readDebugStored(): Boolean = store.getBoolean(KEY_DEBUG) ?: defaults.debug - fun writeApplicationUsageData(value: Boolean) { + /** Write the toggle; returns `true` on an off → on re-enable, like + * [writeDiagnosticsEnabled]. */ + fun writeApplicationUsageData(value: Boolean): Boolean { + val reEnabled = value && !readApplicationUsageData() store.putBoolean(KEY_APPLICATION_USAGE_DATA, value) + return reEnabled } /** diff --git a/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt b/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt index a2add25..0f86d40 100644 --- a/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt +++ b/android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt @@ -94,6 +94,28 @@ class ComapeoPrefsTest { assertTrue(p.readApplicationUsageData()) } + @Test + fun writeDiagnosticsEnabledReportsOnlyOffToOnTransitions() { + // The re-enable signal drives the exit-telemetry anchor reset: a + // false positive would wipe legitimately pending exit records, a + // false negative would leak opted-out-window records to Sentry. + val p = prefs(FakeStore(), diagnosticsDefault = true) + assertFalse("on → on (default on) is not a re-enable", p.writeDiagnosticsEnabled(true)) + assertFalse("on → off is not a re-enable", p.writeDiagnosticsEnabled(false)) + assertTrue("off → on is a re-enable", p.writeDiagnosticsEnabled(true)) + assertFalse("redundant on → on is not a re-enable", p.writeDiagnosticsEnabled(true)) + } + + @Test + fun writeApplicationUsageDataReportsOnlyOffToOnTransitions() { + // Usage data defaults off, so the very first enable is a re-enable. + val p = prefs(FakeStore(), usageDefault = false) + assertTrue("default-off → on is a re-enable", p.writeApplicationUsageData(true)) + assertFalse("redundant on → on is not a re-enable", p.writeApplicationUsageData(true)) + assertFalse("on → off is not a re-enable", p.writeApplicationUsageData(false)) + assertTrue("off → on is a re-enable", p.writeApplicationUsageData(true)) + } + @Test fun debugAutoOffBoundaries() { // fresh enable true; just within window true; just past window false + cleared. diff --git a/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt b/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt index 8085e01..83970a9 100644 --- a/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt +++ b/android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt @@ -121,6 +121,64 @@ class ExitReasonsCollectorTest { assertEquals(now - 100_000 + 15_000, result.newLastSeenMs) } + // ── Toggle-cycle anchor reset (§9b.9) ────────────────────────── + // + // A diagnostics / usage-data off → on flip resets the anchors to the + // flip time, so exits recorded while the user had opted out are never + // reported and post-re-enable durations can't span the off window. + + @Test + fun offWindowRecordsAreNotReportedAfterReEnable() { + seedLastSeen(MAIN, now - 1_000_000) // high-water from before the off window + val reEnabledAt = now - 100_000 + val duringOffWindow = record(timestampMs = reEnabledAt - 50_000) + val afterReEnable = record(timestampMs = reEnabledAt + 50_000) + + anchors.resetExitTelemetryAnchors(reEnabledAt) + + val metrics = collectMetrics(records = listOf(duringOffWindow, afterReEnable)) + assertEquals(1, metrics.size) + assertEquals(reEnabledAt + 50_000, metrics.single().attributes["exit_timestamp_ms"]) + } + + @Test + fun resetAppliesToBothProcessSlots() { + seedLastSeen(FGS, now - 1_000_000) + val reEnabledAt = now - 100_000 + anchors.resetExitTelemetryAnchors(reEnabledAt) + val metrics = collectMetrics( + procKey = FGS, + processName = FGS_PROC_NAME, + records = listOf( + record(processName = FGS_PROC_NAME, timestampMs = reEnabledAt - 50_000), + ), + ) + assertTrue("off-window FGS record must not be reported", metrics.isEmpty()) + assertEquals(reEnabledAt, anchors.readLastSeenMs(FGS)) + } + + @Test + fun resetTruncatesDurationsToTheReEnableTime() { + seedLastSeen(MAIN, now - 1_000_000) + val reEnabledAt = now - 300_000 + // Anchors stamped during the off window. + anchors.writeProcessStartedAtMs(MAIN, reEnabledAt - 3_600_000) + anchors.writeBackgroundedAtMs(MAIN, reEnabledAt - 600_000) + + anchors.resetExitTelemetryAnchors(reEnabledAt) + + val attrs = collectMetrics(records = listOf(record(timestampMs = reEnabledAt + 120_000))) + .single().attributes + // alive_for measured from the re-enable, not the off-window start. + assertEquals(120_000L, attrs["alive_for_ms"]) + assertEquals("1-5m", attrs[SentryTags.UPTIME_BUCKET]) + // The off-window backgrounded_at is neutralised by the fresh + // foregrounded_at stamp — no background duration spanning the + // opted-out period. + assertEquals("unknown", attrs[SentryTags.BG_DURATION_BUCKET]) + assertFalse(attrs.containsKey("backgrounded_for_ms")) + } + // ── Attribute mapping ────────────────────────────────────────── @Test diff --git a/docs/sentry-integration-plan.md b/docs/sentry-integration-plan.md index b774243..3de1b7a 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; toggle anchor resets (9b.9) landed separately. Remaining: native-scope field split (9b.3), boot-transaction slimming (9b.4), backend free-mem refresh (9b.6). | | 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. | --- @@ -308,11 +308,17 @@ diagnostic. See `sentry-integration.md` §7.5. ### 9b.9 Phase 6 timestamp anchor reset on toggle cycle -When `diagnosticsEnabled` flips `false → true`, Phase 6's -`lastSeenAtMs` high-water key resets to `currentTimeMillis()` so -records generated during the "off" window are NOT surfaced on -re-enable. Same behaviour for `captureApplicationData` and the -duration-anchor keys. Simple per-toggle hook on the setter path. +**Done.** When `diagnosticsEnabled` or `applicationUsageData` flips +`false → true`, the setter resets the exit-telemetry anchors to "now" +so records generated during the "off" window are never surfaced on +re-enable. Android: `BackgroundAnchors.resetExitTelemetryAnchors` — the +per-process high-water marks plus the duration anchors +(`process_started_at`, main's `foregrounded_at`). iOS: `ComapeoPrefs` +stamps `sentry.exitTelemetryResetAtMs`; `AppExitMetricsCollector` +drops MetricKit windows that began before the stamp (a 24h aggregate +can't be split, so an overlapping window is dropped whole). Only an +off → on transition resets — redundant sets and disables leave the +anchors alone. See `sentry-integration.md` §7.5. --- diff --git a/docs/sentry-integration.md b/docs/sentry-integration.md index 10fb7fe..c49129a 100644 --- a/docs/sentry-integration.md +++ b/docs/sentry-integration.md @@ -1137,6 +1137,17 @@ no issue lifecycle, so nothing sits unresolved in the Issues UI and nothing fires regression alerts. Attributes carry the slice axes; query in Sentry's Explore UI. Breadcrumb category: `comapeo.exit`. +Toggle-cycle reset: when `diagnosticsEnabled` or `applicationUsageData` +flips off → on, the setter resets the exit-telemetry anchors to "now" so +exits recorded while the user had opted out are never reported after +re-enable. Android resets the per-process high-water marks and duration +anchors (`BackgroundAnchors.resetExitTelemetryAnchors`); iOS stamps +`sentry.exitTelemetryResetAtMs` in `ComapeoPrefs` and the collector +drops MetricKit windows that began before it (24h aggregates can't be +split, so an overlapping window is dropped whole). Only the off → on +transition resets; redundant sets and disables leave the anchors +untouched. + #### 7.5.1 Android — historical exit reasons (`ExitReasonsCollector.kt`) On each process start (API 30+ only; pre-30 sets a one-time scope tag diff --git a/ios/AppExitMetricsCollector.swift b/ios/AppExitMetricsCollector.swift index b74c364..a15d625 100644 --- a/ios/AppExitMetricsCollector.swift +++ b/ios/AppExitMetricsCollector.swift @@ -30,6 +30,15 @@ enum AppExitDecoder { let attributes: [String: Any] } + /// Whether a payload window may be reported given the exit-telemetry + /// reset anchor (stamped by `ComapeoPrefs` on a toggle off → on flip). + /// A 24h window that began before the anchor aggregates exits from the + /// opted-out period and can't be split, so it is dropped whole. + static func windowIsReportable(windowStart: Date, resetAtMs: Double?) -> Bool { + guard let resetAtMs else { return true } + return windowStart.timeIntervalSince1970 * 1000 >= resetAtMs + } + static func metrics(from payload: AppExitPayloadData) -> [Metric] { var out: [Metric] = [] for (cohort, counts) in [ @@ -159,7 +168,15 @@ final class AppExitMetricsCollector: NSObject, MXMetricManagerSubscriber { } func didReceive(_ payloads: [MXMetricPayload]) { + let resetAtMs = ComapeoPrefs.open().readExitTelemetryResetAtMs() for payload in payloads { + guard AppExitDecoder.windowIsReportable( + windowStart: payload.timeStampBegin, + resetAtMs: resetAtMs + ) else { + log("AppExitMetricsCollector: dropping window overlapping an opted-out period") + continue + } // Optional: iOS delivers payloads with no exit data on quiet days. guard let exits = payload.applicationExitMetrics else { continue } let foreground = exits.foregroundExitData diff --git a/ios/ComapeoPrefs.swift b/ios/ComapeoPrefs.swift index b5037e4..1bd57c7 100644 --- a/ios/ComapeoPrefs.swift +++ b/ios/ComapeoPrefs.swift @@ -87,14 +87,32 @@ final class ComapeoPrefs { return true } + /// Write the toggle. An off → on flip stamps the exit-telemetry reset + /// anchor so MetricKit exit windows covering the opted-out period are + /// never reported after re-enable (§9b.9). func writeDiagnosticsEnabled(_ value: Bool) { + if value && !readDiagnosticsEnabled() { + store.setDouble(Key.exitTelemetryResetAtMs, now()) + } store.setBool(Key.diagnosticsEnabled, value) } + /// Write the toggle; stamps the exit-telemetry reset anchor on an + /// off → on flip, like `writeDiagnosticsEnabled`. func writeApplicationUsageData(_ value: Bool) { + if value && !readApplicationUsageData() { + store.setDouble(Key.exitTelemetryResetAtMs, now()) + } store.setBool(Key.applicationUsageData, value) } + /// Wall-clock ms of the most recent toggle re-enable; `nil` when no + /// toggle has ever cycled off → on. `AppExitMetricsCollector` drops + /// MetricKit payloads whose window began before this. + func readExitTelemetryResetAtMs() -> Double? { + store.getDouble(Key.exitTelemetryResetAtMs) + } + /// 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 @@ -136,6 +154,7 @@ final class ComapeoPrefs { static let debug = "sentry.debug" static let debugEnabledAtMs = "sentry.debugEnabledAtMs" static let rootUserId = "sentry.rootUserId" + static let exitTelemetryResetAtMs = "sentry.exitTelemetryResetAtMs" } /// 72h in milliseconds — debug mode auto-disables this long after enable. diff --git a/ios/Tests/AppExitMetricsCollectorTests.swift b/ios/Tests/AppExitMetricsCollectorTests.swift index 034e518..783ef51 100644 --- a/ios/Tests/AppExitMetricsCollectorTests.swift +++ b/ios/Tests/AppExitMetricsCollectorTests.swift @@ -158,6 +158,38 @@ final class AppExitMetricsCollectorTests: XCTestCase { XCTAssertEqual(metric.attributes[SentryTags.exitIntentional] as? Bool, false) } + // The reset anchor is stamped by ComapeoPrefs on a toggle off → on + // flip; a 24h window that began before it aggregates exits from the + // opted-out period and must be dropped whole. + + func testWindowBeginningBeforeResetAnchorIsNotReportable() { + let anchorMs = windowStart.timeIntervalSince1970 * 1000 + 1 + XCTAssertFalse( + AppExitDecoder.windowIsReportable(windowStart: windowStart, resetAtMs: anchorMs), + "window overlapping the opted-out period must be dropped" + ) + } + + func testWindowAtOrAfterResetAnchorIsReportable() { + let startMs = windowStart.timeIntervalSince1970 * 1000 + XCTAssertTrue( + AppExitDecoder.windowIsReportable(windowStart: windowStart, resetAtMs: startMs) + ) + XCTAssertTrue( + AppExitDecoder.windowIsReportable( + windowStart: windowStart, + resetAtMs: startMs - 60_000 + ), + "window beginning after the re-enable is reported" + ) + } + + func testNoResetAnchorReportsEveryWindow() { + XCTAssertTrue( + AppExitDecoder.windowIsReportable(windowStart: windowStart, resetAtMs: nil) + ) + } + func testMissingMetadataOmitsVersionAttributes() { let metrics = AppExitDecoder.metrics( from: payload( diff --git a/ios/Tests/ComapeoPrefsTests.swift b/ios/Tests/ComapeoPrefsTests.swift index 7451e94..11de5cf 100644 --- a/ios/Tests/ComapeoPrefsTests.swift +++ b/ios/Tests/ComapeoPrefsTests.swift @@ -85,6 +85,44 @@ final class ComapeoPrefsTests: XCTestCase { XCTAssertTrue(p.readApplicationUsageData()) } + func testDiagnosticsReEnableStampsExitTelemetryResetAnchor() { + // The anchor drives the MetricKit exit-window filter: a missed + // stamp would report exit windows from the opted-out period. + let store = FakeStore() + let clock = Clock() + let p = prefs(store: store, clock: clock) + + clock.nowMs = 1_000 + p.writeDiagnosticsEnabled(true) // on → on (default on): no stamp + XCTAssertNil(p.readExitTelemetryResetAtMs(), "redundant enable must not stamp") + + p.writeDiagnosticsEnabled(false) + XCTAssertNil(p.readExitTelemetryResetAtMs(), "disable must not stamp") + + clock.nowMs = 2_000 + p.writeDiagnosticsEnabled(true) // off → on: stamp + XCTAssertEqual(p.readExitTelemetryResetAtMs(), 2_000) + + clock.nowMs = 3_000 + p.writeDiagnosticsEnabled(true) // on → on: keep the earlier stamp + XCTAssertEqual(p.readExitTelemetryResetAtMs(), 2_000) + } + + func testUsageDataReEnableStampsExitTelemetryResetAnchor() { + let store = FakeStore() + let clock = Clock() + let p = prefs(store: store, clock: clock) + + clock.nowMs = 500 + p.writeApplicationUsageData(true) // default-off → on: stamp + XCTAssertEqual(p.readExitTelemetryResetAtMs(), 500) + + clock.nowMs = 600 + p.writeApplicationUsageData(false) + p.writeApplicationUsageData(true) // off → on again: fresh stamp + XCTAssertEqual(p.readExitTelemetryResetAtMs(), 600) + } + func testDebugAutoOffBoundaries() { // fresh enable true; just within window true; just past window false + cleared. let store = FakeStore() diff --git a/src/sentry.ts b/src/sentry.ts index 6d27277..e6068e6 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -158,7 +158,9 @@ export function getDiagnosticsEnabled(): boolean { * wiped. The current process keeps emitting events until the next * launch; this is the documented restart-to-activate behaviour, but * those events sit in an outbox we've just wiped, so they never - * upload. + * upload. An off → on flip also resets the app-exit telemetry + * anchors, so exits recorded while the user had opted out are never + * reported after re-enable. */ export function setDiagnosticsEnabled(value: boolean): Promise { // Update the in-memory view only after the native write lands — a