What's still ahead in the Sentry integration. Companion docs:
sentry-integration.md— architecture as it stands today.sentry-integration-history.md— per-phase record of work that's already landed.
The numbering below follows the original phase scheme so cross-references in git history stay valid.
| Phase | Notes |
|---|---|
Phase 4 — @comapeo/core OTel forwarding |
Blocked on @comapeo/core PR #1051 landing. Verification work only. |
| 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; native-scope field split (9b.3), boot-transaction slimming (9b.4), and backend free-mem refresh (9b.6) landed with issue #79; toggle anchor resets (9b.9) landed separately. Phase 9b complete. |
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. |
- Bump
@comapeo/coreonce PR #1051 lands. - Verify Sentry's OTel integration picks up the spans with the RPC transaction as parent.
- Document any required tracing-config overrides.
Value: deep traces inside core operations (sync, indexing, hypercore) — the data Sentry's performance tab is designed to surface.
The toggle infrastructure (prefs store, JS API, native readers, argv plumbing) already shipped in Phase 9a. What's still pending is the captures the toggle gates.
Several items originally specced here have been re-tiered to metrics at diagnostic (Phase 11) since the SDK v8 migration made Application Metrics available on every layer. The privacy rationale: metrics are aggregate, low-cardinality, and carry no per-user timeline or free-text payloads, so they don't need the usage opt-in that per-event captures do. Specifically:
Per-RPC client + server spans— superseded by §11.3: RPC timing is acomapeo.rpc.*.duration_msdistribution metric at diagnostic; per-RPC traces move behind thedebugtoggle.Backend memory checkpoint— superseded by §11.2'scomapeo.backend.memory_rss_bytes/heap_used_bytesgauges at diagnostic (device-health, not user behaviour).— superseded by a bucketed diagnostic-tier counter in §11.2's inventory; the bucketing (privateStorageDirsize sample<10MB,10–100MB,100MB–1GB,>1GB) that made it safe as an event makes it safe as a metric tag.
What genuinely remains usage-tier (per-event, session-shape data):
- Sync session lifecycle transaction. A
comapeo.sync.sessiontransaction fromconnectPeers(or first peer-connected event) through tosyncFinished/disconnect. Spans inside fordiscover,handshake,replicate. Counts only: number of peers (bucketed), bytes transferred (bucketed), duration. No peer identities, no project IDs in raw form. - Background/foreground transitions — host-app
pauseandresumeevents becomecomapeo.app.background/comapeo.app.foregroundbreadcrumbs that ride on subsequent events, helping correlate timing ("error fired 3s after app backgrounded"). before_sendprivacy processor — see Phase 9b for the full design; Phase 5 lands the wiring inbackend/before-send.jsso the captures above are scrubbed before they leave Node.
Cost: ~100 LOC native + JS + backend.
Phase 6 (Android exit reasons) and Phase 7a (iOS MetricKit app-exit
forwarding) have landed — see sentry-integration.md §7.5 for the
as-built design. What remains from the original Phase 7 spec is the
optional 7b sub-phase.
MXAppExitMetric has no per-event timestamps. To answer "the app was
alive for X seconds before the system killed it in the background" at
any resolution, layer a heuristic on top:
- In
applicationDidEnterBackground, write{ state: "background", at: <wall_ms> }toUserDefaults. - In
applicationWillEnterForeground, writestate: "foreground". - In
applicationWillTerminate, write{ state: "terminated_clean", at: <wall_ms> }. - On every cold start: if the previous-session record exists and
state ∈ {"background", "foreground"}(i.e. no clean termination marker), emit a Sentry event"ios kill inferred"taggedios.killed_in_background:true|false(depending on the recorded state) withlast_known_stateandtime_since_last_state_ms. Then overwrite the record so the inference fires once per actual incident.
Two things to be honest about:
- This heuristic catches any unclean termination, including jetsam,
watchdog, user-force-quit, OS reboot, and crash.
MXAppExitMetric(Phase 7a) and sentry-cocoa's crash reporter help disambiguate after the fact — combine the events on dashboards viarelease+ timestamp proximity. time_since_last_state_msis a lower bound. The state marker is only refreshed on lifecycle transitions, not periodically, so if the app sat in the background for 30 minutes and was killed at the end, the value will be ~30min — which is what we want. But if the user force-quit at minute 5 without the marker being refreshed mid-background, we still report ~5min, which understates the system's tolerance. Add a periodic refresh (TimeronRunLoop.main, 30s cadence, only while foregrounded so we don't drain battery) to mitigate.
AppKillHeuristicTests.swift: mockUserDefaults+ a clock; assert:- Clean termination marker prevents the next-launch inference.
- Stale marker fires once and is then cleared.
- Foreground vs background marker drives
ios.killed_in_backgroundcorrectly.
Manual verification: a jetsam test (/usr/bin/MemoryLogger or the
Xcode "Simulate Memory Warning" → background → kill flow).
- Per-event timestamps for
MXAppExitMetric. Apple doesn't expose them. - Background-task-budget instrumentation (how close to the ~30s
assertion expiry were we when iOS suspended us?). Worth a separate
small phase if
background_task_assertion_timeoutshows up frequently in the dashboard.
Cost: ~80 LOC Swift + ~50 LOC tests.
- Tune sample rates from production data.
Migrate exit telemetry (Phases 6/7a) from events to Sentry Application Metrics— landed with the @sentry/react-native v8 migration (sentry-android 8.43, sentry-cocoa 9.15): both platforms now emitcomapeo.app.exitcounts, seedocs/sentry-integration.md§7.5. Archive any exit-event issues left over from the events era in the Sentry UI.- Optional: dual backend bundles for Sentry-free consumers if bundle size becomes a concern.
The plumbing in Phase 9a landed the gating shape; the captures themselves still need to be hardened to honour the distinctions the tiers promise. This is Phase 9b, broken into smaller deliverables.
The substring-scan promised in the hard never-capture list (see
sentry-integration.md §8)
— defensive net for rootKey, base64-22-char strings (rootkey shapes),
lat= / lng= / latitude: / longitude:, and any other token
CoMapeo treats as sensitive. Lives in this module, wired in
initSentry BEFORE the host's beforeSend chain so a malicious or
buggy host can never see an unscrubbed payload. The beforeSend chain
shape is already wired (identity placeholder at src/sentry.ts:233);
this lands the function body.
Symmetric implementation in backend/loader.mjs's
Sentry.addEventProcessor so the same scrub runs on Node-side events.
Same regex list, same drop behaviour. A shared list keeps it in sync;
copy via build step or duplicate by hand with a comment pointing both
ways.
The scrubber walks event.message, event.exception[*].value,
event.extra, event.contexts, every breadcrumb's message + data,
and every span's description + attributes. Trade-off between
false-positive aggressiveness and signal preservation documented
inline with example matches.
Landed (with the Phase 11 branch) — see
sentry-integration.md §9.2
"Sentry user.id" for the as-built design. Two deltas from this
section's original spec:
- The stored ID is named
sentry.rootUserIdand is never sent raw. The usage tier usessha256(root + "|permanent")instead of the raw ID, so the root ID is only ever shared by explicit user action (via thegetRootUserId()API, for support cases). - Both tiers hash with the same shape:
sha256("<root>|<salt>").slice(0, 16)where salt is UTCYYYY-MM(diagnostic, monthly rotation) or"permanent"(usage opt-in).
Distribution matches the spec: native derives once per process start,
exposes userId on the sentryConfig Expo constant, passes
--sentryUserId argv to the backend, and all three SDKs set the same
user.id.
The original §9.8.5.3 design split a Node-side SentryNativeContext
blob across diagnostic vs app-usage tiers. Superseded by Phase 10's
offline-transport forwarder. SentryNativeContext.{kt,swift} was
deleted — Node events are deserialised on the native side and captured
via Sentry.captureEvent, so the FGS-side sentry-android /
sentry-cocoa SDK applies its own scope (with its own field set) at
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:
- 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/appscope at both tiers. Onlyculture(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: 8/16/32/64/128/256/512/1024 GB).os:name,versiononly. Dropkernel_version(both),build(AndroidBuild.DISPLAY). iOSkern.osversionredundant withversion, drop too.app:app_identifier,app_version,app_build. Dropapp_name.culture: drop entirely at diagnostic tier (locale + timezone are high-entropy fingerprint surfaces).device.screen_resolution,device.screen_density,device.screen_dpi: drop.
- App-usage tier adds: kernel_version, Android
Build.DISPLAY,app_name, fullcultureblock, screen metrics.
Boot transactions stay always-on (option (b) from the original design discussion), but the timing-shape data they carry is minimised under the diagnostic tier:
- Strip user-shape fields from boot-transaction attributes — no background-duration anchors, no foreground-state tags, no per-event culture data riding alongside.
- Keep phase-span shape (
boot.fgs-launch,boot.extract-assets,boot.node-spawn,boot.loader-init+ itsboot.loader-import-sentry-nodeandboot.import-indexchildren,boot.manager-init,boot.rootkey-load) — that's the actionable perf signal. - Span
descriptionstrings stay minimal — the phase identifier ("boot.<phase>") is inopand serves as the description too; any longer prose lives in source-code comments. No file paths, user-shape data, or other potentially-sensitive strings ride on the wire.
@sentry/react-native's default httpIntegration records every
fetch / XMLHttpRequest URL + status code as a breadcrumb. URLs can
leak which CoMapeo Cloud account / project / map tile server a user
talks to. Two options:
- Disable
httpIntegrationfrom our defaults entirely. Cheapest; most aggressive. - Keep it but install a
beforeBreadcrumbthat scrubs the URL to host-only (drop path, query string).
Recommend the latter — host-only URLs are still useful for diagnosing "all our requests are failing" patterns. Implementation chains alongside the PII scrubber.
Phase 9a's cheap fix (attach os.freemem / os.totalmem /
fs.statfsSync to handleFatal exceptions) shipped already. The
periodic-update direction is still relevant for keeping
Node-process memory / storage numbers fresh on every event (not
just handleFatal). Re-specify as either:
- (a) a small "Node device context" event-processor in
loader.mjsthat re-readsos.freememon each capture, or - (b) periodic native → Node updates of a small allowlist of Node-process metrics.
Whichever way, this is no longer a vehicle for native device/os/app fields — those come from the native SDK's scope.
Scoped to app-usage tier because periodic memory polling is itself usage-shape data (frequency reveals app activity).
Move backend consoleIntegration from the always-on default to
app-usage. Today backend/loader.mjs adds it unconditionally; under
the new model, install it only when the loader receives a
--captureApplicationData argv flag (which native only passes when
the effective toggle is on).
Done — landed with the Phase 6/7 implementation rather than
deferred here. The duration-derived fields (bg_duration_bucket,
uptime_bucket, comapeo.fgs.killed_in_background, exact-duration
extras) and the iOS per-event multiplication only flow when
capture-application-data is on; the exit records themselves ship at
diagnostic. See sentry-integration.md §7.5.
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.
Shift day-to-day performance signal from per-RPC tracing to Sentry
Application Metrics (product docs),
keeping tracing as an investigation-only mode behind a new user-facing
debug toggle. Rename captureApplicationData → applicationUsageData
with refined semantics (stable user.id + usage events, no longer perf
tracing).
Unblocked. The SDK v8 migration brought the metrics API to every
layer: @sentry/react-native 8.x (JS), sentry-android 8.43 (Kotlin),
sentry-cocoa 9.15 (Swift), @sentry/node-core 10.53 (backend). The
first metrics consumer — comapeo.app.exit from the Phase 6/7a exit
collectors — already emits from the native layers, so the pipeline is
proven; this phase is "more of the same" plus the toggle rework.
The tier rationale, stated once: metrics are aggregate and
low-cardinality — pre-bucketed tags, no per-user timeline, no
free-text payloads — so they sit at the always-on diagnostic tier
where per-event equivalents would have needed the usage opt-in.
Traces (a precise per-operation timeline) are the privacy-expensive
shape, which is why they move behind debug.
Motivation: Platformatic's Hidden Cost of Async Context
benchmarks show full OTel auto-instrumentation removes ~80% of throughput
and 4×s p99 latency. On a mobile RPC server the CPU number is invisible
at ~10 RPS, but the per-call envelope egress (battery + mobile data +
ingest $$$) and the always-on Sentry.startSpan + ALS wrapper are real
costs paid for a question — "what's our p95 of observation.create on
low-end Android?" — that a histogram answers more directly with no
egress per call.
Companion changes elsewhere:
- §11.4 supersedes Phase 9b.2's
diagnostic-vs-app-usage gating for
user.idrotation. The new contract: monthly hash wheneverapplicationUsageData=false, rawinstallationIdwheneverapplicationUsageData=true.debugdoes not unlock stableuser.id. - §11.3 supersedes Phase 5's "per-RPC client + server spans" entry —
RPC spans are now
debug-only; metrics carry the day-to-day perf signal. Phase 5 retains the sync-session transaction and thebefore_sendprivacy processor.
Rename captureApplicationData → applicationUsageData and add debug.
All three are orthogonal but applicationUsageData and debug AND
with diagnosticsEnabled internally; host UI never has to mirror that.
| Toggle | What it gates | Default |
|---|---|---|
diagnosticsEnabled |
Sentry.init runs. Errors, lifecycle, metrics, boot/sync/shutdown transactions. |
true (per plugin) |
applicationUsageData |
Feature-usage breadcrumbs/counters + stable user.id (no monthly hash). |
false |
debug |
Per-RPC traces, @comapeo/core OTel spans, consoleIntegration, rpcArgsBytes capture (if plugin >0). |
false |
Effective combinations:
| State | Sentry.init |
Errors | Boot/sync trace | Metrics | Usage events | user.id |
Per-RPC trace |
|---|---|---|---|---|---|---|---|
| Off | – | – | – | – | – | – | – |
| Diagnostic (default) | ✓ | ✓ | ✓ | ✓ | – | sha256 monthly | – |
| Diag + Usage | ✓ | ✓ | ✓ | ✓ | ✓ | stable | – |
| Diag + Debug | ✓ | ✓ | ✓ | ✓ | – | sha256 monthly | ✓ 100% |
| Diag + Usage + Debug | ✓ | ✓ | ✓ | ✓ | ✓ | stable | ✓ 100% |
All recordings use Sentry.metrics.distribution(...) /
Sentry.metrics.count(...) / Sentry.metrics.gauge(...) from
@sentry/node-core v10 (Node) and @sentry/react-native v8 (RN);
native call sites use Sentry.metrics() (Kotlin) and
SentrySDK.metrics (Swift) — see ExitReasonsCollector.kt /
SentryNativeBridge.countMetric for the landed pattern. Backend
envelopes ride the existing forwarding transport
(backend/lib/sentry.js forwardingTransport) — same DSN, same
control-socket → native sink, same offline-aware native queue. No new
pipeline.
Tags follow strict low-cardinality rules (see §11.8). One default
tag is attached by metrics.js to every emission so we can never
forget it at the call site:
platform(ios/android)
Device tags (device_class, os_major) ride only on the
.by_device mirror metrics, not on every metric. Sticking them on
every emission would multiply cardinality by ~30× on the per-method
metrics (see §11.2.c) and the mirror metric exists precisely so the
primary metric can stay narrow.
The primary metric in each pair carries per-method (or per-phase)
detail for "which operation is slow"; the .by_device mirror drops
the question-specific dimension and carries device tags instead, so
the per-method × per-device join doesn't materialise as one bloated
metric. Same call site emits both with one helper call.
| Metric | Type | Tags | Source |
|---|---|---|---|
comapeo.rpc.server.duration_ms |
distribution | method, status, platform |
backend/lib/sentry.js rpcHook |
comapeo.rpc.server.duration_ms.by_device |
distribution | status, platform, device_class, os_major |
same call site |
comapeo.rpc.server.errors |
counter | method, error_class, platform |
server hook on catch |
comapeo.rpc.client.duration_ms |
distribution | method, status, platform |
src/ComapeoCoreModule.ts hook |
comapeo.rpc.client.duration_ms.by_device |
distribution | status, platform, device_class, os_major |
same call site |
comapeo.rpc.client.send_ms |
distribution | method, platform |
existing rn.send.syncMs measurement |
comapeo.boot.phase_duration_ms |
distribution | phase (fgs-launch, extract-assets, node-spawn, loader-init, manager-init, rootkey-load), platform |
each boot-span end() |
comapeo.boot.phase_duration_ms.by_device |
distribution | phase, platform, device_class, os_major |
same call site |
comapeo.boot.outcome |
counter | outcome (started / error), error_phase?, platform |
STARTED / ERROR transition |
comapeo.sync.session.duration_ms |
distribution | outcome, platform |
sync session end |
comapeo.sync.session.duration_ms.by_device |
distribution | outcome, platform, device_class, os_major |
same call site |
comapeo.sync.session.peers_bucket |
counter | bucket (1-3 / 4-10 / 10+), platform |
session start |
comapeo.sync.bytes_bucket |
counter | bucket (<1M / 1-10M / 10-100M / 100M+), platform |
session end |
comapeo.backend.memory_rss_bytes |
gauge | platform |
60s timer in backend/index.js |
comapeo.backend.heap_used_bytes |
gauge | platform |
same timer |
comapeo.fgs.uptime_s |
gauge | platform |
same timer |
comapeo.state.transitions |
counter | from, to, platform |
every stateChange |
comapeo.storage.size_bucket |
counter | bucket (<10MB / 10-100MB / 100MB-1GB / >1GB), platform |
once at STARTED (ex-Phase 5 item) |
comapeo.app.exit (landed) |
counter | see sentry-integration.md §7.5 — reason/bucket/severity/cohort attributes |
exit collectors (Phases 6/7a) |
Raw device.model would explode metric cardinality (~2,000 distinct
values on Android alone, × methods × statuses). The classification
bucket gives us the actionable signal — "low-end devices are 4× slower
at observation.create" — at low cardinality. Raw model/manufacturer
stay on Sentry's event/trace scope (already attached via native SDK
scope per sentry-integration.md §7.3),
so one debug-mode trace from the slow bucket gives the specific model.
Thresholds (revisit with comapeo-mobile's low-end device list if one exists; otherwise these are first-cut):
| Class | Memory | Cores |
|---|---|---|
low |
< 3 GB | OR < 4 |
mid |
3–6 GB | AND 4–6 |
high |
≥ 6 GB | AND ≥ 6 |
Computed once at native process start; cached on the SentryConfig
object alongside DSN/environment/release. Plumbed:
- to RN via the existing
readSentryConfig()Expo constant (extended with adeviceTagsfield); - to Node via new argv flags
--deviceClass,--osMajor,--platformTag(parsed inloader.mjs, stored on the singleton inbackend/lib/sentry.js).
os_major is <platform>.<major> — Build.VERSION.RELEASE.split(".")[0]
on Android, UIDevice.systemVersion.split(".")[0] on iOS. Major-only
because point releases rarely move the perf needle and would expand
cardinality unnecessarily.
Worked example, why we split. Take rpc.server.duration_ms with
the worst-case tag bag (the old "everything on every metric" design,
preserved here as the counter-example):
| Dimension | Count | Notes |
|---|---|---|
method |
~50–80 | full @comapeo/core IPC surface; includes namespaced methods |
status |
3 | ok / error / timeout |
platform |
2 | |
os_major |
5 ios + 6 android = ~11 valid platform+os pairs | not 20 — (ios, android.13) can't co-occur, so the joint is the sum, not the product |
device_class |
3 | |
release (Sentry auto-tag) |
3–5 active | distinct releases in active install base |
environment (Sentry auto-tag) |
1–3 | usually prod plus internal/qa |
Cartesian, middle values: 70 × 3 × 11 × 3 × 4 × 2 = ~55k series
for that one metric. Even discounting release and environment
(if Sentry's billing indexes them separately, which is the optimistic
read): 70 × 3 × 11 × 3 = 6.9k base series per metric — and with
five RPC-shaped metrics that path lands well past Sentry's 10k-per-
metric guidance the moment two of them run side-by-side.
The split fixes it. With device tags moved off the primary
metrics and onto the .by_device mirrors, no single metric carries
all the dimensions:
| Metric | Tags | Series (base × release-env) |
|---|---|---|
rpc.server.duration_ms |
method × status × platform | 70 × 3 × 2 = 420 (× 12 ≈ 5,040 worst-case) |
rpc.server.duration_ms.by_device |
status × platform × device_class × os_major | 3 × 11 × 3 = 99 (× 12 ≈ 1,188) |
rpc.server.errors |
method × error_class × platform | 70 × 5 × 2 = 700 (× 12 ≈ 8,400) |
rpc.client.duration_ms |
method × status × platform | 420 (× 12 ≈ 5,040) |
rpc.client.duration_ms.by_device |
status × platform × device_class × os_major | 99 (× 12 ≈ 1,188) |
rpc.client.send_ms |
method × platform | 140 (× 12 ≈ 1,680) |
boot.phase_duration_ms |
phase × platform | 12 (× 12 ≈ 144) |
boot.phase_duration_ms.by_device |
phase × platform × device_class × os_major | 198 (× 12 ≈ 2,376) |
boot.outcome |
outcome × error_phase × platform | ≈ 24 (× 12 ≈ 288) |
sync.session.duration_ms |
outcome × platform | 6 (× 12 ≈ 72) |
sync.session.duration_ms.by_device |
outcome × platform × device_class × os_major | 99 (× 12 ≈ 1,188) |
sync.session.peers_bucket |
bucket × platform | 6 (× 12 ≈ 72) |
sync.bytes_bucket |
bucket × platform | 8 (× 12 ≈ 96) |
backend.memory_rss_bytes |
platform | 2 (× 12 ≈ 24) |
backend.heap_used_bytes |
platform | 2 |
fgs.uptime_s |
platform | 2 |
state.transitions |
from × to × platform | 25 × 2 = 50 (× 12 ≈ 600) |
No metric over 10k even at the worst case where release and
environment count toward the budget (which is conservative — Sentry's
docs aren't explicit but historical behaviour was to index those
separately from user-defined tags). Most metrics under 2k.
Open question on auto-tags. Worth confirming with Sentry support
or empirical testing before landing whether release and environment
count toward the per-metric series limit. If they do, the table above
is the budget we live within. If they don't, we have ~10× more
headroom than this table suggests.
Two practical pitfalls if we tagged with raw device.model:
- Cardinality cost — ~2,000 Android model strings × 80 methods × 3 status × ~6 Android-major = ~3M series for one metric on Android alone. Unaffordable on any Sentry plan and unusable on dashboards.
- Long-tail noise — a histogram with 10 samples from "Tecno Spark 7" isn't actionable. Bucketed by class, the same 10 samples become "12,847 low-end Android samples this hour, p95 480ms" and we can act on it.
Raw model stays on the event/trace side (Sentry's native SDK scope
attaches it today). When a metric flags "low-end Android 11 is bad,"
one debug-mode trace from that bucket gives the actual model in
device.model + device.manufacturer.
Always-on essential (diagnostic tier, 100% sample, not gated by debug):
comapeo.boottransaction + phase children — once per launch.comapeo.shutdowntransaction + phase children — once per launch.comapeo.sync.sessiontransaction (top-level only; no per-peer or per-block child spans). Span attributes restricted to bucketed peer count, bucketed bytes, outcome. Frequency-bounded by sync sessions themselves so volume stays low.
Gated on debug=true (100% sample while on):
rpc.clientspan (JS) +rpc.servertransaction (Node) per RPC.@comapeo/corePR #1051 OTel spans (inherit the RPC parent — whendebug=onthe parent exists).consoleIntegrationbreadcrumbs on backend (currently always-on perbackend/lib/sentry.js:127; moves todebug-only, superseding the Phase 9b.7 plan).rpc.argsspan attribute whenrpcArgsBytes>0in plugin ANDdebug=true. (Two gates becauserpcArgsBytesis build-time developer config anddebugis runtime; both must agree.)
100% sample on debug because the user-bounded window keeps total
volume small. No partial sampling logic in v1.
Previously this gated per-RPC tracing + the perf grab bag. After this phase it gates only:
-
Stable
user.id— disables the monthly hash rotation specified in Phase 9b.2. Locks to the permanent hash of the root user ID (never the raw ID). WithoutapplicationUsageDatathe user.id rotates monthly across diagnostic captures (cohort-unlinkable). With it on, stable across launches and months (cohort analysis works). -
Usage breadcrumbs / counters — a module-supplied helper:
import { recordUsage } from "@comapeo/core-react-native/sentry"; recordUsage.screen("ObservationList"); recordUsage.feature("export.geojson");
Emits a
comapeo.usage.*breadcrumb (for crash-context) and acomapeo.usage.{screen,feature}counter (for aggregate cohort analysis). No-op whenapplicationUsageData=false. The module ships the helper; the consumer decides which screens/features to instrument. -
Background/foreground breadcrumbs (from Phase 5 — moves here as the natural home for "how is the app used" data).
What this no longer unlocks (compared to the old captureApplicationData):
- Per-RPC tracing → moved to
debug(§11.3). rpc.argscapture → still requires plugin'srpcArgsBytes>0ANDdebug=true.- Anything on the §8 hard never-capture list.
User-facing in settings, restart-to-activate, same pattern as the other two toggles.
export function getDebugEnabled(): boolean;
export function setDebugEnabled(value: boolean): Promise<void>;- Storage:
ComapeoPrefskeysentry.debug. Defaultfalse. - Plugin default:
debugDefaultfield, defaultfalseeverywhere (including internal builds — the workflow is "support tells the user to flip Debug on, reproduce, send the trace link, flip it off"; baking it on for QA would dilute the signal). - Argv:
--debug(boolean flag, native passes only when on). - Effective gates (enforced inside the module, never in host UI):
Sentry.initrequiresdiagnosticsEnabled.- Usage events / stable
user.idrequirediagnosticsEnabled && applicationUsageData. - Per-RPC traces require
diagnosticsEnabled && debug. rpc.argsrequiresdiagnosticsEnabled && debug && rpcArgsBytes>0.
- Breadcrumb on transition:
comapeo.debug.enabled/comapeo.debug.disabledso the timeline records when a session was diagnostic-only vs. tracing. tracesSampleRate:debug ? 1.0 : 0(full sample because the window is user-bounded; no partial sampling). Replaces the currentapplicationUsageData ? 0.1 : 0logic inbackend/lib/sentry.js:121-123andsrc/sentry.ts:227-229.
debug=true auto-expires 24 hours after the most recent enable.
Bounds the cost of a user (or support engineer) forgetting to flip
it back off without forcing a per-session re-enable.
- Storage: new pref slot
sentry.debugEnabledAtMswritten synchronously alongsidesentry.debug=true. Cleared ondebug=false. Wall-clock (currentTimeMillis()/Date()) not monotonic so it survives reboots. - Check point: at native process start (both the main process
and the FGS, before argv is built). Single
now - storedTs > 24hcomparison; if true, the prefs writer flipsdebug=false, clears the timestamp, and queues acomapeo.debug.auto_disabledbreadcrumb to fire on the nextSentry.init. Argv is built withdebug=falsefor that launch. - Re-enable semantics:
setDebugEnabled(true)always writes a freshdebugEnabledAtMs. Calling it while already enabled refreshes the 24h window — toggling at 23h59m gives another 24h. - Clock skew: winding the system clock forward triggers early auto-off; backward delays it. Acceptable — this is a best-effort cost guardrail, not a security boundary.
- Edge case:
debug=truewith no timestamp (older install predating Phase 11 with the cell missing) → treat as "enabled now"; write the timestamp on first read so the 24h clock starts cleanly. Not reachable in practice since Phase 11 ships the slot alongside the toggle, but cheap to handle.
v1 guardrails not included (track for v2 if needed):
- Sample-rate cap. Add only if support reports forgotten-debug-on sessions filling Sentry — but the 24h auto-off above is the primary mitigation, so this is unlikely to be needed.
src/sentry.ts— renamegetCaptureApplicationData/setCaptureApplicationData→…ApplicationUsageData; addget/setDebugEnabled;initSentryreads all three prefs.tracesSampleRatederived fromdebug(not fromapplicationUsageData).src/ComapeoCoreModule.ts— rename native bridge methods; addsetDebugEnabledNative;readSentryPreferences()returns{ diagnosticsEnabled, applicationUsageData, debug }. TheonRequestHook(:207-277) splits into:- always:
performance.now()delta +metrics.rpcClient(method, status, ms)+ dual write to.by_device; - when
debug: the existingSentry.startSpan/startNewTrace/getTraceDatablock, includinghasInheritableActiveSpanplumbing.
- always:
android/src/main/java/com/comapeo/core/ComapeoPrefs.kt— renamecaptureApplicationDatakey + reader/writer; one-shot migration on open: if old key present and new absent, copy then delete. Addsentry.debugslot andsentry.debugEnabledAtMsslot. ThereadDebugEnabled()reader implements the §11.5 24h auto-off: if the stored age exceeds 24h, flipsdebug=false, clears the timestamp, queues theauto_disabledbreadcrumb, returnsfalse. The setter writes the timestamp synchronously alongside the value.android/src/main/java/com/comapeo/core/SentryConfig.kt— renamecaptureApplicationDataDefault; adddebugDefaultanddeviceTags(computed via newDeviceTags.kt).android/src/main/java/com/comapeo/core/ComapeoCoreService.kt— argv now includes--applicationUsageData,--debug,--deviceClass,--osMajor,--platformTag.android/src/main/java/com/comapeo/core/DeviceTags.kt(new) —classify(ctx): DeviceTags(platform, deviceClass, osMajor). UsesActivityManager.MemoryInfo.totalMem+Runtime.getRuntime().availableProcessors(). Cached lazy.ios/ComapeoPrefs.swift,ios/SentryConfig.swift,ios/AppLifecycleDelegate.swift— mirror the Android changes, includingsentry.debugEnabledAtMsand the auto-off reader logic.ios/DeviceTags.swift(new) — same shape;ProcessInfo.physicalMemoryProcessInfo.processorCount.
app.plugin.js— renamecaptureApplicationDataDefault→applicationUsageDataDefault; adddebugDefault. Validation: warn (don't error) oncaptureApplicationDataDefaultfor one minor with a pointer to the new field.backend/lib/sentry.js— renamecaptureApplicationDatafield inargSpec+Argvtypedef →applicationUsageData; adddebug,deviceClass,osMajor,platformTag. Updateinit:SplittracesSampleRate: argv.debug ? 1.0 : 0, // rpcHook registered only when debug; metrics layer always-on at diagnostic
rpcHook(:282-341): always callmetrics.rpcServer(method, status, ms); only run thecontinueTrace+startSpanblock whenargv.debug.backend/loader.mjs— parse the new argv flags through the existingsentry.argSpecmachinery.
backend/lib/metrics.js(new) — module-private wrapper aroundSentry.metrics.*. Singletons populated fromsentry-init.js. Exports:rpcServer(method, status, ms)— writes both the…duration_ms{method,status}distribution and the…by_device{status}distribution.rpcServerError(method, errorClass)— counter.bootPhase(phase, ms)— dual-write.syncSession(outcome, ms, peersBucket, bytesBucket)— three writes.backendMemorySample()— three gauges fromprocess.memoryUsage().stateTransition(from, to)— counter.usageScreen(name),usageFeature(name)— counters, no-op unlessapplicationUsageData=true.- Internal
defaultTags = { platform }(the only one cheap enough to attach to every metric — see §11.2.c).device_classandos_majorare passed explicitly only to the.by_devicehelpers, so the cardinality split is enforced at the API boundary, not at the call site. - No-ops entirely when Sentry is off.
backend/lib/sentry-init.js— also exportSentry.metricsformetrics.jsto consume; no additional dependency (it's part of@sentry/node-corev10).backend/index.js— register periodic memory gauge timer whendiagnosticsEnabled(was Phase 5 opt-in; promote). Eachboot.<phase>span end callsmetrics.bootPhase(phase, ms)alongsidespan.end(). Wiremetrics.stateTransition(...)on every state change.backend/lib/comapeo-rpc.js— pass-through; the actual recording lives insentry.js'srpcHooksince that's where method + status- duration are known.
src/sentry-metrics.ts(new) — RN-side mirror. ExportsrecordUsage.{screen,feature}(no-op unlessapplicationUsageData) and internal helpers for RPC metric recording. SamedefaultTagsshape (platform only); device tags supplied explicitly to.by_devicehelpers, read fromsentryConfig.deviceTags.
src/ComapeoCoreModule.ts:198-203—hasInheritableActiveSpan/startNewTraceplumbing stays but moves inside thedebugbranch. With per-RPC tracing gated ondebugonly, the App-Start race no longer exists for the diagnostic tier and the plumbing becomes cold code outside debug windows.backend/lib/sentry.js:282-341rpcHook—forceTransaction: truestays fordebugmode; withoutdebug, the hook returnsundefinedso the RPC server skips middleware entirely (the metric is recorded in a sibling, always-on shim).
docs/sentry-integration.md§9 — rewrite the tier table; renamecaptureApplicationData→applicationUsageDatathroughout; adddebugrow. Add a §9.6 cross-link to this Phase 11 for the metrics inventory and gating.docs/sentry-integration-history.md— append Phase 11 entry once landed.
- Plugin field:
captureApplicationDataDefaultcontinues to be read for one minor but logs a deprecation warning pointing toapplicationUsageDataDefault. Drop in the minor after. - Prefs key: one-shot migration on first open of
ComapeoPrefs: ifsentry.captureApplicationDataexists andsentry.applicationUsageDatadoes not, copy then delete the old key. Idempotent. - JS API:
getCaptureApplicationData/setCaptureApplicationDatare-exported with@deprecatedJSDoc forwarding to the new names for one minor. Drop in the minor after. - Native bridge methods: same deprecation shape; old method names forward to new for one minor.
- Behaviour at the boundary: a user with
captureApplicationData=truetoday getsapplicationUsageData=trueafter migration → keeps stableuser.id+ usage breadcrumbs. They also lose per-RPC tracing unless support flipsdebugon. This is the intended boundary — most users won't notice; the few hitting perf issues get a focused debug session rather than always-on traces.
platform: 2 values
These multiply cardinality enough that they don't ride on every
metric — only on the explicit .by_device variants that drop the
question-specific dimension in exchange.
device_class: 3 valuesos_major: ~5–6 values per platform (so the joint withplatformis ~11 valid pairs, not 20)
method: small enum (~50–80 RPC methods across the full@comapeo/coreIPC surface)status: 3 values (ok/error/timeout)phase: 6 boot phases / 3 shutdown phasesoutcome: 2 valuesfrom/to: 5 state enum eachbucket: ≤ 4 values per bucket familyerror_class: bounded (TimeoutError,IPCError,RpcError, etc.)
These are off by construction on the metrics path, mirroring the §8 hard never-capture list:
device.model,device.id,device.manufacturer— raw model stays on event/trace scope, NEVER on metrics.- Raw
os.version(useos_majoronly). screen.resolution,screen.density,screen.dpi.- Locale, timezone.
project_id(raw or hashed).peer_idof any kind, raw peer count (usepeers_bucket).rootkeysubstring, base64-22-char strings.- File paths.
- Lat/lng or quantised location.
- RPC method args (raw or sliced).
A before_metric_send hook (cheap regex, same shape as before_send)
runs symmetrically on RN and Node sides. Drops emissions whose tag
names or values match a forbidden pattern. This is belt-and-suspenders
— the fix is always at the call site, but the hook catches typos and
copy-paste mistakes before they ship a high-cardinality tag.
Lands alongside backend/before-send.js from Phase 9b.1; the two
hooks share the regex list.
- Unit (Kotlin / Swift):
ComapeoPrefsmigration — old key present + new absent → new populated, old key deleted, value preserved. Both platforms. - Unit (Kotlin / Swift):
ComapeoPrefs.readDebugEnabled24h auto-off — fresh enable returnstrue; +23h59m returnstrue; +24h01m returnsfalse, clears the timestamp, and the next read returnsfalsewith no further mutation. Setter refresh resets the window. Both platforms. - Unit (Kotlin / Swift):
DeviceTagsclassification — boundary cases (exactly 3 GB RAM, exactly 4 cores, both platforms). - Unit (JS):
metrics.jsno-ops when Sentry off; records correct metric names + tags when on; applies default tags via the singleton. - Unit (JS):
before_metric_senddrops events with forbidden tag names (e.g.project_id) and forbidden tag values (raw base64-22). - Integration (Node): extend
backend/lib/sentry.test.mjs— debug-off ⇒rpcHookdoesn't create a span; metric recorded. debug-on ⇒ span created and metric recorded. - Integration (RN): extend
src/__tests__/sentry.test.js— same shape on the client side. - Manual smoke (Sentry test project): flip each combination,
verify on Sentry's metrics explorer:
- Diagnostic only → boot trace present, error events present,
rpc.server.duration_mspopulated, no per-RPC traces, no usage breadcrumbs,user.iddiffers between two launches one month apart (mock by setting system clock). -
- Usage →
comapeo.usage.*counters appear,user.idstable across launches.
- Usage →
-
- Debug → per-RPC traces appear with
@comapeo/corespans as children. Metrics still recording in parallel.
- Debug → per-RPC traces appear with
- Verify
.by_devicemetric splits cleanly across two physical test devices (differentdevice_class).
- Diagnostic only → boot trace present, error events present,
- Regression: existing
scripts/run-instrumented-tests.sh+ Swift / Xcode test suites pass with all toggles off (Sentry inert).
| Question | Decision |
|---|---|
| Sentry plan availability for metrics ingestion | Verified — metrics are included in the current plan. Land Phase 11 as drafted; no .by_device mirror drop needed. |
comapeo.sync.session transaction tier |
Always-on at diagnostic with bucketed attributes (current §11.3 draft). Trades a known small trace volume — one per sync session — for richer drill-down on the rare sync issues. |
Auto-off guardrail for debug |
Ship 24h auto-off in v1 (see §11.5). Sample-rate cap deferred to v2 unless real support traffic demands it. |
device_class thresholds |
Use the first-cut thresholds in §11.2.b (Low < 3 GB OR < 4 cores; Mid 3–6 GB AND 4–6 cores; High ≥ 6 GB AND ≥ 6 cores). Revisit post-landing if observed perf cliffs don't align — the table is straightforward to retune. |
before_sendprivacy processor: feed it events containing base64-shaped strings, latitude/longitude markers, and raw project IDs; assert each is redacted or dropped.- Backend rollup output (re-verify when Phase 8 dual-bundle lands, if
it does): assert the multi-entry build produces
loader.mjs,index.mjs,importHook.js, andlib/register.js; thatloader.mjsdoes not statically reference@sentry/node; that the rewrittenmodule.register('./importHook.js', ...)call is in the bundled output (no bareimport-in-the-middle/hook.mjsreference). - Per-phase tests are detailed inline (see §6.9 for Phase 6, §7.9 for Phase 7).
- Run the example app with a temporary DSN (a test Sentry project)
configured via the plugin. Trigger each opt-in capture (per-RPC
span, sync session, bg/fg, memory checkpoint, storage size sample)
with
captureApplicationData=trueand confirm presence; toggle off and confirm absence. - Confirm no PII in events: open each event, scan for base64-shaped
22-char strings, file paths under
Application Support, project secrets. - Confirm distributed trace shows JS-client span → backend RPC transaction → (with PR #1051) core operation spans.
- Run the existing
scripts/run-instrumented-tests.shand the iOSswift test/xcodebuild testsuite withinitSentrynot called → no behaviour change.
| Question | Decision |
|---|---|
| Sentry SDK versions | @sentry/node@^8, @sentry/react-native@^7, @sentry/core@^9 (RN v7 re-exports it). OpenTelemetry-first majors so PR #1051 forwarding works without glue. |
release source |
Default to versionName + "+" + versionCode (Android) / CFBundleShortVersionString + "+" + CFBundleVersion (iOS). Successive EAS builds of the same marketing version produce distinct releases. Plugin override always wins. |
| Boot transaction sample rate | Force 100% even when overall tracesSampleRate is low. Boot is once-per-process and high-value. |
| Bundle size strategy | Single bundle with rollup chunk-splitting — accept the disk cost. No dual-bundle build for v1. |
Plugin behaviour with no sentry arg |
No-op silently. Treat absent meta-data / plist keys as Sentry off. Used by apps/integration/. |
| Sourcemap upload | Consumer responsibility. Module ships *.map in npm package; consumer excludes from APK/IPA and runs sentry-cli sourcemaps upload against node_modules/.../nodejs-project/ in their own CI with their own credentials. |
| Toggle UI surface | Out of scope for this module. Module exposes getDiagnosticsEnabled / setDiagnosticsEnabled and getCaptureApplicationData / setCaptureApplicationData only; consumer builds the settings UI and the restart prompt. |
| Capture-application-data default | Per-environment, decided by consumer at build time via captureApplicationDataDefault plugin field. EAS env var pattern: default to true when environment !== "production". Once user flips the switch their explicit choice wins. |
| Offline transport | Landed in Phase 10. Node envelopes are forwarded to the native side via the control socket; sentry-android / sentry-cocoa queue them under their existing offline-aware transports. |
- Lazy chunk on iOS
--jitless: dynamicimport()of a separate ESM chunk should work but isn't proven for our specific config. The Phase 3 smoke test exercises both with-Sentry and without-Sentry loader paths on iOS; re-verify on each@sentry/react-native/@sentry/nodemajor bump. iOS is also the platform we already stub@comapeo/core's maps plugin to keep undici out (seebackend/lib/maps-stub.js); the Sentry chunk is an additional surface for this kind of iOS-only quirk. - Cross-process scope on Android (re-verify on bumps): FGS-process
Sentry events must carry
proc:fgsand@sentry/react-native's main-process tags must not override them in the dashboard. Verified in Phase 2b; re-verify when bumping@sentry/react-native's sentry-android dep.
backend/package.json— bump@comapeo/coreonce PR #1051 ships.- Smoke-test verification, no code changes expected.
backend/lib/comapeo-rpc.js— wiretracesSampleRateconditionally on the toggle; register sync-session emitter only when on.backend/index.js— gate memory-checkpoint timer and storage sampling on the toggle.backend/before-send.js(new) —before_sendprivacy processor (the §9b.1 scrubber wired in the backend; the JS side is wired in the next phase).src/sentry.ts— wire RN-side bg/fg breadcrumbs onAppState.changeevents.
android/src/main/java/com/comapeo/core/ExitReasonsCollector.kt(new)android/src/main/java/com/comapeo/core/BackgroundAnchors.kt(new)android/src/main/java/com/comapeo/core/ExitReasonTags.kt(new)android/src/main/java/com/comapeo/core/SentryCategories.kt— addcomapeo.exit.android/src/main/java/com/comapeo/core/ComapeoCoreApplicationLifecycleListener.kt— schedule the collector on main-process boot.android/.../ComapeoCoreService.kt— schedule the collector on FGS boot; writeprocess_started_at_wall_msandbackgrounded_at_wall_msanchors.expo-module.config.json— register theApplicationLifecycleListener.android/src/test/java/com/comapeo/core/ExitReasonsCollectorTest.kt,ExitReasonTagsTest.kt(new).
ios/AppExitMetricsCollector.swift(new).ios/AppLifecycleDelegate.swift— subscribe the collector on iOS 14+; skip on simulator if it complicates testing.ios/SentryNativeBridge.swift— addcaptureExitBucketEvent(...)helper for the per-event emission.ios/SentryCategories.swift— addcomapeo.exit.ios/Tests/AppExitMetricsCollectorTests.swift(new).- Phase 7b additional files:
ios/AppKillHeuristic.swift(new),ios/Tests/AppKillHeuristicTests.swift(new).
backend/rollup.config.ts— if dual-bundle ships, emit a second bundle with the Sentry chunks stripped at build time, andscripts/build-backend.tsselects which to copy based on whether the consumer'sapp.jsonregistered the plugin with a DSN.
src/sentry.ts— replace the identitybeforeSendplaceholder with the substring scrubber; chain the URL-scrubbingbeforeBreadcrumbfrom §9b.5.backend/loader.mjs—Sentry.addEventProcessorfor the symmetric Node-side scrubber; moveconsoleLoggingIntegrationto the app-usage-only branch (§9b.7).backend/before-send.js(new) — the shared scrubber implementation referenced by bothloader.mjsand Phase 5's backend wiring.android/.../ComapeoPrefs.kt,ios/ComapeoPrefs.swift— add thesentry.installationIdslot and theuser.idderivation per tier.android/.../ComapeoCoreService.kt,ios/AppLifecycleDelegate.swift— pass the computeduserIdintoSentry.setUser({ id }).backend/loader.mjs— parse--sentryUserIdand callSentry.setUser({ id }).- Phase 6 / Phase 7 collectors — gate the app-usage-tier tags on
captureApplicationData. - Setter paths (
setDiagnosticsEnabled,setCaptureApplicationData) — reset Phase 6 anchors on toggle cycle (§9b.9).
Renames (captureApplicationData → applicationUsageData) and new
debug toggle plumbing:
src/sentry.ts— rename toggle accessors; addget/setDebugEnabled; derivetracesSampleRatefromdebug(notapplicationUsageData).src/ComapeoCoreModule.ts— rename native bridge methods; splitonRequestHookinto always-on metric recording and debug-gatedSentry.startSpanblock.src/sentry-metrics.ts(new) — RN-sidemetrics.*wrappers +recordUsage.{screen, feature}helpers; default-tag injection fromsentryConfig.deviceTags.app.plugin.js— renamecaptureApplicationDataDefault→applicationUsageDataDefault; adddebugDefault; deprecation warning on the old field for one minor.android/.../ComapeoPrefs.kt,ios/ComapeoPrefs.swift— rename pref key + one-shot migration of the old key; addsentry.debugandsentry.debugEnabledAtMsslots; implement the 24h auto-off in the debug reader (§11.5).android/.../SentryConfig.kt,ios/SentryConfig.swift— renamecaptureApplicationDataDefault; adddebugDefaultanddeviceTags.android/.../ComapeoCoreService.kt,ios/AppLifecycleDelegate.swift— extend argv with--applicationUsageData,--debug,--deviceClass,--osMajor,--platformTag.android/.../DeviceTags.kt(new),ios/DeviceTags.swift(new) — device classification + OS-major computation.backend/lib/sentry.js— renamecaptureApplicationDataargv field; adddebug/deviceClass/osMajor/platformTag;tracesSampleRate: argv.debug ? 1.0 : 0; splitrpcHookinto always-on metric write + debug-gated span.backend/lib/metrics.js(new) —Sentry.metrics.*wrapper with default-tag injection and thebefore_metric_senddefensive scrubber.backend/lib/sentry-init.js— re-exportSentry.metricstometrics.js.backend/index.js— register periodic memory gauge timer; callmetrics.bootPhaseat each boot-span end; wiremetrics.stateTransition.backend/loader.mjs— parse the new argv flags via the existingsentry.argSpec.docs/sentry-integration.md— rewrite §9 tier table to match Phase 11's three-toggle model; add §9.6 cross-link to this section.docs/sentry-integration-history.md— append Phase 11 entry on landing.
Tests added:
android/src/test/java/com/comapeo/core/DeviceTagsTest.kt,ios/Tests/DeviceTagsTests.swift— classification boundary cases.android/.../ComapeoPrefsMigrationTest.kt,ios/Tests/ComapeoPrefsMigrationTest.swift— old-key → new-key one-shot migration.backend/lib/metrics.test.mjs(new) — default-tag injection,before_metric_senddefensive scrubber, no-op when Sentry off.- Extensions to
backend/lib/sentry.test.mjsandsrc/__tests__/sentry.test.jsfor debug-on / debug-off branching.