Skip to content

feat(sentry): Phase 11 toggle rework, metrics layer + PII scrubbers#111

Merged
gmaclennan merged 25 commits into
mainfrom
feat/sentry-metrics-phase11
Jul 2, 2026
Merged

feat(sentry): Phase 11 toggle rework, metrics layer + PII scrubbers#111
gmaclennan merged 25 commits into
mainfrom
feat/sentry-metrics-phase11

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Jun 22, 2026

Copy link
Copy Markdown
Member

Implements the Sentry Phase 11 workblock (tracking #74) in one branch / one PR, plus the user.id half of #78 and the review fixes from the pre-merge review. Spec: docs/sentry-integration-plan.md §11.1–§11.8, §9b.1, §9b.2, §9b.5.

Part of #74 (tracking umbrella — remaining children stay open: #77, #78, #80#83, #190, #191)
Closes #75
Closes #76
Closes #161


#75 — Toggle rework (§11.1, §11.5, §11.6, §11.7, §11.2.b)

  • Rename captureApplicationDataapplicationUsageData everywhere: JS get/set, native bridge methods, the on-device stored key, the Expo plugin field, and the Node CLI flags. Deprecated aliases forward for one minor release; a one-shot stored-key migration runs on first prefs open on both platforms.
  • New debug toggle: get/setDebugEnabled (JS), setDebugEnabled (native), sentry.debug + sentry.debugEnabledAtMs slots, --debug argv, debugDefault plugin field. 72h auto-off (§11.5 specced 24h; widened during review): at process start the debug reader flips debug=false if switched on >72h ago and queues a comapeo.debug.auto_disabled breadcrumb; re-enable refreshes the window.
  • Device classification (§11.2.b): DeviceTags.{kt,swift} bucket the device low/mid/high by RAM + cores and compute <platform>.<major>. Passed to JS via sentryConfig.deviceTags and to Node via --deviceClass / --osMajor / --platformTag.
  • tracesSampleRate is now owned by native and mirrored by the other layers: 1.0 while the debug window is on, otherwise the plugin-configured rate (0 when unset — the expected production config). This resolves the four-init-site divergence documented in Sentry: captureApplicationData → tracesSampleRate gate diverges across the four init sites #161; the policy is documented in docs/sentry-integration.md §8/§9.

#76 — Metrics layer (§11.2, §11.3, §11.6, §11.8)

  • New backend/lib/metrics.js + src/sentry-metrics.ts: thin wrappers around Sentry.metrics.* that inject platform + units, no-op when Sentry is off, and run a forbidden-tag filter on every emission.
  • One metric per measurement (the planned .by_device mirrors were collapsed — the new Sentry metrics product bills by volume, not cardinality, so device tags ride the primary metrics and group-by does the slicing). The RPC method attribute is gated on applicationUsageData for privacy, not cardinality.
  • Wired end-to-end: RPC durations (client + server), boot-phase timings + outcome, 60s heap + event-loop-delay sampler (per-window max), state transitions, storage-size bucket, and the pre-existing comapeo.app.exit. Sync-session / shutdown / transport-health emitters ship unwired — call sites tracked in Sentry: sync-session transaction + bg/fg breadcrumbs (usage tier) #80 and Sentry: wire the remaining diagnostic metric emitters (shutdown phases, ipc.errors, telemetry.forwarding_failures) #190, and their rows in the §9.2 tier table are marked accordingly.
  • RPC hooks split on both sides: always record the metric; create a span only under debug, recording the metric while the span is active so it links to the trace. Hooks never capture issues — RPC rejections are the caller's decision. Backend consoleIntegration moved behind debug.

PII scrubbers (§9b.1, §9b.5 — the bulk of #77)

  • Mirrored scrubbers in src/sentry-scrub.ts (RN beforeSend, ahead of the host's chain) and backend/before-send.js (Node event processor), kept in sync by shared cases in test-support/scrubber-cases.js. Walks message, exception text, extra, contexts, breadcrumb message + data, span data, and structured logs.
  • Redacts rootKey markers and coordinate markers — lat / lng / lon / latitude / longitude, in key-value, JSON-quoted, and object-key forms.
  • HTTP breadcrumb URLs reduced to host-only (§9b.5).
  • The broad 22-char base64 rule is deliberately disabled (it matched trace ids, exception type names, and error_class tags); Sentry: narrower scrub rule for bare rootkey-shaped tokens (base64-22) #77 is re-scoped to the narrower design, so it stays open.

user.id — root user ID + monthly rotation (§9b.2 / §11.4, the user.id half of #78)

  • Identity anchors on a short root user ID (XXXX-XXXX-XXXX, base32 without I/L/O/U, 60 bits, secure RNG) generated lazily and stored in on-device settings so uninstall resets identity. Short and unambiguous so a user can hand-copy it from a screen.
  • Sentry user.id is always a derived hash (sha256("<root>|<salt>"), 16 hex chars): salt is the UTC YYYY-MM month by default (rotates monthly, cross-month unlinkable) or "permanent" under applicationUsageData (stable, cohort analysis works). The raw root ID is never sent.
  • Same value on all three SDKs per launch: native derives once, applies to its own scope, exposes sentryConfig.userId to JS (Sentry.setUser, locked), and passes --sentryUserId to the backend's initialScope.
  • getRootUserId() exposed from the /sentry sub-export for a debug/about screen; support can recompute historical monthly IDs from a shared root ID. Kotlin/Swift derivations share pinned test vectors.
  • The §9b.9 anchor resets remain in Sentry: reset exit-telemetry anchors on toggle cycle (§9b.9) #78 (re-scoped).

Review fixes

  • Scrubbers: added the lon field (@comapeo/schema's actual field name) and quote handling for JSON-serialized coordinates.
  • Preference setters update the in-memory live view only after the native write resolves, so a rejected write can't leave the getters reporting an opt-out that never persisted.
  • Docs reconciled: 72h window everywhere, the actual tracesSampleRate policy in the three places that described the old gate, §8 rewritten to what the shipped scrubbers do, unwired metric rows flagged in the tier table.

Checks run locally

  • npm run lint, npm run build — pass
  • npm test (jest, src) — 69/69
  • npm run backend:test — 73/73
  • swift test (ios/) — 118/118
  • ./scripts/run-instrumented-tests.sh --unit-only (Android JVM) — pass (incl. new SentryUserIdTest, prefs root-ID tests)

Deferred (tracked)

gmaclennan and others added 2 commits June 22, 2026 12:34
Implements the Sentry Phase 11 workblock (plan §11, §9b.1, §9b.5):

#75 toggle rework
- Rename captureApplicationData -> applicationUsageData across the JS
  API, native bridge methods, on-device stored key, Expo plugin field,
  and Node CLI flags. Deprecated aliases forward for one minor release;
  a one-shot stored-key migration runs on first prefs open (both
  platforms).
- New `debug` toggle (get/setDebugEnabled, sentry.debug +
  sentry.debugEnabledAtMs slots, --debug argv, debugDefault plugin
  field) with a 24h auto-off enforced in the native debug reader.
- Device classification (DeviceTags.{kt,swift}): low/mid/high by
  RAM + cores plus <platform>.<major>, plumbed to RN via
  sentryConfig.deviceTags and to Node via --deviceClass/--osMajor/
  --platformTag.
- tracesSampleRate now derives from debug (1.0/0).

#76 metrics layer
- New backend/lib/metrics.js + src/sentry-metrics.ts: wrappers that
  inject `platform` on every metric, attach device tags only on the
  .by_device mirrors, no-op when Sentry is off, and drop forbidden
  tags. RPC hooks split: always record the metric, span only under
  debug (recorded while active so it links to the trace). console
  integration moved behind debug; 60s memory/event-loop sampler,
  boot-phase durations, state transitions, storage-size bucket.

#77 PII scrubbers
- Shared regex list (rootKey, 22-char base64, lat/lng) in
  src/sentry-scrub.ts (RN) and backend/before-send.js (Node). RN wires
  the real beforeSend ahead of the host chain plus a host-only URL
  beforeBreadcrumb; Node registers the same scrub as an event
  processor. Walks message, exception, extra, contexts, breadcrumb
  message+data, span description+data; HTTP breadcrumb URLs reduce to
  host-only.

Tests: backend metrics/before-send suites, extended sentry suites
(debug branching + scrubber), native migration/24h-auto-off/device
boundary tests. npm lint, tsc, jest (18), backend node --test (47)
all green locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gmaclennan
gmaclennan marked this pull request as ready for review June 22, 2026 12:25
Scrubber (src/sentry-scrub.ts + backend/before-send.js, kept mirrored):
- Redact numeric/string lat/lng held as object fields (key-aware).
- Match base64url runs of 22+ chars so longer project keys/ids scrub.
- RN scrubEvent now reduces breadcrumb URLs to host-only via
  scrubBreadcrumb (symmetric with Node).
- Walk event.request (url→host, query_string/headers/cookies/data).

Metrics:
- Emit comapeo.rpc.client.send_ms on the always-on and debug paths.

Native (review-only, not compiled here):
- Android/iOS: read debug toggle before native Sentry init so the
  §11.5 auto_disabled breadcrumb is queued before the drain.
- Document the main-process auto-off breadcrumb drop on Android.

Tests:
- New RN suites: sentry-scrub, sentry-metrics, rpc-client-hook.
- Backend: real forbidden-NAME integration test through the wrappers;
  lat/lng-object, base64 43/52, and event.request regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New feature (changelog) label Jun 24, 2026
@gmaclennan

Copy link
Copy Markdown
Member Author

Phase 11 review fixes — a119f7e

Applied the confirmed findings. Scrubber changes are kept mirrored between src/sentry-scrub.ts (RN) and backend/before-send.js (Node).

Fixed

  • Numeric lat/lng as object fields bypass the scrubber (major) — scrubValue is now key-aware (SENSITIVE_KEY_PATTERN): {latitude, longitude, lat, lng} fields are redacted regardless of value type, at every nesting level. Both files.
  • 22-char-exact base64 boundary misses longer keys/ids (major) — quantifier widened to {22,} in SCRUB_PATTERNS and FORBIDDEN_METRIC_VALUE_PATTERNS in both files (rootKey@22, keypair public keys@43, z-base-32 ids@~52). Comments updated; floor kept at 22 (not 21).
  • RN scrubEvent left breadcrumb path+query intact (major) — RN breadcrumb loop now calls scrubBreadcrumb (host-only reduction), symmetric with Node; RN scrubBreadcrumb also runs data through scrubValue. Docstring updated for §9b.5.
  • scrubEvent never walked event.request (minor) — added a request branch (url→host, query_string/headers/cookies/data scrubbed) to both files; docstrings updated.
  • comapeo.rpc.client.send_ms never emitted (major) — now emitted via rpcClientSendMetric on the always-on path and the debug-span path in src/ComapeoCoreModule.ts.
  • debug 24h auto-off breadcrumb drained before queued (major, both platforms) — Android ComapeoCoreService.onCreate now reads debug/usage prefs before SentryFgsBridge.init; iOS didFinishLaunching calls readDebugEnabled() before initFromConfig/consume(). (Native — not compiled here; see below.)
  • Hollow "drops forbidden tag NAMES" test (major + minor dupes) — replaced with a real integration test driving a forbidden name/value through a new metrics.__testInternals seam; fails if the name-filter branch is removed.
  • RN metrics layer had no tests (major) — added src/__tests__/sentry-metrics.test.js (platform injection, by_device split, real isForbiddenMetric, off-switch, usage gating, rpcStatusFor).
  • RN rpcHook debug-on/off coverage missing (major) — added src/__tests__/rpc-client-hook.test.js (always-record-metric, debug-gated span, error path).
  • Added src/__tests__/sentry-scrub.test.js plus backend regressions (lat/lng-object, base64 43/52, event.request).

Documented, not code-changed

  • Main-process auto-off breadcrumb is undeliverable on Android (minor) — took the offered doc-only fallback: documented the cross-JVM drop in DebugAutoOff rather than adding JS-bridge plumbing (out of proportion for a minor timeline-marker loss). Flagging for a maintainer call on whether to wire the JS drain later.

Skipped

  • "PR deletes no tests" / rebase premise (nit) — no code change; addressed by fast-forwarding the branch to origin before working. git diff origin/main...HEAD is the correct three-dot review diff.

Checks

  • npm run lint — pass
  • npx tsc --noEmit (src) — pass
  • npm test (jest) — 4 suites / 35 tests pass. (Note: a stale cross-worktree symlink for babel-preset-expo in node_modules had to be repaired locally; not a repo change.)
  • backend node --test lib/**/*.test.mjs — 50 pass / 0 fail
  • backend npm run types fails only on pre-existing implicit-any in *.test.mjs fakes (identical on baseline; the new tests follow the existing fake-SDK pattern).

Reviewer please double-check (native, not compilable here)

  • android/.../ComapeoCoreService.kt: pref-read order moved above SentryFgsBridge.init.
  • ios/AppLifecycleDelegate.swift: readDebugEnabled() before initFromConfig.

…hase11

* origin/main: (92 commits)
  docs(readme): document uploading sourcemaps, dSYMs, and native symbols to Sentry
  feat(e2e): forward RPC traces to Sentry, "e2e" environment for both test apps
  fix(e2e): drop tsconfig paths alias that double-loaded the module
  Release v1.0.0-pre.6
  fix(android): wake blocked read in disconnect() and tidy close() teardown
  fix(android): shutdown IPC socket on JS reload so backend cleans up subscriptions
  docs: vendor relevant Expo skills into .claude/skills/
  docs: drop stale issue list from AGENTS.md, fix web platform status
  docs: slim AGENTS.md to orientation, defer detail to docs/
  docs: run local e2e against a Release build, drop the dev-client flow
  chore: add build:ios to match build:android in both test apps
  chore(deps): bump the minor-and-patch group across 1 directory with 14 updates
  docs: rename agents.md to AGENTS.md, add CLAUDE.md, and ship agent skills
  ci: align dependabot and action pinning with policy
  chore: add dev setup/test scripts and a Maestro e2e skill
  fix(android): stop dangling argv pointers from corrupting node args
  test(e2e): Maestro rootkey persistence across app restarts
  chore: keep package version in sync with origin/main
  fix(ios): ad-hoc-sign simulator tests with a keychain entitlement
  Release v1.0.0-pre.5
  ...

# Conflicts:
#	android/src/main/java/com/comapeo/core/ComapeoCoreService.kt
#	src/ComapeoCoreModule.ts
…rt cycle

The post-merge ComapeoCoreModule imports the Phase 11 metrics layer,
which eagerly pulls ./sentry and forms an import cycle back into this
module. Stub ./sentry-metrics like the other transitive deps so the
notification-wrapper test loads the real module without tripping the
cycle's eager readSentryConfig() call.
sampleStorageSize() recursively stats the entire private storage tree at
boot, but its only output is a bucket metric that no-ops when Sentry is
disabled. Gate the walk on metrics.isEnabled() so opted-out users don't
pay the disk I/O for a discarded sample.
@gmaclennan

Copy link
Copy Markdown
Member Author

Code review (xhigh, workflow-backed)

I merged main into this branch and ran a multi-agent review over the Phase 11 diff plus the merge resolution. Two conflicts were resolved (ComapeoCoreService.kt — combined main's deferred-backend-init + process-name detection with Phase 11's applicationUsageData/debug/deviceTags; and a trivial both-added interface conflict in ComapeoCoreModule.ts). All suites pass (jest 39/39, backend 51/51, tsc + eslint clean).

I've already pushed two fixes that needed no decision:

  • test(sentry): the merge made notification-permissions.test.js load the real ComapeoCoreModule, which now pulls the metrics layer and trips an import cycle — stubbed ./sentry-metrics like the test's other transitive deps.
  • perf(sentry): gated sampleStorageSize's recursive disk walk on metrics.isEnabled() (finding 6 below).

The rest below need your call or touch the PII scrubber, so I'm flagging rather than editing.


1. RPC error capture is now gated behind debug (off by default) — likely a regression

backend/lib/sentry.js:371 and src/ComapeoCoreModule.ts:344

On main, every RPC-handler rejection was sent to Sentry as an issue whenever Sentry was initialised (i.e. at the diagnostics tier, on by default). After this change, captureException only lives inside the debug-gated span path (sentry.js:427, ComapeoCoreModule.ts:403). The default (debug: false, and debug auto-expires after 24h) takes the early !debug branch, which records duration/error counters only and returns — so a throwing handler (e.g. a regression in observation.create) produces no Sentry issue, no stack trace, no grouping. On-call loses the error visibility the diagnostic tier previously had; only the comapeo.rpc.server.errors count moves.

If decoupling issue-capture from per-RPC tracing was intentional noise reduction, worth saying so explicitly. If not, captureException should run in the always-on path (gated on Sentry being up), independent of debug.

2. PII scrubber's broad token regex over-matches legitimate Sentry IDs

The base64-ish rule …[A-Za-z0-9_-]{22,}… (src/sentry-scrub.ts:48, mirrored in backend/before-send.js) is applied in three places where the matched value isn't PII:

  • contexts.trace.trace_id gets redacted (sentry-scrub.ts:141). A trace_id is 32 hex chars, so it matches and becomes "[redacted]". This breaks crash↔trace correlation — the exact feature this PR adds — and since Sentry/Relay validates trace_id as 32-hex, an invalid value can get the trace context stripped or the event dropped.
  • Long exception type names get redacted (sentry-scrub.ts:133 runs ex.type through scrubString). FailedPreconditionError, BlockNotAvailableError, etc. are single tokens ≥22 chars, so distinct error classes all collapse to one [redacted] issue title — losing grouping/alerting for the domain errors most worth tracking.
  • Long error_class metric tags get dropped (errorClassFormetrics.rpcServerError, sentry.js:451/377). The same pattern in FORBIDDEN_METRIC_VALUE_PATTERNS drops any metric whose tag value is ≥22 word chars, so comapeo.rpc.server.errors silently records nothing for longer-named errors — the error-rate dashboard under-counts precisely those.

These three share one root cause: the token rule is meant for secrets/opaque keys but also matches Sentry's own identifiers and PascalCase class names. Suggested direction (your call on the exact shape, since it's the PII boundary): exclude the trace context's id fields (trace_id/span_id/parent_span_id) and the exception type/error_class fields from the token rule, rather than running them through free-text scrubbing. Whatever you pick needs to land in both the RN and backend copies. A test asserting a real 32-hex trace_id survives would lock this in (today only contexts.geo is covered).

3. Unguarded promise chain in the backend !debug path can exit the process

backend/lib/sentry.js:373Promise.resolve(next(request)).then(onFulfilled, onRejected) has no trailing .catch. If a metrics.* call inside the rejection handler throws, the derived promise rejects unhandled, which your process-level handler routes to handleFatal('runtime')process.exit(1) — taking down in-flight RPCs and sync. The RN sibling already guards the equivalent chain with .catch(noop) (ComapeoCoreModule.ts:354); the backend should match. (Folds naturally into whatever you do for #1, since it's the same branch.)

4. Hand-mirrored scrubber/metrics modules will drift

src/sentry-scrub.tsbackend/before-send.js are byte-for-byte copies of ~200 lines (patterns, forbidden-tag sets, scrub*/isForbiddenMetric); src/sentry-metrics.tsbackend/lib/metrics.js duplicate the metric primitives + status classifier (rpcStatusFor/statusFor). The stated reason (different module systems) is thin — both are ESM graphs (Metro and rollup both follow import), so a shared core .js (+ .d.ts for the TS side) could serve both. The concrete risk: a new secret/coordinate shape added to one copy and forgotten in the other is a one-sided PII leak that each file's own tests still pass. Not a blocker, but the duplication is load-bearing for privacy, which raises the stakes.

Lower priority

  • src/sentry-metrics.ts:69 (+ metrics.js): distribution/count/gauge are three near-identical wrappers repeating the sentryUp → withPlatform → isForbiddenMetric → emit gate; a private emit(kind, …) collapses each trio and keeps the §11.8 safety gate in one place across the six call sites.
  • android/.../ComapeoPrefs.kt:192: the 24h debug auto-off breadcrumb is handed off via a process-local static (DebugAutoOff) that a later SDK-init drains. On Android the main process and the :ComapeoCore backend process are separate OS processes with separate memory, so a static set in one isn't visible in the other — on the common cold-start ordering the auto_disabled breadcrumb is lost. Already documented in-code as a deferred limitation; noting it so it isn't forgotten.

Findings verified by independent adversarial agents; 5 candidates were refuted and dropped (e.g. a Hermes-lookbehind concern — Hermes does support lookbehind). The import cycle in note (test fix above) only bites under eager module evaluation like jest's; Metro's inlineRequires likely defers it in production, but worth confirming the main entry (index.tsComapeoCoreModule first) loads cleanly without importing /sentry first.

captureException had moved into the debug-gated span path, so with debug
off (the default, and it auto-expires after 24h) a throwing RPC handler
produced no Sentry issue — only duration/error counters. Move capture to
the always-on path in both the backend rpcHook and the RN client hook,
gated only on Sentry being initialised. Add the missing trailing .catch on
the backend chain so a throw from a metrics call can't escalate to
unhandledRejection -> handleFatal -> process.exit.
The 22+-char URL-safe-base64 token rule over-matched Sentry's own
identifiers: it redacted contexts.trace.trace_id (breaking crash-to-trace
correlation and risking event rejection), collapsed long PascalCase
exception type names to [redacted] (breaking issue grouping), and dropped
metrics whose error_class tag was a long error name. Remove it from both
the RN scrubber and the backend mirror, leaving the targeted root_key= and
lat/lng rules. Bare tokens are unscrubbed until the team agrees a narrower
rule; documented in-code and in the tests.
@gmaclennan

Copy link
Copy Markdown
Member Author

Update — findings addressed (pushed):

  • Convert date timestamps to Date objects over RPC #1 (RPC error capture gated behind debug): confirmed unintended. captureException is now called on the always-on path in both the backend rpcHook and the RN client hook, gated only on Sentry being initialised, independent of debug. Added the missing .catch on the backend chain (Android Testing Infrastructure & Bug Fixes #3) so a throw from a metrics call can't escalate to process.exit. New tests lock in capture-on-debug-off (and no-capture when Sentry is down). No double-capture: debug-on still captures once in the span path.
  • Integrate into comapeo-mobile #2 (PII scrubber over-matching): the broad {22,} base64 token rule is disabled in both the RN scrubber and the backend mirror, per the decision to discuss a narrower rule with the team first. This fixes all three symptoms at the root — trace_id, exception type names, and error_class metric tags are no longer redacted. The targeted root_key= and lat/lng rules stay. Bare tokens are unscrubbed until a replacement lands; documented in-code and in the tests so it isn't silently forgotten.
  • Add iOS support & test infrastructure #6 (storage walk): gated on metrics.isEnabled() (pushed earlier).

Still open for the team: #4 (duplicated scrubber/metrics modules), #10 (the metric-wrapper dedup), #9 (Android cross-process breadcrumb, already documented as deferred), and the eventual narrower token-scrub rule.

The RPC hooks now observe errors for metrics + tracing only; neither the
backend rpcHook nor the RN client hook calls captureException. An RPC
rejection is often expected control flow (e.g. NotFound) that should not
auto-create a Sentry issue — whether an error is worth reporting is the
calling application's decision, made at the call site. The response and its
rejection reach the JS caller through rpc-reflector's own channel
independently of the hook, so not capturing here changes nothing about
propagation. Genuine backend fatals are still caught by the global
uncaughtException/unhandledRejection handlers (captureFatal).
@gmaclennan

Copy link
Copy Markdown
Member Author

Follow-up on the earlier review thread:

RPC error capture — removed from the hooks entirely. Tracing of the actual flow confirmed the RN client hook sees every RPC failure (handler errors come back deserialized with the backend stack preserved, plus client-only TimeoutError/ChannelClosedError), while the backend rpcHook only ever sees a subset (handler errors) — so capturing in both double-counts. More importantly, the hook is the wrong place to decide what's report-worthy: an RPC rejection is often expected control flow (e.g. NotFound) that shouldn't auto-create a Sentry issue. So both hooks are now metrics + tracing only; captureException is gone from both. Error reporting is left to the calling application at the call site. Genuine backend fatals are still covered by the global uncaughtException/unhandledRejection handlers (captureFatal).

Storage-size metric — the recursive-walk concern is filed as #178 (replace the JS walk with Android StorageStatsManager.queryStatsForUid, with a full plan + pros/cons). The interim metrics.isEnabled() gate stays in this PR.

Still open for the team: the duplicated scrubber/metrics modules, the metric-wrapper dedup, and the eventual narrower token-scrub rule (broad base64-22 rule remains disabled).

…e + error metrics

Front-end feature-usage recording moves to PostHog, so drop the RN
`recordUsage` API and the backend `comapeo.usage.screen`/`feature`
counters. The `applicationUsageData` toggle stays as the opt-in tier and
now gates the usage-revealing metric dimensions instead: the RPC `method`
breakdown (client + server latency and `send_ms`) and the sync
`peers_bucket`/`bytes_bucket` counts. Aggregate latency, sync
duration/outcome, storage-size bucket, boot/health/exit signals, and
device tags stay at the always-on diagnostic tier. The Android app-exit
exact-ms durations remain gated, with the collector's flag renamed from
`captureApplicationData` to `applicationUsageData`.

Drop the `comapeo.rpc.server.errors` counter entirely: routine RPC
rejections (NotFound and similar) aren't worth counting, and the latency
metric's `status` tag already carries the error rate. Significant errors
reach Sentry as issues via application code.

Also remove the now-dead `captureApplicationData` rename-migration code
and legacy aliases (the module is unreleased), and rewrite code comments
that referenced the temporary planning doc so they read as permanent.

Update docs/sentry-integration.md to the three-toggle model with a
per-signal gating table and justifications.
Comment thread android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
Comment thread android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
Comment thread android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
Comment on lines +67 to +72
// Read the debug + usage prefs BEFORE SentryFgsBridge.init:
// readDebugEnabled() may queue the auto_disabled breadcrumb, and init
// drains the DebugAutoOff queue (SentryFgsBridge.init →
// DebugAutoOff.consume). If init ran first the crumb would be lost on
// the launch that performed the auto-off. These reads are independent
// of the diagnostics gate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tighten this comment

* the production constructor.
* the production constructor and runs the one-shot key migration.
*/
internal class ComapeoPrefs(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right abstraction at this point, now we have added more values to it? Take a step back and think through other abstractions over SharedPreferences that might involve less boilerplate and reduce code needed for each additional pref that is added.

Comment on lines +161 to +176
/**
* Holds the `comapeo.debug.auto_disabled` breadcrumb queued by the 24h
* auto-off. [ComapeoPrefs.readDebugEnabled] runs before
* `Sentry.init`, so the breadcrumb can't be added directly; it's drained
* by [SentryFgsBridge.init] / RN init once the SDK is up.
*
* Known gap: the main app process and the `:ComapeoCore` FGS process are
* separate JVMs with separate `DebugAutoOff` statics. On the common
* cold-start ordering the main-process `sentryPreferences` read
* ([ComapeoCoreModule]) can win the 24h flip; the FGS read then sees
* `debug` already false and is a no-op, and the main process has no
* native-side drain — so the crumb queued there is currently dropped.
* The auto-off behaviour itself is unaffected; only the timeline marker
* is lost. Delivering it would need cross-process plumbing (expose the
* pending flag to JS and drain in the RN `initSentry` path).
*/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tighten this comment and make it clearer what the consequences of the current gap are.

const val DEFAULT_DEBUG = false

/** 24h in milliseconds. */
const val DEBUG_MAX_AGE_MS = 24L * 60 * 60 * 1000

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change debug auto-off to 72h (on both android and ios)

Comment thread ios/ComapeoCoreModule.swift Outdated
// (see `setDiagnosticsEnabled` / `setCaptureApplicationData`).
// (see `setDiagnosticsEnabled` / `setApplicationUsageData` /
// `setDebugEnabled`).
Constant("sentryPreferences") { () -> [String: Any] in

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same questions here as in the kotlin side

Comment thread ios/ComapeoPrefs.swift
///
/// Constructor takes read/write closures so unit tests don't need a
/// real `UserDefaults`.
final class ComapeoPrefs {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to kotlin, take a step back and thing whether this remains the best abstraction for these preferences - as we add more values to the prefs it starts to become a bit unweildy.

Comment thread README.md Outdated
- `getDebugEnabled()` / `setDebugEnabled(value)` — opt-in debug mode (per-RPC
traces, `@comapeo/core` OTel spans, richer capture). Restart-to-activate and
auto-expires 24h after the most recent enable.
- `recordUsage.screen(name)` / `recordUsage.feature(name)` — record a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed from the readme I think. Does this API still exist?

…trics rework

Correctness fixes surfaced by the code review:
- Break the ComapeoCoreModule -> sentry-metrics -> sentry import cycle
  (sentry-metrics reads device tags from readSentryConfig, memoized) so
  importing the package entry no longer throws a TDZ ReferenceError at load.
- Scrub the structured-log channel via beforeSendLog (RN + backend).
- scrubUrlToHost drops the query/fragment of relative URLs instead of
  falling back to a no-op; scrubValue guards against cycles and depth;
  the rootKey pattern stops at field delimiters instead of eating the rest
  of the string. All mirrored in src/sentry-scrub.ts + backend/before-send.js.
- Backend 60s sampler uses monitorEventLoopDelay and is skipped entirely
  when Sentry is off; rpcStatusFor never reports a failure as "ok".

Review-driven reworks:
- tracesSampleRate: native resolves the effective rate (debug window forces
  full, else the plugin cap) and forwards it; backend and RN mirror it.
- Drop the misleading rss gauge (whole-process on iOS); keep heap_used + uptime.
- ComapeoPrefs (Kotlin + Swift) persists through a Store abstraction instead
  of five positional lambdas; debug auto-off is 72h with a backward-clock guard.
- metrics.js gains a by_device mirror helper; the server per-method RPC series
  is dropped (the client end-to-end metric owns the method breakdown).
- Snapshot the applicationUsageData tier at boot rather than per RPC.

Docs/tests:
- Shared scrubber case table (test-support/) exercised by both the RN jest
  and backend node:test suites so the mirrored copies can't drift.
- Metrics reference table in docs/ARCHITECTURE.md; drop stale
  capture-application-data / recordUsage references from README and docs.
…e state

`getDiagnosticsEnabled` / `getApplicationUsageData` / `getDebugEnabled` read
the current persisted value instead of the boot snapshot, so a settings screen
reflects a `setX` made earlier in the same session and survives a JS reload —
application code no longer has to keep its own app-wide copy in sync.

- Add a synchronous native `getSentryPreferences()` (Kotlin + Swift) that reads
  the current SharedPreferences / UserDefaults values fresh (cheap in-memory
  reads). `debug` is the raw stored toggle via a new side-effect-free
  `ComapeoPrefs.readDebugStored()`, so a getter never triggers the launch-time
  auto-off write.
- The `sentryPreferences` Constant stays the boot snapshot that the module's own
  session behaviour (initSentry, metrics tier, debug tracing) is pinned to — a
  live read there would be wrong, and reading it once keeps it off hot paths.
- RN `readSentryPreferencesLive()` backs the public getters and falls back to the
  snapshot when the native live getter is absent (older native / tests).

Reading live rather than caching boot state is what makes the reload case work:
there is no cached snapshot to go stale.
…e server per-method

Sentry Application Metrics bills by ingested volume, not cardinality (high
cardinality is the default, not a limit), so the primary + `.by_device` split
was doubling emitted volume for no saving while making method × device queries
impossible. Collapse each pair into a single metric that carries every
dimension — status, phase, and the device tags all ride together — and slice
with group-by at query time.

- Drop every `.by_device` metric and the `distributionMirrored` helper; device
  tags now attach to the single duration metric (RN + backend).
- `method` (and the sync collaboration/volume buckets) stay gated on
  `applicationUsageData`, but as a privacy gate now, not a cardinality one:
  when on, `method` is just another attribute on the one metric.
- Restore server per-method RPC: rpcServer(method, status, ms) →
  `comapeo.rpc.server.duration_ms{method,status,device_class,os_major}`, so
  handler-vs-IPC latency is separable per method (cheap now that cardinality
  isn't billed).
- Update the metrics tests and the §7.5 metrics reference table to the
  single-metric model and the volume-billing rationale.

Verified both SDKs emit to the GA Application Metrics product: @sentry/core
10.58.0 (under @sentry/react-native 8.15.1) and @sentry/node-core 10.53.1,
both above the metrics minimums.
…op candidates

Each metric now states what we can actually learn from it, and honestly flags
the ones whose aggregate is weak or redundant, with a "candidates to reconsider"
list: fgs.uptime_s (sampled monotonic gauge, no actionable aggregate),
rpc.client.send_ms (redundant with the debug-span rn.send.syncMs attribute),
state.transitions (overlaps boot.outcome), and event_loop_delay_ms (60s mean is
too blunt — p99 would be sharper).
Remove metrics whose aggregate carried no actionable signal:
- comapeo.fgs.uptime_s — a 60s-sampled monotonic gauge; its average is
  meaningless and its max is just ≈ session length.
- comapeo.rpc.client.send_ms — redundant with the rn.send.syncMs attribute
  already recorded on the debug-gated client span (which is kept).
- comapeo.state.transitions — the started/error signal overlapped
  boot.outcome; the rest was routine lifecycle churn.

Keeps the rn.send.syncMs span attribute and the event-loop-delay gauge
(the latter's sampling strategy is being reworked separately).
…live read

The two native primitives are both needed but were confusingly named. Rename to
make the difference obvious, and stop the live read from running on every render.

- Constant `sentryPreferences` → `sentryPreferencesAtLaunch`: the snapshot in
  effect this session (initSentry, metrics tier, debug tracing are pinned to it).
- Function `getSentryPreferences` → `getCurrentSentryPreferences`: the current
  saved value (raw debug, no auto-off) for a settings screen to read back.
- JS wrappers follow: readSentryPreferences → readSentryPreferencesAtLaunch,
  readSentryPreferencesLive → readCurrentSentryPreferences.

The public getDiagnosticsEnabled/getApplicationUsageData/getDebugEnabled getters
now read an in-memory cache seeded once from the current-value native call and
kept in sync by the setters, so a preferences screen doesn't pay a synchronous
SharedPreferences/UserDefaults read (Android also a PackageManager metadata read)
on every render. A JS reload drops the cache and it re-seeds, so it still
reflects changes made since the native process launched.
The 60s mean buried a brief stall in ~6000 samples. Report the window max
(worst single stall per minute) as a distribution with device tags, so Sentry
gives fleet percentiles of the per-minute worst stall, sliceable by device
class. Drop resolution 20→10ms.

monitorEventLoopDelay is pure libuv (no addon/worker/inspector) so it runs in
nodejs-mobile; Sentry's stack-on-stall integrations don't (the deprecated
anrIntegration needs node:inspector, which nodejs-mobile disables via its intl
dependency; eventLoopBlockIntegration is a native addon with no mobile prebuild).
A stack-on-stall event is left as a possible follow-up.
@gmaclennan
gmaclennan enabled auto-merge July 1, 2026 20:03
@gmaclennan
gmaclennan disabled auto-merge July 1, 2026 20:06
Anchor Sentry identity on a random per-install root user ID stored in
native prefs and never sent to Sentry. user.id is sha256("<root>|<salt>")
truncated to 16 hex chars: salt is the UTC YYYY-MM month (rotates
monthly) by default, or the constant "permanent" when the user opts in
to applicationUsageData. Both derivations are recomputable from a
user-shared root ID (exposed via getRootUserId() for a debug screen), so
historical events can be re-associated for support cases.

Native derives the value once per process start and distributes it to
all three SDKs: Sentry.setUser on RN (via the sentryConfig constant),
scope user on the native inits, and --sentryUserId argv to the backend's
initialScope. Kotlin/Swift derivations share pinned test vectors.
The scrubbers covered lat/lng/latitude/longitude but not `lon` — the
field name @comapeo/schema observations actually use — and the marker
regex missed JSON-serialized coordinates (`"lat":-12.3`) because it
lacked the optional-quote handling the rootKey pattern already had.
Shared regression cases added so the two mirrored copies can't drift.
…write lands

The setters mutated the in-memory view before the native persist
resolved, so a rejected write (e.g. native context not attached) left
the getters reporting an opt-out that never happened — the UI would
show diagnostics off while the on-disk value stayed on.
… policy

The auto-off constant shipped as 72h but the breadcrumb text, JSDoc,
and docs still said 24h — align them all on 72h. Document the actual
trace-sampling policy (debug ? 1.0 : plugin rate, 0 when unset — a
build-time consumer decision, not a per-user gate) in the three places
that still described the old usage-toggle gate, and replace the stale
"identity placeholder" / base64-22 claims in §8 with what the shipped
scrubbers actually do.
Replace the UUID root user ID with XXXX-XXXX-XXXX from a Crockford-style
base32 alphabet (no I/L/O/U): 60 bits of entropy — ample collision
headroom across the install base — while short and unambiguous enough
for a user to manually transcribe from a screen for a support case.
Generated with a secure RNG on both platforms; stored and hashed exactly
as formatted, hyphens included.
Sync-session, shutdown-phase, and transport-health signals have shipped
emitters but no call sites yet (#80, #190) — flag their rows so nobody
builds a dashboard on empty series. Also update the RPC rows to the
collapsed single-metric shape (the .by_device mirrors are gone).
…peo-core-react-native into feat/sentry-metrics-phase11

* 'feat/sentry-metrics-phase11' of github.com:digidem/comapeo-core-react-native:
  chore: workflow cleanup & syntax highlight hints
  merge main
  feat(sentry): symbolicate backend errors in debug builds without uploading sourcemaps
  test(ios): fix shutdown-ordering race in IPC lifecycle tests
  Release v1.0.0-pre.8
  chore(deps): bump the minor-and-patch group across 1 directory with 3 updates
  chore(deps): bump rpc-reflector
  fix(sentry): make initSentry idempotent across JS-bundle reloads
  chore(deps): bump @comapeo/ipc
  Release v1.0.0-pre.7
  chore(deps): bump comapeocat
  chore(deps): bump @digidem/types
  chore(deps): bump the minor-and-patch group across 1 directory with 4 updates
  ci: reference PR bot app-id/user-id as vars, not secrets
  ci(deps): fast-track first-party dependency updates
  fix(android): address review — @volatile watchdog fields, stronger test, doc fix
  fix(android): graceful node drain before self-terminate kill
  test(android): debug-gated seam to exercise the FGS self-terminate watchdog
  feat(android): self-terminate FGS on terminal ERROR for uniform crash recovery
@gmaclennan
gmaclennan added this pull request to the merge queue Jul 2, 2026
Merged via the queue into main with commit f0a2a79 Jul 2, 2026
26 checks passed
@gmaclennan
gmaclennan deleted the feat/sentry-metrics-phase11 branch July 2, 2026 05:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature (changelog)

Projects

None yet

1 participant