Skip to content

ADFA-5526: include carousel metrics in crash reports - #1801

Open
davidschachterADFA wants to merge 5 commits into
feature/ADFA-5534-metrics-in-feedbackfrom
feature/ADFA-5526-metrics-in-crash-reports
Open

ADFA-5526: include carousel metrics in crash reports#1801
davidschachterADFA wants to merge 5 commits into
feature/ADFA-5534-metrics-in-feedbackfrom
feature/ADFA-5526-metrics-in-crash-reports

Conversation

@davidschachterADFA

@davidschachterADFA davidschachterADFA commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Every report the IDE sends now carries what the machine was doing in the minutes before it. A crash arrives with a stack and no idea whether memory was climbing, the daemon was thrashing, or the device was thermally throttled — and for an out-of-memory kill that history is most of the answer.

Four commits: a pruning fix, the buffer work, the pre-allocated destinations, then the attachment.

It did not need its blocker

This was marked blocked by ADFA-5494. It isn't. A crash is the one loss cause with a hookable momentIDEApplication already installs an UncaughtExceptionHandler — which is exactly why it can be served on its own. The low-memory kill that 5494 exists for produces no report at all, because nothing runs on a SIGKILL, so it was never this ticket's case. The two tickets had been conflated.

Not allocating while dying

A crash handler is the wrong place to ask for memory: the crash being reported may be the heap running out, and a handler that throws replaces a useful report with no report. So the eleven destinations a snapshot needs are taken at startup, where failing to get them is survivable and obvious.

  • copyInto writes a ring buffer into an array the caller already owns; toLongArray delegates to it. It requires an exact-length destination — a short one truncates the history and a long one leaves a stale tail, and both read as data.
  • MetricsScratch is single-use at a time rather than thread-confined. claim hands it to one caller, release gives it back, and a caller that cannot claim it allocates for itself instead of waiting — two writers into one array is a scrambled file, and a crash must not block on an export. withSnapshot scopes the claim to a block, because the snapshot points into the scratch, so the scratch must outlive whatever reads it, which is a file write rather than the call that assembled it.
  • Everything is inside runCatching, which in Kotlin catches Throwable. That is deliberate: an OutOfMemoryError raised in here would cost the whole report. Losing the attachment is the right way to fail.

And it costs less memory than before

Held for the life of the process
Before 859 KB (11 buffers × 10,000 × 8B)
Naive: add a scratch 1,719 KB
This PR: scratch + 3,600 entries 619 KB

MAX_USAGE_ENTRIES drops from 10,000 to 3,600. Ten thousand is nearly three hours at the default rate, of which the chart shows sixty samples at a time.

One thing to check rather than take from me: the constant counts samples, not time, so at the fastest offered rate of 100 ms it is six minutes rather than an hour. If the fast rates are meant to give hours of history, 3,600 is the wrong number — it is one constant, and I would rather change it now than have it noticed later.

ADFA-5536 is filed for the follow-on: storing each series in a type and unit appropriate to it, which would save a further ~310 KB. Not done here — it makes the storage layer mixed-unit, which is the shape that produces a double-multiply bug in the numbers the feature exists to report, and the cheaper lever above saves more.

Reaching the watchers from a crash

They live in an activity-scoped ViewModel, which is right for the carousel and no use to a handler with no activity that arrives on whatever thread threw. MetricsSource is the one indirection; the ViewModel registers itself and clears on onCleared, conditionally, since an activity recreation can register the replacement before the outgoing one is cleared.

The assembler's @UiThread comes off. Every read it makes already takes the watcher's own history lock, so it was always safe from any thread — the annotation was conservative rather than load-bearing.

A pruning bug found on the way

pruneTo chose "the oldest n" across every file and then skipped the one just written, so whenever that one sorted into the set it deleted one file too few. Two exports inside a single filesystem timestamp make that routine, and the directory crept one over the limit per collision. Pre-existing in the chart snapshots; reusing pruneTo for the CSV exports is what surfaced it, as a bounded-directory test finding four files where three were allowed. Fixed, with a test that ties the timestamps deliberately rather than relying on the collision happening.

Verification, including what is not verified

Unit tests: an attachment appears when there is history and gunzips to a valid CSV with the expected header; nothing is attached without a source, or without samples; the event comes back unchanged when the attachment throws. Plus the scratch's claim semantics, its destination sizes against MAX_USAGE_ENTRIES, install being idempotent, copyInto's ordering, identity and length check, and the stale-tail case. Full :app unit suite (689 tests) and spotlessCheck green.

The device leg is open, and I want that on the record rather than buried. adb shell am crash aborts the WebView renderer natively, which never reaches the Java uncaught handler — so it exercised nothing, and produced no attachment, correctly. I did not find another reachable path that raises a reported Java exception in the main process, and stopped hunting rather than spend more time on it. What that leaves unproven is only the last hop — that Sentry carries the Hint attachment into the envelope — since the file production and the decision logic are covered above. The cheapest way to close it is a debug-only crash trigger; say if that is wanted and I will add one behind BuildConfig.DEBUG.

Font scale

No UI.

Review fixes

The pruning fix has moved down to ADFA-5534, where the test that catches it lives — that PR was red on its own both directories stay bounded without it. Nothing is lost here; the tie-case test that shipped with it was vacuous and has been rewritten there. See #1800.

The scratch path carries the sample times. ADFA-5531 now reads each watcher's times and values in one critical section, so copyUsageInto and copyHistoryInto take a times destination alongside the value destinations and hand both back together. MetricsScratch already pre-allocated the three times arrays; they are filled through the same call as the values now rather than through a separate locked read, so the pre-allocated path has the same guarantee as the allocating one — a row of the crash attachment is one moment.

🤖 Generated with Claude Code

https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@davidschachterADFA
davidschachterADFA force-pushed the feature/ADFA-5534-metrics-in-feedback branch from c193d08 to 2405b68 Compare September 7, 2026 12:28
@davidschachterADFA
davidschachterADFA force-pushed the feature/ADFA-5526-metrics-in-crash-reports branch from 7524dac to a82ab2c Compare September 7, 2026 12:34
davidschachterADFA and others added 5 commits September 7, 2026 05:42
Two changes that pay for each other.

copyInto writes a ring buffer into an array the caller already owns.
toLongArray now delegates to it. Snapshotting the watchers otherwise
takes eleven fresh arrays, and the next commit needs a caller that
allocates nothing at all -- a crash handler must not ask for memory,
because the crash it is reporting may be the heap running out. It
requires an exact-length destination: a short one truncates the history
and a long one leaves a stale tail behind it, and both read as data.

MAX_USAGE_ENTRIES drops from 10,000 to 3,600. Ten thousand samples is
nearly three hours at the default rate, of which the chart shows sixty at
a time, and eleven buffers of it is 859KB held for the life of the
process. At 3,600 the live buffers plus the pre-allocated destinations
cost 619KB together -- less than the live buffers alone did before.

Note what the constant is not: it counts samples, not time, so at the
fastest offered rate of 100ms it is six minutes rather than an hour. If
the fast rates are meant to give hours of history, this is the wrong
number, and it is one constant.

Also relaxes readUsages to internal so a test can drive one sample
without starting the sampling loop, matching what ADFA-5514 does to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j
…hable

A crash handler is the wrong place to ask for memory. The crash being
reported may be the heap running out, and a handler that throws replaces
a useful report with no report. So the eleven destinations a snapshot
needs are taken at startup, where failing to get them is survivable and
obvious, and filled rather than allocated when a snapshot is taken.

MetricsScratch is single-use at a time rather than thread-confined:
claim hands it to one caller and release gives it back. A caller that
cannot claim it allocates for itself instead of waiting, because two
writers into one array is a scrambled file and a crash must not block on
an export. withSnapshot scopes the claim to a block, because the snapshot
points into the scratch and so the scratch has to outlive whatever reads
it -- which is a file write, not the call that assembled it.

MetricsSource is the other half: the watchers live in an activity-scoped
ViewModel, which is right for the carousel and no use to a crash handler
that has no activity and arrives on whatever thread threw. The ViewModel
registers itself and clears on onCleared, conditionally, since an
activity recreation can register the replacement before the outgoing one
is cleared.

The assembler's @UiThread comes off. Every read it makes takes the
watcher's own history lock, so it was always safe from any thread; the
annotation was conservative rather than load-bearing, and a crash does
not get to choose its thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j
A crash arrives with a stack and no idea what the machine was doing. The
minutes of memory, network, temperature and power before it are what turn
"it died" into a diagnosis, and for an out-of-memory kill they are most
of the answer.

Registered as a Sentry EventProcessor beside GlitchTipDiagnosticsContext
rather than on the uncaught exception handler, so it also covers the
non-fatal captureException calls the IDE makes deliberately, and events
raised from anywhere rather than only uncaught throws.

Everything is inside runCatching, which in Kotlin catches Throwable. That
is deliberate: this runs while the process is dying, and an
OutOfMemoryError raised in here would cost the whole report rather than
just the attachment. Losing the attachment is the right way to fail.
Attaches nothing when the editor has never run -- onboarding, the project
chooser and direct boot have no history, and direct boot has no
credential-protected cache to write to either -- and nothing when
nothing has been sampled, since a header-only file on every early crash
would be noise rather than context.

This does not need ADFA-5494, despite having been blocked on it. A crash
is the one loss cause with a hookable moment, which is why it can be
served alone; the low-memory kill 5494 exists for produces no report at
all, because nothing runs on a SIGKILL.

Verified by unit test: an attachment appears with history and gunzips to
a valid CSV, nothing is attached without a source or without samples, and
the event comes back unchanged when the attachment throws. NOT verified
on device: `am crash` aborts the WebView renderer natively, which never
reaches the Java uncaught handler, and I did not find another reachable
path that raises a reported Java exception in the main process. The
device leg of this is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j
The processor putting an Attachment on the Hint is our half of the hop;
whether Sentry carries it into the envelope it sends is the SDK's, and
asserting on our own call says nothing about that.

So this runs the real SDK against a transport that keeps what it is
handed, captures an exception, and reads the envelope back: an attachment
item named *.csv.gz, content type application/gzip, whose bytes gunzip to
a CSV with the expected header and at least one data row. Confirmed to
fail when the addAttachment call is removed. The negative case is pinned
too -- a session with no samples produces an envelope with no metrics
attachment.

Why this rather than a device run. A crash on device does reach the
processor: verified on a Pixel 6 Pro with a build carrying a
non-resolvable DSN, where `am crash` on the app's main pid wrote
2026_09_07_01_48_52_523.csv.gz at crash time. But no event envelope
survives to disk to read back, because the IDE's own uncaught handler
captures and then calls exitProcess without waiting for Sentry to flush.
Session and log envelopes cache fine, so the cache works; the crash event
never gets written. That looks like ordinary crash reports being lost
whatever this ticket does, and it is worth its own ticket rather than a
change here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j
Three stray braces from resolving this branch onto the row-alignment
fix, where copyUsageInto and copyHistoryInto grew a times destination.
No behaviour: the files did not parse before this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j
@davidschachterADFA
davidschachterADFA force-pushed the feature/ADFA-5534-metrics-in-feedback branch from 2405b68 to 6b40850 Compare September 7, 2026 12:43
@davidschachterADFA
davidschachterADFA force-pushed the feature/ADFA-5526-metrics-in-crash-reports branch from a82ab2c to fab3b7d Compare September 7, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant