From b8017aae71f9f193a2151ad1a4503a79bec5a11a Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 7 Jul 2026 21:49:57 +0100 Subject: [PATCH 1/2] feat(sentry): usage-tier sync-session transaction, sync metrics wiring, bg/fg breadcrumbs Derive sync sessions from the data.isSyncEnabled edges of each project's $sync sync-state events: a comapeo.sync.session transaction (usage-tier, sampled via a name-matched tracesSampler so it records while the base trace rate is 0) with discover/replicate child spans, carrying only bucketed peer count, bucketed bytes, and outcome. The same lifecycle wires the previously call-site-less metrics.syncSession emitter (duration at diagnostic tier, peers/bytes buckets usage-gated). RN side adds comapeo.app.background/foreground breadcrumbs from AppState changes, usage-gated. Closes #80. --- backend/index.js | 3 + backend/lib/metrics.js | 2 + backend/lib/metrics.test.mjs | 1 + backend/lib/sentry.js | 80 +++++++- backend/lib/sync-observer.js | 146 ++++++++++++++ backend/lib/sync-observer.test.mjs | 311 +++++++++++++++++++++++++++++ docs/sentry-integration.md | 6 +- src/__tests__/sentry.test.js | 65 +++++- src/sentry.ts | 43 +++- 9 files changed, 641 insertions(+), 16 deletions(-) create mode 100644 backend/lib/sync-observer.js create mode 100644 backend/lib/sync-observer.test.mjs diff --git a/backend/index.js b/backend/index.js index de72846..9f06388 100644 --- a/backend/index.js +++ b/backend/index.js @@ -9,6 +9,7 @@ import { createMapServer } from "./lib/create-map-server.js"; import { SimpleRpcServer } from "./lib/simple-rpc.js"; import * as sentry from "./lib/sentry.js"; import * as metrics from "./lib/metrics.js"; +import { observeSyncSessions } from "./lib/sync-observer.js"; // 60s sampler cadence for backend memory + uptime gauges. No-op // when Sentry is off (the metrics layer never got its SDK). @@ -248,6 +249,8 @@ async function withPhase(phase, fn) { rootKey, }); + observeSyncSessions(comapeoManager); + // Start the MapeoManager's Fastify so its blob/icon server is // reachable. `$blobs.getUrl()` / `$icons.getUrl()` await the server's // address (5s timeout, else AbortError). Non-fatal: surface listen diff --git a/backend/lib/metrics.js b/backend/lib/metrics.js index 89dd16d..b58342f 100644 --- a/backend/lib/metrics.js +++ b/backend/lib/metrics.js @@ -249,6 +249,8 @@ export function telemetryForwardingFailure() { /** @param {number} peers @returns {string} */ export function peersBucket(peers) { + // A session can end without any peer ever connecting. + if (peers <= 0) return "0"; if (peers <= 3) return "1-3"; if (peers <= 10) return "4-10"; return "10+"; diff --git a/backend/lib/metrics.test.mjs b/backend/lib/metrics.test.mjs index 1798161..60fa5c3 100644 --- a/backend/lib/metrics.test.mjs +++ b/backend/lib/metrics.test.mjs @@ -152,6 +152,7 @@ test("before_metric_send drops forbidden tag VALUES (lat/lng shape)", () => { }); test("bucketing helpers match the spec thresholds", () => { + assert.equal(metrics.peersBucket(0), "0"); assert.equal(metrics.peersBucket(1), "1-3"); assert.equal(metrics.peersBucket(4), "4-10"); assert.equal(metrics.peersBucket(50), "10+"); diff --git a/backend/lib/sentry.js b/backend/lib/sentry.js index 05e2649..ce95619 100644 --- a/backend/lib/sentry.js +++ b/backend/lib/sentry.js @@ -59,6 +59,9 @@ let Sentry = null; /** @type {{ rpcArgsBytes: number, applicationUsageData: boolean, debug: boolean, deviceClass: string, osMajor: string, platformTag: string } | null} */ let config = null; +/** Root span name for the usage-tier sync-session transaction. */ +export const SYNC_SESSION_TRANSACTION = "comapeo.sync.session"; + /** Read-only view of the resolved config for the metrics layer. */ export function getConfig() { return config; @@ -132,18 +135,28 @@ export function init({ Sentry: sdk, argv, envelopeToFrame: toFrame }) { }; envelopeToFrame = toFrame; + // Native (Kotlin/Swift) owns the trace-sampling decision and forwards the + // already-resolved rate — it folds in the `debug` window (full sampling + // while on) and any plugin-configured cap. Mirror it verbatim here; absent + // means 0. Day-to-day perf signal rides the always-on metrics layer. + const baseTracesSampleRate = argv.sentryTracesSampleRate + ? numericArg(argv.sentryTracesSampleRate) + : 0; + Sentry.init({ dsn: argv.sentryDsn, environment: argv.sentryEnvironment, release: argv.sentryRelease, sampleRate: numericArg(argv.sentrySampleRate), - // Native (Kotlin/Swift) owns the trace-sampling decision and forwards the - // already-resolved rate — it folds in the `debug` window (full sampling - // while on) and any plugin-configured cap. Mirror it verbatim here; absent - // means 0. Day-to-day perf signal rides the always-on metrics layer. - tracesSampleRate: argv.sentryTracesSampleRate - ? numericArg(argv.sentryTracesSampleRate) - : 0, + // The sync-session transaction is usage-tier, not debug-tier, so it must + // sample even while the base rate is 0. Everything else keeps the + // parent-based / base-rate behaviour a plain `tracesSampleRate` gives. + tracesSampler: ({ name, inheritOrSampleWith }) => { + if (name === SYNC_SESSION_TRANSACTION) { + return config?.applicationUsageData ? 1 : 0; + } + return inheritOrSampleWith(baseTracesSampleRate); + }, // v9 moved this out of `_experiments` — keep the CLI flag name so // native doesn't have to change. enableLogs: argv.sentryEnableLogs, @@ -293,6 +306,59 @@ export async function withSpan(op, fn) { }); } +/** + * @typedef {{ + * startPhase: (phase: "discover" | "replicate") => void, + * end: (args: { outcome: string, peersBucket: string, bytesBucket: string }) => void, + * }} SyncSessionTransaction + */ + +/** + * Start the usage-tier `comapeo.sync.session` transaction. Returns + * `null` when Sentry is off or `applicationUsageData` is off, so the + * caller treats the handle as optional. `end` sets the only attributes + * the transaction may carry — bucketed peer count, bucketed bytes, + * outcome (§11.3) — never peer identities or project IDs. `startPhase` + * ends the previous phase child span and starts the next. + * + * @returns {SyncSessionTransaction | null} + */ +export function startSyncSessionTransaction() { + if (!Sentry || !config?.applicationUsageData) return null; + const sentryRef = Sentry; + // Inactive span: a sync session is long-lived and concurrent with + // unrelated work, so it must not occupy the ALS context. + const root = sentryRef.startInactiveSpan({ + name: SYNC_SESSION_TRANSACTION, + op: SYNC_SESSION_TRANSACTION, + forceTransaction: true, + }); + if (!root) return null; + /** @type {ReturnType | null} */ + let phaseSpan = null; + return { + startPhase(phase) { + phaseSpan?.end(); + phaseSpan = sentryRef.startInactiveSpan({ + name: `sync.${phase}`, + op: `sync.${phase}`, + parentSpan: root, + }); + }, + end({ outcome, peersBucket, bytesBucket }) { + phaseSpan?.end(); + phaseSpan = null; + root.setAttributes({ + outcome, + peers_bucket: peersBucket, + bytes_bucket: bytesBucket, + }); + root.setStatus({ code: 1, message: "ok" }); + root.end(); + }, + }; +} + /** * Capture a fatal with phase + source tags. try/catch because * `handleFatal` must still broadcast + exit even if Sentry throws. diff --git a/backend/lib/sync-observer.js b/backend/lib/sync-observer.js new file mode 100644 index 0000000..591de55 --- /dev/null +++ b/backend/lib/sync-observer.js @@ -0,0 +1,146 @@ +// Sync-session telemetry derived from the lifecycle signals +// `@comapeo/core` exposes: each project's `$sync` emits `sync-state` +// with `data.isSyncEnabled` (edges mark session start/end) and a +// per-peer map of remaining `want`/`wanted` block counts. One session = +// the interval where data sync is enabled. Emits the usage-tier +// `comapeo.sync.session` transaction (see `sentry.js`) and the +// `metrics.syncSession` duration/bucket metrics — bucketed counts only, +// never peer identities or project IDs. + +import * as metrics from "./metrics.js"; +import * as sentry from "./sentry.js"; + +/** + * Public sync state shape from `@comapeo/core`'s SyncApi (`$sync`). + * + * @typedef {{ + * isSyncEnabled: boolean, + * want: number, + * wanted: number, + * }} RemoteGroupState + * @typedef {{ + * initial: { isSyncEnabled: boolean }, + * data: { isSyncEnabled: boolean }, + * remoteDeviceSyncState: Record, + * }} SyncApiState + * @typedef {{ + * getState: () => SyncApiState, + * on: (event: "sync-state", listener: (state: SyncApiState) => void) => unknown, + * off: (event: "sync-state", listener: (state: SyncApiState) => void) => unknown, + * }} SyncApiLike + */ + +// The sync API exposes remaining block counts, not byte counters, so a +// real bytes bucket isn't derivable; keep the attribute present with a +// stable placeholder until core exposes bytes. +const BYTES_BUCKET_UNKNOWN = "unknown"; + +/** + * Observe every project the RN client opens: wraps `manager.getProject` + * (the only project-open signal MapeoManager exposes — there is no + * project-opened event) and attaches a session watcher to each new + * project instance's `$sync`. No-op when Sentry never initialised. + * + * @param {import("@comapeo/core").MapeoManager} manager + */ +export function observeSyncSessions(manager) { + if (!metrics.isEnabled()) return; + /** @type {WeakSet} */ + const observed = new WeakSet(); + const getProject = manager.getProject.bind(manager); + manager.getProject = async (projectPublicId) => { + const project = await getProject(projectPublicId); + if (!observed.has(project.$sync)) { + observed.add(project.$sync); + watchSyncApi(/** @type {SyncApiLike} */ (project.$sync)); + } + return project; + }; +} + +/** + * Track sync sessions on one project's `$sync`. Session start is the + * `data.isSyncEnabled` false→true edge; end is the true→false edge + * (manual stop or autostop-after-synced). Phases: `sync.discover` while + * no peer is connected, `sync.replicate` once one is. A handshake phase + * is not derivable — the noise/protomux handshake is internal to core. + * Returns a detach function (used by tests). + * + * @param {SyncApiLike} syncApi + */ +export function watchSyncApi(syncApi) { + /** + * @type {{ + * startedAt: number, + * maxPeers: number, + * synced: boolean, + * discovering: boolean, + * trace: import("./sentry.js").SyncSessionTransaction | null, + * } | null} + */ + let session = null; + + /** @param {SyncApiState} state */ + const onSyncState = (state) => { + const peers = Object.keys(state.remoteDeviceSyncState).length; + if (!session) { + if (!state.data.isSyncEnabled) return; + session = { + startedAt: performance.now(), + maxPeers: 0, + synced: false, + discovering: peers === 0, + trace: sentry.startSyncSessionTransaction(), + }; + session.trace?.startPhase(session.discovering ? "discover" : "replicate"); + } + if (peers > 0) { + session.maxPeers = Math.max(session.maxPeers, peers); + // Recomputed per event so late-arriving data flips it back off; kept + // when peers drop to 0 (an empty peer map says nothing about sync). + session.synced = isAllSynced(state); + if (session.discovering) { + session.discovering = false; + session.trace?.startPhase("replicate"); + } + } + if (!state.data.isSyncEnabled) { + const outcome = session.synced ? "completed" : "stopped"; + const durationMs = performance.now() - session.startedAt; + const peersBucket = metrics.peersBucket(session.maxPeers); + session.trace?.end({ + outcome, + peersBucket, + bytesBucket: BYTES_BUCKET_UNKNOWN, + }); + metrics.syncSession(outcome, durationMs, peersBucket, BYTES_BUCKET_UNKNOWN); + session = null; + } + }; + + syncApi.on("sync-state", onSyncState); + // Seed from the current state in case data sync was already running + // when the watcher attached. + onSyncState(syncApi.getState()); + return () => { + syncApi.off("sync-state", onSyncState); + }; +} + +/** + * All connected peers have nothing left to send or receive in either + * namespace group. Only meaningful with ≥1 peer. + * + * @param {SyncApiState} state + */ +function isAllSynced(state) { + const devices = Object.values(state.remoteDeviceSyncState); + if (devices.length === 0) return false; + return devices.every( + (d) => + d.initial.want === 0 && + d.initial.wanted === 0 && + d.data.want === 0 && + d.data.wanted === 0, + ); +} diff --git a/backend/lib/sync-observer.test.mjs b/backend/lib/sync-observer.test.mjs new file mode 100644 index 0000000..4ed5ca4 --- /dev/null +++ b/backend/lib/sync-observer.test.mjs @@ -0,0 +1,311 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import * as Sentry from "@sentry/node-core"; + +import { initSentry } from "./sentry-init.js"; +import { setSink, flush } from "./sentry.js"; +import * as metrics from "./metrics.js"; +import { watchSyncApi } from "./sync-observer.js"; + +/** + * Sync-session lifecycle telemetry, driven through a fake `$sync` + * (EventEmitter + getState, the surface `watchSyncApi` consumes) with + * the REAL Sentry SDK — the same pattern as sentry.test.mjs: + * + * - usage ON ⇒ a `comapeo.sync.session` transaction envelope reaches + * the sink even with the base traces rate at 0, carrying + * ONLY the allowed bucketed attributes, plus the + * duration metric and bucket counters; + * - usage OFF ⇒ no envelope; the duration metric still records + * (diagnostic tier) and the bucket counters don't. + */ + +const baseArgv = { + sentryDsn: "https://x@sentry.io/1", + sentryEnvironment: "test", + sentryRelease: "0.0.0+test", + sentrySampleRate: "1.0", + // Deliberately absent → base traces rate 0. The sync-session + // transaction must sample via the name-matched tracesSampler branch. + sentryRpcArgsBytes: "0", + sentryEnableLogs: false, + sentryBaggage: "", + debug: false, + deviceClass: "mid", + osMajor: "android.14", + platformTag: "android", +}; + +function recordingMetricsSdk() { + const distributions = []; + const counts = []; + return { + distributions, + counts, + sdk: { + metrics: { + distribution: (name, value, data) => + distributions.push({ name, value, ...data }), + count: (name, value, data) => counts.push({ name, value, ...data }), + gauge: () => {}, + }, + }, + }; +} + +function initMetrics(sdk, applicationUsageData) { + metrics.init({ + Sentry: sdk, + platform: "android", + deviceClass: "mid", + osMajor: "android.14", + applicationUsageData, + }); +} + +function fakeSyncApi(initialState) { + const emitter = new EventEmitter(); + let state = initialState; + return { + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + getState: () => state, + setState(next) { + state = next; + emitter.emit("sync-state", next); + }, + }; +} + +function deviceStates(n, { want = 0, wanted = 0 } = {}) { + const out = {}; + for (let i = 0; i < n; i++) { + out[`fakepeerid${i}`] = { + initial: { isSyncEnabled: true, want: 0, wanted: 0 }, + data: { isSyncEnabled: true, want, wanted }, + }; + } + return out; +} + +function syncState({ dataEnabled, devices }) { + return { + initial: { isSyncEnabled: true }, + data: { isSyncEnabled: dataEnabled }, + remoteDeviceSyncState: devices, + }; +} + +const idleState = syncState({ dataEnabled: false, devices: {} }); + +/** Extract the transaction payload from a base64 `sentry-envelope` frame. */ +function decodeTransaction(frame) { + const lines = Buffer.from(frame.data, "base64") + .toString("utf-8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + for (let i = 1; i < lines.length - 1; i++) { + if (lines[i].type === "transaction") return lines[i + 1]; + } + return undefined; +} + +/** Drive one full discover → replicate → synced → autostop session. */ +function driveCompletedSession(api) { + // start() with no peers connected yet → discover phase. + api.setState(syncState({ dataEnabled: true, devices: {} })); + // Two peers connect with data outstanding → replicate phase. + api.setState( + syncState({ dataEnabled: true, devices: deviceStates(2, { wanted: 5 }) }), + ); + // Everything transferred. + api.setState(syncState({ dataEnabled: true, devices: deviceStates(2) })); + // Autostop flips data sync off → session end. + api.setState(syncState({ dataEnabled: false, devices: deviceStates(2) })); +} + +test("usage ON: session emits a comapeo.sync.session transaction with only bucketed attributes", async () => { + initSentry({ ...baseArgv, applicationUsageData: true }); + const rec = recordingMetricsSdk(); + initMetrics(rec.sdk, true); + + const captured = []; + setSink((frame) => captured.push(frame)); + + const api = fakeSyncApi(idleState); + const detach = watchSyncApi(api); + driveCompletedSession(api); + detach(); + await flush(2000); + + const frames = captured.filter((f) => f.type === "sentry-envelope"); + const payloads = frames.map(decodeTransaction).filter(Boolean); + assert.equal(payloads.length, 1, "expected exactly one transaction"); + const [payload] = payloads; + assert.equal(payload.transaction, "comapeo.sync.session"); + + // Attribute allowlist: our code sets only outcome + the two buckets; + // anything else must be SDK-internal (sentry.* / otel.*). + const data = payload.contexts?.trace?.data ?? {}; + assert.equal(data.outcome, "completed"); + assert.equal(data.peers_bucket, "1-3"); + assert.equal(data.bytes_bucket, "unknown"); + for (const key of Object.keys(data)) { + assert.ok( + ["outcome", "peers_bucket", "bytes_bucket"].includes(key) || + key.startsWith("sentry.") || + key.startsWith("otel."), + `unexpected transaction attribute: ${key}`, + ); + } + + // No peer identities anywhere in the payload. + assert.ok( + !JSON.stringify(payload).includes("fakepeerid"), + "transaction payload leaked a peer id", + ); + + // Child spans: discover (pre-first-peer) then replicate. + const ops = (payload.spans ?? []).map((s) => s.op); + assert.deepEqual(ops.sort(), ["sync.discover", "sync.replicate"]); + + // Metrics from the same lifecycle: duration + usage-gated buckets. + const duration = rec.distributions.find( + (d) => d.name === "comapeo.sync.session.duration_ms", + ); + assert.ok(duration, "duration metric not recorded"); + assert.equal(duration.attributes.outcome, "completed"); + assert.deepEqual( + rec.counts.map((c) => [c.name, c.attributes.bucket]).sort(), + [ + ["comapeo.sync.bytes_bucket", "unknown"], + ["comapeo.sync.session.peers_bucket", "1-3"], + ], + ); + + await Sentry.close(); +}); + +test("usage OFF: no transaction envelope; duration metric still records, buckets don't", async () => { + initSentry({ ...baseArgv, applicationUsageData: false }); + const rec = recordingMetricsSdk(); + initMetrics(rec.sdk, false); + + const captured = []; + setSink((frame) => captured.push(frame)); + + const api = fakeSyncApi(idleState); + const detach = watchSyncApi(api); + driveCompletedSession(api); + detach(); + await flush(500); + + const transactions = captured + .filter((f) => f.type === "sentry-envelope") + .map(decodeTransaction) + .filter(Boolean); + assert.equal( + transactions.length, + 0, + "usage-off must not emit a sync-session transaction", + ); + + assert.ok( + rec.distributions.some( + (d) => d.name === "comapeo.sync.session.duration_ms", + ), + "diagnostic-tier duration metric must record with usage off", + ); + assert.equal(rec.counts.length, 0, "bucket counters are usage-gated"); + + await Sentry.close(); +}); + +test("manual stop before synced records outcome=stopped; replicate-only spans when peers present at start", async () => { + initSentry({ ...baseArgv, applicationUsageData: true }); + const rec = recordingMetricsSdk(); + initMetrics(rec.sdk, true); + + const captured = []; + setSink((frame) => captured.push(frame)); + + // Peers already connected when data sync starts → no discover phase. + const api = fakeSyncApi(idleState); + const detach = watchSyncApi(api); + api.setState( + syncState({ dataEnabled: true, devices: deviceStates(5, { want: 9 }) }), + ); + // Stopped while blocks are still outstanding. + api.setState( + syncState({ dataEnabled: false, devices: deviceStates(5, { want: 4 }) }), + ); + detach(); + await flush(2000); + + const payload = captured + .filter((f) => f.type === "sentry-envelope") + .map(decodeTransaction) + .find(Boolean); + assert.ok(payload, "no transaction envelope reached the sink"); + assert.equal(payload.contexts.trace.data.outcome, "stopped"); + assert.equal(payload.contexts.trace.data.peers_bucket, "4-10"); + assert.deepEqual( + (payload.spans ?? []).map((s) => s.op), + ["sync.replicate"], + ); + + const duration = rec.distributions.find( + (d) => d.name === "comapeo.sync.session.duration_ms", + ); + assert.equal(duration.attributes.outcome, "stopped"); + + await Sentry.close(); +}); + +test("session that never sees a peer buckets peers as 0", async () => { + initSentry({ ...baseArgv, applicationUsageData: true }); + const rec = recordingMetricsSdk(); + initMetrics(rec.sdk, true); + setSink(() => {}); + + const api = fakeSyncApi(idleState); + const detach = watchSyncApi(api); + api.setState(syncState({ dataEnabled: true, devices: {} })); + api.setState(syncState({ dataEnabled: false, devices: {} })); + detach(); + await flush(500); + + const peersCount = rec.counts.find( + (c) => c.name === "comapeo.sync.session.peers_bucket", + ); + assert.equal(peersCount.attributes.bucket, "0"); + + await Sentry.close(); +}); + +test("watcher seeded from getState picks up a session already running", async () => { + initSentry({ ...baseArgv, applicationUsageData: true }); + const rec = recordingMetricsSdk(); + initMetrics(rec.sdk, true); + setSink(() => {}); + + // Data sync already enabled when the watcher attaches. + const api = fakeSyncApi( + syncState({ dataEnabled: true, devices: deviceStates(1) }), + ); + const detach = watchSyncApi(api); + api.setState(syncState({ dataEnabled: false, devices: deviceStates(1) })); + detach(); + await flush(500); + + const duration = rec.distributions.find( + (d) => d.name === "comapeo.sync.session.duration_ms", + ); + assert.ok(duration, "seeded session did not record on end"); + assert.equal(duration.attributes.outcome, "completed"); + + await Sentry.close(); +}); diff --git a/docs/sentry-integration.md b/docs/sentry-integration.md index 10fb7fe..a21185c 100644 --- a/docs/sentry-integration.md +++ b/docs/sentry-integration.md @@ -1393,9 +1393,9 @@ 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. | -| 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)).* | +| Sync session duration + outcome | Diagnostics | Sync performance/reliability is core-function health; that a sync ran is inherent to a P2P app. | +| Sync `peers_bucket` | applicationUsageData | How many devices a user syncs with is a proxy for their collaboration/social-graph size. Buckets: `0` / `1-3` / `4-10` / `10+`. | +| Sync `bytes_bucket` | applicationUsageData | Volume of data exchanged is a proxy for how much a user collects/shares. Currently always `unknown`: `@comapeo/core`'s sync state exposes remaining block counts, not byte counters. | | `state.transitions` | Diagnostics | App lifecycle states; no user content. | | `storage.size_bucket` | Diagnostics | Coarse 4-bucket dataset size — kept on so crashes/OOMs can be correlated with data volume. | | App-exit coarse buckets (`uptime_bucket`, `bg_duration_bucket`, OEM-kill flags) | Diagnostics | Aggregate stability signal ("which OEMs kill us"); low-resolution. | diff --git a/src/__tests__/sentry.test.js b/src/__tests__/sentry.test.js index be0c269..f8a1f1a 100644 --- a/src/__tests__/sentry.test.js +++ b/src/__tests__/sentry.test.js @@ -31,6 +31,8 @@ describe("initSentry", () => { let setContextSpy; let addEventProcessorSpy; let setDiagnosticsEnabledNativeSpy; + let addBreadcrumbSpy; + let appStateHandler; beforeEach(() => { preferences = { @@ -50,6 +52,8 @@ describe("initSentry", () => { setContextSpy = jest.fn(); addEventProcessorSpy = jest.fn(); setDiagnosticsEnabledNativeSpy = jest.fn(() => Promise.resolve()); + addBreadcrumbSpy = jest.fn(); + appStateHandler = undefined; jest.resetModules(); @@ -100,7 +104,7 @@ describe("initSentry", () => { getGlobalScope: () => globalScope, captureException: jest.fn(), captureMessage: jest.fn(), - addBreadcrumb: jest.fn(), + addBreadcrumb: addBreadcrumbSpy, setTag: jest.fn(), startSpan: jest.fn(), getActiveSpan: jest.fn(), @@ -120,11 +124,18 @@ describe("initSentry", () => { })); // `react-native` is needed because `src/sentry.ts` reads - // `Platform.OS`. Without the mock, Jest tries to load the real - // RN bundle and trips on its ESM-style imports under the - // expo-module-scripts preset. + // `Platform.OS` and attaches an `AppState` listener at module + // load. Without the mock, Jest tries to load the real RN bundle + // and trips on its ESM-style imports under the expo-module-scripts + // preset. Capture the AppState handler so tests can drive + // background/foreground transitions. jest.doMock("react-native", () => ({ Platform: { OS: "ios" }, + AppState: { + addEventListener: (event, handler) => { + if (event === "change") appStateHandler = handler; + }, + }, })); }); @@ -202,7 +213,10 @@ describe("initSentry", () => { }); test("autoInitializeNativeSdk omitted on Android so RNSentry inits the main-process SDK", () => { - jest.doMock("react-native", () => ({ Platform: { OS: "android" } })); + jest.doMock("react-native", () => ({ + Platform: { OS: "android" }, + AppState: { addEventListener: jest.fn() }, + })); const { initSentry } = require("../sentry"); initSentry(); const opts = initSpy.mock.calls[0][0]; @@ -413,4 +427,45 @@ describe("initSentry", () => { // value is still true, so the getter must agree. expect(getDiagnosticsEnabled()).toBe(true); }); + + test("usage tier on: bg/fg AppState changes add comapeo.app.* breadcrumbs", () => { + preferences.applicationUsageData = true; + const { initSentry } = require("../sentry"); + initSentry(); + expect(typeof appStateHandler).toBe("function"); + + appStateHandler("background"); + appStateHandler("active"); + expect(addBreadcrumbSpy.mock.calls.map(([c]) => c.message)).toEqual([ + "comapeo.app.background", + "comapeo.app.foreground", + ]); + expect(addBreadcrumbSpy.mock.calls[0][0].category).toBe( + "comapeo.app.lifecycle", + ); + + // Repeated same-direction events (Android can fire "active" more + // than once) and iOS transient "inactive" add nothing. + appStateHandler("active"); + appStateHandler("inactive"); + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(2); + }); + + test("usage tier off: AppState changes add no breadcrumbs", () => { + preferences.applicationUsageData = false; + const { initSentry } = require("../sentry"); + initSentry(); + appStateHandler("background"); + appStateHandler("active"); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + test("no breadcrumbs when diagnostics is off (Sentry never initialised)", () => { + preferences.diagnosticsEnabled = false; + preferences.applicationUsageData = true; + const { initSentry } = require("../sentry"); + initSentry(); + appStateHandler("background"); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/sentry.ts b/src/sentry.ts index 6d27277..622f7c4 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -12,7 +12,7 @@ * attaches the state listeners so they're ready to fire — they no-op * until [initSentry] runs and flips `sentryReady`. */ -import { Platform } from "react-native"; +import { AppState, Platform, type AppStateStatus } from "react-native"; import * as Sentry from "@sentry/react-native"; import { @@ -513,3 +513,44 @@ function truncateForSentry(input: string): string { return `${input.slice(0, MESSAGE_ERROR_MAX_LEN)}… [truncated ${input.length - MESSAGE_ERROR_MAX_LEN} chars]`; } +// ── App lifecycle breadcrumbs (usage tier) ────────────────────── +// +// Background/foreground transitions become `comapeo.app.background` / +// `comapeo.app.foreground` breadcrumbs riding on subsequent events, so +// an error can be read as "fired N seconds after backgrounding" from +// the breadcrumb timestamps. When the app is used is session-shape +// data, so the crumbs are gated on the `applicationUsageData` tier +// (snapshot-at-launch, like every other capture this module makes). + +// Lazy so nothing reads the native module at import time (mirrors +// sentry-metrics.ts — this can run before the native binding attaches). +let usageDataAtLaunch: boolean | undefined; + +function usageDataEnabledAtLaunch(): boolean { + return (usageDataAtLaunch ??= + readSentryPreferencesAtLaunch().applicationUsageData); +} + +let lastAppLifecycleCrumb: "background" | "foreground" | undefined; + +function handleAppStateChange(next: AppStateStatus): void { + if (!sentryReady || !usageDataEnabledAtLaunch()) return; + // iOS `inactive` (app switcher, notification shade) is transient — + // only a real background/foreground pair is worth a crumb. + const crumb = + next === "background" + ? ("background" as const) + : next === "active" + ? ("foreground" as const) + : undefined; + if (!crumb || crumb === lastAppLifecycleCrumb) return; + lastAppLifecycleCrumb = crumb; + Sentry.addBreadcrumb({ + category: "comapeo.app.lifecycle", + level: "info", + message: `comapeo.app.${crumb}`, + }); +} + +AppState.addEventListener("change", handleAppStateChange); + From 3f362620c47668e37157a4e62e12f06660c641f3 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 8 Jul 2026 15:56:51 +0200 Subject: [PATCH 2/2] remove unnecessary type coercion --- backend/lib/sync-observer.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/lib/sync-observer.js b/backend/lib/sync-observer.js index 591de55..0725adf 100644 --- a/backend/lib/sync-observer.js +++ b/backend/lib/sync-observer.js @@ -52,7 +52,7 @@ export function observeSyncSessions(manager) { const project = await getProject(projectPublicId); if (!observed.has(project.$sync)) { observed.add(project.$sync); - watchSyncApi(/** @type {SyncApiLike} */ (project.$sync)); + watchSyncApi(project.$sync); } return project; }; @@ -113,7 +113,12 @@ export function watchSyncApi(syncApi) { peersBucket, bytesBucket: BYTES_BUCKET_UNKNOWN, }); - metrics.syncSession(outcome, durationMs, peersBucket, BYTES_BUCKET_UNKNOWN); + metrics.syncSession( + outcome, + durationMs, + peersBucket, + BYTES_BUCKET_UNKNOWN, + ); session = null; } };