Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions android/src/main/java/com/comapeo/core/BackgroundAnchors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
14 changes: 11 additions & 3 deletions android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -245,23 +245,31 @@ 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 ->
val ctx = appContext.reactContext
?: 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 ->
Expand Down
15 changes: 13 additions & 2 deletions android/src/main/java/com/comapeo/core/ComapeoPrefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
}

/**
Expand Down
22 changes: 22 additions & 0 deletions android/src/test/java/com/comapeo/core/ComapeoPrefsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 58 additions & 0 deletions android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions docs/sentry-integration-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---
Expand Down Expand Up @@ -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.

---

Expand Down
11 changes: 11 additions & 0 deletions docs/sentry-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions ios/AppExitMetricsCollector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions ios/ComapeoPrefs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions ios/Tests/AppExitMetricsCollectorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading