Skip to content

fix(android,ios): make native metrics attributable to a device and a footprint - #250

Open
gmaclennan wants to merge 3 commits into
mainfrom
fix/exit-metrics-attribution
Open

fix(android,ios): make native metrics attributable to a device and a footprint#250
gmaclennan wants to merge 3 commits into
mainfrom
fix/exit-metrics-attribution

Conversation

@gmaclennan

@gmaclennan gmaclennan commented Aug 21, 2026

Copy link
Copy Markdown
Member

Reading the comapeo.app.exit data collected so far turned up two things that make it much less useful than intended.

It carries no device attributes. Grouping 512 exits by device_class returns a single "—" bucket, while comapeo.boot.phase_duration_ms next to it slices cleanly into low/mid/high. We can see that the FGS was killed, but not what kind of hardware killed it — the first question anyone asks of an OOM statistic. Android metric emission now funnels through a small shared emitter, SentryMetricEmit, which injects platform / device_class / os_major and runs the metric scrub before calling Sentry.metrics() — one path serves the FGS bridge's countMetric and the exit collector in both processes, so a call site can forget neither the attributes nor the scrub. On iOS SentryNativeBridge.countMetric does the same. Call-site attributes win on a key collision, and DeviceTags.compute is memoized (it is a process constant several startup paths ask for). The attribute names live in SentryTags on both platforms, pinned by tests against the backend's spelling.

The footprint at death is unreadable. pss_kb / rss_kb are numeric attributes and Explore cannot group by those — the same reason the durations here are already pre-bucketed strings. They are now additionally emitted as comapeo.app.exit.rss_bytes and comapeo.app.exit.pss_bytes distributions carrying the same attributes, so "how big was the process when the kernel took it" becomes a percentile query. A zero from ApplicationExitInfo (some reasons, some vendors) means "not measured" and is skipped, rather than recorded as a real zero that would drag every percentile towards it.

The raw numeric attributes stay — harmless, and useful if Explore ever groups by them.

These pair with comapeo.backend.rss_peak_bytes from #249: one says what the process grew to, the other what it died holding. One documented caveat: exit records are decoded on the launch after the death, so os_major reflects the OS at report time — exits straddling an OS update carry the new version.

On iOS, DeviceTags.compute() now reads only ProcessInfo — its first caller is MetricKit's background delivery queue, which must not touch UIDevice off the main thread. iOS exits still come from MetricKit via AppExitMetricsCollector (already routed through countMetric, so they pick up the attributes); MetricKit reports a daily aggregate window rather than per-process footprint, so there is no distribution equivalent to add there.

Emitter-level JVM tests pin the attribute injection, the merge direction, and the scrub on both the count and distribution paths; :comapeo-core-react-native:testDebugUnitTest is green at 161 tests and the Swift package tests at 146.

…print

Reading the `comapeo.app.exit` data we have collected so far turned up two
things that make it much less useful than intended.

It carries no device attributes. Grouping 512 exits by `device_class`
returns a single "—" bucket, while `comapeo.boot.phase_duration_ms` next to
it slices cleanly into low/mid/high. So we can see that the FGS was killed
but not what kind of hardware killed it, which is the first question anyone
asks of an OOM statistic. The backend's metrics layer injects `platform` /
`device_class` / `os_major` centrally, but these emissions go to
`Sentry.metrics()` directly — the main process has no `SentryFgsBridge.init`
to hang shared attributes off — so thread them through the collector, which
already has the Context.

The footprint at death is unreadable. `pss_kb` / `rss_kb` are *numeric*
attributes, and Explore cannot group by those — the same reason the
durations here are already pre-bucketed strings. Emit them additionally as
`comapeo.app.exit.rss_bytes` / `comapeo.app.exit.pss_bytes` distributions,
carrying the same attributes, so the fleet question "how big was the
process when the kernel took it" becomes a percentile query. A zero from
`ApplicationExitInfo` (some reasons, some vendors) means "not measured" and
is skipped rather than recorded as a real zero that would drag every
percentile down.

The raw numeric attributes stay, harmless and useful if Explore ever groups
by them.
@github-actions github-actions Bot added the fix Bug fix (changelog) label Aug 21, 2026
@gmaclennan

Copy link
Copy Markdown
Member Author

Verified end to end on a local Release build (Pixel_7a_API_34, device_class: low), three kill/relaunch cycles.

comapeo.app.exit is now attributable — this previously returned a single "—" bucket:

device_class  os_major     platform  proc  exit.reason      count
low           android.14   android   main  user_requested   2
low           android.14   android   fgs   user_requested   2

And the footprint at death is readable for the first time:

comapeo.app.exit.rss_bytes   fgs/low → 182.9 MB    main/low → 163.2 MB
comapeo.app.exit.pss_bytes   fgs/low → 107.2 MB    main/low →  68.4 MB

Consistent with the running-process gauges from #249 on the same device: peak 256 MB, settled ~172 MB, died holding 183 MB.

The same gap the exit metrics had applies to every metric emitted natively.
`rootkey.load` (2,352 samples) and `rootkey.wrapper_key.created` (536) both
come back with `device_class` and `platform` as "—", and so does the iOS
`comapeo.app.exit` from MetricKit. StrongBox availability and keystore
migration outcomes are close to being *device* facts, so not being able to
slice them by device class or OS version wastes most of what they could
tell us.

Fix it at the wrapper rather than per call site: `countMetric` on both
platforms now merges `platform` / `device_class` / `os_major` — the same
names the backend's metrics layer injects centrally, so a native metric and
a Node one slice in one Explore query. Call-site attributes win on a key
collision. Computed once per process: the answer cannot change within one.

The exit collector keeps threading them itself, since it calls
`Sentry.metrics()` directly and the main process has no
`SentryFgsBridge.init` to hang shared attributes off.
@gmaclennan gmaclennan changed the title fix(android): make exit telemetry attributable to a device and a footprint fix(android,ios): make native metrics attributable to a device and a footprint Aug 21, 2026
@gmaclennan

Copy link
Copy Markdown
Member Author

Extended after auditing the rest of the native metrics: the missing-device-attribution problem was not limited to the exit collector.

rootkey.load (2,352 samples) and rootkey.wrapper_key.created (536) both return device_class and platform as today, and so does the iOS comapeo.app.exit coming from MetricKit. StrongBox availability and keystore migration outcomes are close to being device facts, so losing that slice wastes most of what they could tell us.

So the fix now lands at the wrapper instead of per call site — SentryFgsBridge.countMetric (Android) and SentryNativeBridge.countMetric (iOS) merge platform / device_class / os_major, the same names the backend's metrics layer injects centrally, so native and Node metrics slice in one query. Call-site attributes win on a key collision, and the tags are computed once per process.

The exit collector still threads them itself, since it calls Sentry.metrics() directly and the main process has no SentryFgsBridge.init to hang shared attributes off.

Mirrored asMetricAttributes() tests on both platforms. Android JVM 156 tests / 0 failures; Swift package 146 tests / 0 failures.

@gmaclennan

Copy link
Copy Markdown
Member Author

Verified the extended fix on a local Release build (Pixel_7a_API_34, device_class: low). The 30-minute window catches both the old build's emissions and the new one's, side by side:

rootkey.load
  platform  device_class  os_major     outcome     count
  —         —             —            native      2      ← before
  android   low           android.14   native      1      ← after
  —         —             —            generated   1      ← before
  android   low           android.14   generated   1      ← after

rootkey.wrapper_key.created
  android   low           false                    1      ← after
  —         —             false                    1      ← before

strongbox: false sliced by device_class is exactly the query that was impossible before this.

… emitter

Review follow-ups: Android metric emission (FGS bridge countMetric and the
exit collector, both processes) now funnels through SentryMetricEmit, which
injects the device attributes and runs SentryMetricScrub — closing the
scrub gap on the exit-collector path and removing the manual deviceTags
threading. DeviceTags.compute is memoized (and a failure can no longer
abort SentryFgsBridge.init). iOS DeviceTags.compute drops the UIKit branch
so the first (MetricKit background queue) caller never touches UIDevice
off the main thread. Attribute names move to SentryTags constants on both
platforms. New emitter-level tests pin the injection, merge direction, and
scrub; docs correct the iOS exit-collector claim and the Node-parity
overstatement, and note the report-time os_major caveat.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (changelog)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant