@gravitylabsllc/porthole 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/trace.ts ADDED
@@ -0,0 +1,769 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import type { DeviceEvent } from "./device.js";
4
+
5
+ /**
6
+ * Turning a recorded run into something worth reading.
7
+ *
8
+ * A dump of events serves only a machine, and badly. What a person wants back
9
+ * from a capture is a short list of what is worth looking at; what CI wants is
10
+ * a number it can compare. Both come from here.
11
+ */
12
+
13
+ /**
14
+ * The shape of the trace file.
15
+ *
16
+ * Nothing validates against it on read, so it is a label rather than a gate.
17
+ * Adding an optional field or a new metric key is therefore compatible by
18
+ * construction — `compare` fills a key the other side lacks with zero — and does
19
+ * not move this number. Removing or repurposing one would.
20
+ */
21
+ export const TRACE_VERSION = 1;
22
+
23
+ export type Severity = "error" | "warning" | "note";
24
+
25
+ /**
26
+ * How strongly a finding can be claimed.
27
+ *
28
+ * Load-bearing, not decoration. "observed" means the device said so — a query
29
+ * ran on the main thread, a frame missed its deadline. "correlated" means two
30
+ * things happened near each other, which is ordering and not causation. A
31
+ * report that blurs the two teaches people to distrust all of it.
32
+ */
33
+ export type Confidence = "observed" | "correlated";
34
+
35
+ export interface Finding {
36
+ id: string;
37
+ severity: Severity;
38
+ confidence: Confidence;
39
+ title: string;
40
+ detail?: string;
41
+ count?: number;
42
+ /** The mark this fell under, when the run was marked. */
43
+ during?: string;
44
+ evidence?: Record<string, unknown>;
45
+ /**
46
+ * Where this finding sits on the device's uptime clock (GRA-113) — the same
47
+ * clock every Porthole event and `momentOf` already speak in. Every finding
48
+ * `/api/findings` returns carries exactly one of `window` or `spanning`,
49
+ * never both and never neither: a finding that cannot say where it belongs
50
+ * is a defect in the code that produced it, not a legitimate third state.
51
+ *
52
+ * `from`/`to` may be equal — a single instant is a zero-width window, not a
53
+ * special case — and, for a trace-derived finding, arrives already
54
+ * converted through `moment.ts`'s `fromBootMs`, never as the raw
55
+ * boot-clock ns a query answered in.
56
+ */
57
+ window?: { from: number; to: number };
58
+ /**
59
+ * Set instead of `window` for a finding that is a property of the whole
60
+ * window it was asked about rather than of a moment inside it — a
61
+ * thread-state aggregate summed across however many disjoint stretches the
62
+ * scheduler happened to visit that state, say, or GRA-58's exit findings,
63
+ * whose `exit.t` is stamped in the *next* process's uptime clock and so
64
+ * cannot be honestly placed on this session's axis at all. Drawing either
65
+ * as a point under one frame would invent a precision neither one has.
66
+ */
67
+ spanning?: true;
68
+ }
69
+
70
+ export interface Trace {
71
+ porthole: number;
72
+ scenario: string;
73
+ capturedAt: string;
74
+ durationMs: number;
75
+ driver?: string;
76
+ app: Record<string, unknown>;
77
+ device: Record<string, unknown>;
78
+ marks: Array<{ at: number; label: string; detail?: string }>;
79
+ metrics: Record<string, number>;
80
+ findings: Finding[];
81
+ events?: DeviceEvent[];
82
+ }
83
+
84
+ /** Exported for `index.ts`'s `exits` section (GRA-58): the same loose coercion every event field here already gets, so a device's numeric fields don't need retyping at a second call site. */
85
+ export const num = (value: unknown, fallback = 0): number => {
86
+ if (typeof value === "number") return value;
87
+ const parsed = Number(value);
88
+ return Number.isFinite(parsed) ? parsed : fallback;
89
+ };
90
+
91
+ export const str = (value: unknown, fallback = ""): string =>
92
+ typeof value === "string" ? value : value == null ? fallback : String(value);
93
+
94
+ /**
95
+ * The value below which `share` of the samples fall.
96
+ *
97
+ * Percentiles rather than means throughout: a mean frame time is the one number
98
+ * guaranteed to hide the problem, because the frames anyone cares about are the
99
+ * tail.
100
+ *
101
+ * Only ever fed completed work. A span that was still open has a floor under
102
+ * its duration and not a duration, and a floor mixed into a distribution of
103
+ * completions drags the tail *down* — the longest wait in the run would make
104
+ * the p95 look better. Open spans are counted and named separately instead.
105
+ */
106
+ function percentile(values: number[], share: number): number {
107
+ if (values.length === 0) return 0;
108
+ const sorted = [...values].sort((a, b) => a - b);
109
+ const index = Math.min(sorted.length - 1, Math.floor(share * sorted.length));
110
+ return Math.round(sorted[index]);
111
+ }
112
+
113
+ /** A span whose end arrived: `ms` is how long it took. */
114
+ export interface CompletedSpan {
115
+ open: false;
116
+ ms: number;
117
+ data: Record<string, unknown>;
118
+ /** When it ended — always known, since only a closed span reaches here. */
119
+ endedAt: number;
120
+ /**
121
+ * When it started, if this capture saw the start; null for an end with no
122
+ * start (a capture that attached mid-call, not a hang — see `spans`).
123
+ */
124
+ startedAt: number | null;
125
+ }
126
+
127
+ /** A span that was still running when the events ran out. */
128
+ export interface OpenSpan {
129
+ open: true;
130
+ /**
131
+ * A floor under how long it ran, in ms — it had lasted at least this when the
132
+ * last event arrived, and the real figure is larger and unknowable from here.
133
+ *
134
+ * Named for what it is rather than `ms`, because a number that is silently a
135
+ * lower bound gets averaged, plotted and regression-gated as though it were a
136
+ * measurement, and no caller ever finds out.
137
+ */
138
+ atLeastMs: number;
139
+ /** When it started, which is the only timestamp it has. */
140
+ startedAt: number;
141
+ data: Record<string, unknown>;
142
+ }
143
+
144
+ export type Span = CompletedSpan | OpenSpan;
145
+
146
+ // Predicates rather than inline `!s.open`, so the narrowing survives `filter`
147
+ // and the compiler is the thing that stops `atLeastMs` reaching a percentile.
148
+ const isCompleted = (span: Span): span is CompletedSpan => !span.open;
149
+ const isOpen = (span: Span): span is OpenSpan => span.open;
150
+
151
+ /**
152
+ * Start/end pairs recovered from the event stream — including the ones with no
153
+ * end.
154
+ *
155
+ * A span still open when the recording stops is the shape of a hang: a request
156
+ * that never returns, a query that never completes, a job wedged on a lock. It
157
+ * used to be dropped here, which meant the trace reported fewer calls than were
158
+ * made, no percentile influence, and no finding about the one that mattered —
159
+ * the run that most needed investigating came back looking like the quietest on
160
+ * record. They are emitted instead, marked `open`, carrying a floor under their
161
+ * duration measured to the last event seen.
162
+ *
163
+ * An end with no start is the mirror case and is not a hang: that is a capture
164
+ * that attached mid-call. It keeps the device's own `elapsedMs`.
165
+ */
166
+ function spans(events: DeviceEvent[], prefix: string): Span[] {
167
+ const open = new Map<string, { at: number; data: Record<string, unknown> }>();
168
+ const out: Span[] = [];
169
+ let lastAt = 0;
170
+
171
+ for (const event of events) {
172
+ lastAt = Math.max(lastAt, event.t);
173
+ const id = str(event.data.id);
174
+ if (event.event === `${prefix}_start`) {
175
+ // Not an unconditional `set`. A repeated id is the device contradicting
176
+ // itself, and overwriting would silently discard the earlier start — the
177
+ // same class of defect as dropping open spans. Keeping the first start
178
+ // keeps the floor conservative and invents nothing.
179
+ if (!open.has(id)) open.set(id, { at: event.t, data: event.data });
180
+ } else if (event.event === `${prefix}_end`) {
181
+ const started = open.get(id);
182
+ open.delete(id);
183
+ const ms = started === undefined ? num(event.data.elapsedMs) : event.t - started.at;
184
+ out.push({
185
+ open: false,
186
+ ms,
187
+ data: event.data,
188
+ endedAt: event.t,
189
+ startedAt: started ? started.at : null,
190
+ });
191
+ }
192
+ }
193
+
194
+ // Insertion order, so these come out oldest first.
195
+ for (const started of open.values()) {
196
+ out.push({
197
+ open: true,
198
+ atLeastMs: lastAt - started.at,
199
+ startedAt: started.at,
200
+ data: started.data,
201
+ });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ export function metricsOf(events: DeviceEvent[]): Record<string, number> {
207
+ const frames = events.filter((e) => e.event === "frame");
208
+ const stalls = events.filter((e) => e.event === "blocked");
209
+ const db = spans(events, "db");
210
+ const http = spans(events, "http");
211
+ const work = spans(events, "work");
212
+ const recompose = events.filter((e) => e.event === "recompose");
213
+ const memory = events.filter((e) => e.event === "memory");
214
+ const gc = events.filter((e) => e.event === "gc");
215
+
216
+ const perFrame = new Map<number, number>();
217
+ for (const event of recompose) {
218
+ const bucket = Math.floor(event.t / 16);
219
+ perFrame.set(bucket, (perFrame.get(bucket) ?? 0) + 1);
220
+ }
221
+
222
+ return {
223
+ "frames.missed": frames.reduce((sum, e) => sum + num(e.data.missedFrames, 1), 0),
224
+ "frames.worstMs": frames.reduce((worst, e) => Math.max(worst, num(e.data.totalMs)), 0),
225
+ "frames.p95Ms": percentile(
226
+ frames.map((e) => num(e.data.totalMs)),
227
+ 0.95,
228
+ ),
229
+
230
+ "mainThread.stalls": stalls.length,
231
+ "mainThread.worstMs": stalls.reduce((worst, e) => Math.max(worst, num(e.data.durationMs)), 0),
232
+ "mainThread.blockedMs": stalls.reduce((sum, e) => sum + num(e.data.durationMs), 0),
233
+
234
+ // Counts are over everything that started, percentiles over what finished.
235
+ // A query that never came back still happened, and still cost the user the
236
+ // wait; it just has no duration to put in a distribution.
237
+ "db.queries": db.length,
238
+ "db.stillOpen": db.filter(isOpen).length,
239
+ "db.onMainThread": db.filter(
240
+ (q) => q.data.onMainThread === "true" || q.data.onMainThread === true,
241
+ ).length,
242
+ // Over completed queries only — see `percentile`.
243
+ "db.p95Ms": percentile(
244
+ db.filter(isCompleted).map((q) => q.ms),
245
+ 0.95,
246
+ ),
247
+
248
+ "http.calls": http.length,
249
+ "http.stillOpen": http.filter(isOpen).length,
250
+ "http.failed": http.filter((c) => num(c.data.status) >= 400 || c.data.error !== undefined)
251
+ .length,
252
+ // Over completed calls only — see `percentile`.
253
+ "http.p95Ms": percentile(
254
+ http.filter(isCompleted).map((c) => c.ms),
255
+ 0.95,
256
+ ),
257
+
258
+ "recompose.total": recompose.length,
259
+ "recompose.peakPerFrame": perFrame.size ? Math.max(...perFrame.values()) : 0,
260
+
261
+ "memory.peakHeapMb": memory.reduce((peak, e) => Math.max(peak, num(e.data.heapUsedMb)), 0),
262
+ "memory.peakRamMb": memory.reduce((peak, e) => Math.max(peak, num(e.data.totalRamMb)), 0),
263
+ "memory.blockingGcMs": gc.reduce((sum, e) => sum + num(e.data.pausedMs), 0),
264
+
265
+ "work.runs": work.length,
266
+ "work.stillOpen": work.filter(isOpen).length,
267
+ "work.retries": work.filter((w) => w.data.retrying === "true").length,
268
+ "work.failures": work.filter((w) => str(w.data.state) === "FAILED").length,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * The envelope from the earliest to the latest of a set of real, timestamped
274
+ * events — for a finding that aggregates several occurrences (a run of
275
+ * blocking GCs, every `trimMemory` call) rather than naming one. Unlike a
276
+ * trace-side aggregate summed with no timestamp at all, each contributor here
277
+ * has a real `t`, so the envelope is read off the events that produced the
278
+ * finding, not invented for it. Undefined only for an empty list, which no
279
+ * caller should ever pass — every call site already checked `.length > 0`.
280
+ */
281
+ function eventWindow(events: DeviceEvent[]): { from: number; to: number } | undefined {
282
+ if (events.length === 0) return undefined;
283
+ const ts = events.map((e) => e.t);
284
+ return { from: Math.min(...ts), to: Math.max(...ts) };
285
+ }
286
+
287
+ /** `eventWindow`'s counterpart for a list of spans rather than raw events. */
288
+ function spanWindow(spans: Span[]): { from: number; to: number } | undefined {
289
+ if (spans.length === 0) return undefined;
290
+ const starts = spans.map((s) => (s.open ? s.startedAt : (s.startedAt ?? s.endedAt)));
291
+ const ends = spans.map((s) => (s.open ? s.startedAt : s.endedAt));
292
+ return { from: Math.min(...starts), to: Math.max(...ends) };
293
+ }
294
+
295
+ /** The mark in force at a moment, or undefined if the run was not marked. */
296
+ function markAt(marks: Trace["marks"], at: number): string | undefined {
297
+ let current: string | undefined;
298
+ for (const mark of marks) {
299
+ if (mark.at <= at) current = mark.label;
300
+ else break;
301
+ }
302
+ return current;
303
+ }
304
+
305
+ /**
306
+ * The frame budget for this device, in ms.
307
+ *
308
+ * Derived from the refresh rate rather than assuming 60: a budget is 8.3ms on a
309
+ * 120Hz panel, and calling a 10ms frame fine there is wrong.
310
+ */
311
+ export function frameBudgetMs(refreshHz: number): number {
312
+ const hz = refreshHz > 1 ? refreshHz : 60;
313
+ return Math.round((1000 / hz) * 10) / 10;
314
+ }
315
+
316
+ /**
317
+ * The budget clause every place that names a frame budget in prose shares
318
+ * (GRA-185's "second, smaller thing": `frames` and `findings` used to print
319
+ * the same quantity two different ways on the same panel). `findingsOf`'s
320
+ * own `frames-dropped` title and `frames`' tool text (index.ts) both call
321
+ * this, so the two cannot drift apart again the way GRA-185 found them.
322
+ *
323
+ * When `assumed` is true this is a guess dressed as one, not printed as an
324
+ * observed fact — `resolveProfile`'s own doc comment explains why the
325
+ * fallback matters enough to say so in the sentence itself.
326
+ */
327
+ export function describeBudget(profile: { refreshHz: number; assumed: boolean }): string {
328
+ const ms = frameBudgetMs(profile.refreshHz);
329
+ return profile.assumed
330
+ ? `${ms}ms (assumed ${Math.round(profile.refreshHz)}Hz; no display profile seen)`
331
+ : `${ms}ms at ${Math.round(profile.refreshHz)}Hz`;
332
+ }
333
+
334
+ /** The fields a `device`/`profile` event, or a session's `meta.json`, carries about the device. Structurally what `sessions.ts`'s `SessionMeta.profile` stores. */
335
+ export interface ProfileData {
336
+ model: string;
337
+ sdkInt: number;
338
+ abi: string;
339
+ cores: number;
340
+ deviceRamMb: number;
341
+ refreshHz: number;
342
+ lowRamDevice: boolean;
343
+ }
344
+
345
+ /**
346
+ * `null` unless `event` is a `device`/`profile` event — the one place that
347
+ * shape is read off the wire, shared by `resolveProfile` below (the live-
348
+ * buffer scan) and `sessions.ts`'s `SessionWriter.append` (capturing it into
349
+ * `meta.json` as it flows past), so the two readings can never disagree
350
+ * about what a profile event means.
351
+ */
352
+ export function profileFromEvent(event: DeviceEvent): ProfileData | null {
353
+ if (event.event !== "device" || str(event.data.kind) !== "profile") return null;
354
+ return {
355
+ model: str(event.data.model),
356
+ sdkInt: num(event.data.sdkInt),
357
+ abi: str(event.data.abi),
358
+ cores: num(event.data.cores),
359
+ deviceRamMb: num(event.data.deviceRamMb),
360
+ refreshHz: num(event.data.refreshHz, 60),
361
+ lowRamDevice: event.data.lowRamDevice === "true" || event.data.lowRamDevice === true,
362
+ };
363
+ }
364
+
365
+ /** What `buildTrace` needs to know about the device: the resolved refresh rate, and whether that number is something the device actually reported (`assumed: false`, `full` carries the rest) or a guess (`assumed: true`, `full` absent). */
366
+ export type ResolvedProfile = { assumed: false; refreshHz: number; full: ProfileData } | { assumed: true; refreshHz: number };
367
+
368
+ /**
369
+ * GRA-185 ruling 1: the device profile used for the frame budget must not
370
+ * depend on whether the requested window happens to contain the one
371
+ * `device`/`profile` event `DeviceCollector` emits at startup — a window
372
+ * that starts after startup used to silently fall back to 60Hz and print it
373
+ * as though it were observed. Resolved in one order, everywhere:
374
+ *
375
+ * 1. The most recent profile event in the *live* buffer at or before the
376
+ * window's end, **regardless of the window's start** — `liveEvents`
377
+ * here must be the whole ring (or run), never pre-filtered to `from`.
378
+ * 2. Else the profile recorded in the current session's `meta.json`
379
+ * (`SessionWriter.append` captures it as it flows past — see that
380
+ * function's own comment) — covers a window the live ring has already
381
+ * rolled the startup event out of, or a disk-only read
382
+ * (`saveFromSessions`) with no live ring at all.
383
+ * 3. Else the fallback: assumed 60Hz, `assumed: true`. Every caller
384
+ * (`findings`, `save_moment`, `porthole save`, `/api/findings`,
385
+ * `/api/save`, and `frames`' own prose) resolves through this one
386
+ * function — no second copy of the search order to drift from it.
387
+ */
388
+ export function resolveProfile(params: {
389
+ /** The full live buffer/run, unfiltered by the window's own `from` — see point 1 above. Pass `[]` when there is no live buffer to search (the CLI's disk-only path). */
390
+ liveEvents: DeviceEvent[];
391
+ /** Only a profile at or before this counts — never one from later than what is being described. */
392
+ windowTo: number;
393
+ /** `meta.json`'s own `profile` field for the session in force, if any is on disk. */
394
+ sessionProfile?: ProfileData | null;
395
+ hello: Record<string, unknown> | null;
396
+ }): ResolvedProfile {
397
+ const { liveEvents, windowTo, sessionProfile } = params;
398
+
399
+ let latest: { t: number; data: ProfileData } | null = null;
400
+ for (const event of liveEvents) {
401
+ if (event.t > windowTo) continue;
402
+ const data = profileFromEvent(event);
403
+ if (!data) continue;
404
+ if (!latest || event.t > latest.t) latest = { t: event.t, data };
405
+ }
406
+ if (latest) return { assumed: false, refreshHz: latest.data.refreshHz, full: latest.data };
407
+
408
+ if (sessionProfile) return { assumed: false, refreshHz: sessionProfile.refreshHz, full: sessionProfile };
409
+
410
+ return { assumed: true, refreshHz: 60 };
411
+ }
412
+
413
+ /**
414
+ * What was still running when the recording stopped.
415
+ *
416
+ * Its own finding rather than a line folded into the counts, because an
417
+ * unfinished call is not a slow call and the two want different responses. The
418
+ * wording carries "at least" into the title and the detail on purpose: the
419
+ * number is a floor, and a reader who copies it into a bug report should copy
420
+ * that qualifier with it.
421
+ */
422
+ function stillOpenFinding(
423
+ lane: Span[],
424
+ id: string,
425
+ noun: { one: string; many: string },
426
+ describe: (data: Record<string, unknown>) => { label: string; evidence: Record<string, unknown> },
427
+ marks: Trace["marks"],
428
+ ): Finding | undefined {
429
+ const open = lane.filter(isOpen);
430
+ if (open.length === 0) return undefined;
431
+
432
+ const oldest = open.reduce((a, b) => (a.atLeastMs >= b.atLeastMs ? a : b));
433
+ const { label, evidence } = describe(oldest.data);
434
+ const count = open.length;
435
+
436
+ return {
437
+ id,
438
+ severity: "warning",
439
+ confidence: "observed",
440
+ title:
441
+ `${count} ${count === 1 ? noun.one : noun.many} ${count === 1 ? "was" : "were"} ` +
442
+ `still open when the capture ended, the oldest for at least ${oldest.atLeastMs}ms`,
443
+ detail: `oldest: ${label} — at least, not exactly: it had not finished, so that is a floor under the wait`,
444
+ count,
445
+ during: markAt(marks, oldest.startedAt),
446
+ evidence: { oldestAtLeastMs: oldest.atLeastMs, ...evidence },
447
+ // `to` is the last moment we know it was still open — the capture's own
448
+ // last event, which is exactly what `atLeastMs` is already measured
449
+ // against — not an invented "now". A window that stopped narrating
450
+ // wherever the run happened to end would be lying about how sure it is.
451
+ window: { from: oldest.startedAt, to: oldest.startedAt + oldest.atLeastMs },
452
+ };
453
+ }
454
+
455
+ export function findingsOf(
456
+ events: DeviceEvent[],
457
+ marks: Trace["marks"],
458
+ refreshHz: number,
459
+ /** GRA-185: true when `refreshHz` is the 60Hz fallback rather than something the device reported — flips `frames-dropped` from `observed` to `correlated` and says so in the title, instead of stating a guess as fact. Defaults to false so every existing caller (a bare refresh rate, no opinion on how it was derived) keeps behaving exactly as before. */
460
+ assumed = false,
461
+ ): Finding[] {
462
+ const findings: Finding[] = [];
463
+ const db = spans(events, "db");
464
+ const http = spans(events, "http");
465
+ const work = spans(events, "work");
466
+
467
+ // Completed queries only, and not merely to have an `ms` to sort on: which
468
+ // thread a query ran on is reported by the *end* event, so an open span has
469
+ // nothing to answer the question with. A query still running on the main
470
+ // thread when the capture stopped is reported by `db-still-open` instead,
471
+ // which is the more alarming finding of the two anyway.
472
+ const onMain = db
473
+ .filter(isCompleted)
474
+ .filter((q) => q.data.onMainThread === "true" || q.data.onMainThread === true);
475
+ if (onMain.length > 0) {
476
+ const worst = onMain.reduce((a, b) => (a.ms >= b.ms ? a : b));
477
+ findings.push({
478
+ id: "db-on-main-thread",
479
+ severity: "error",
480
+ confidence: "observed",
481
+ title: `${onMain.length} database ${onMain.length === 1 ? "query" : "queries"} ran on the main thread`,
482
+ detail: `Worst was ${worst.ms}ms: ${str(worst.data.sql).slice(0, 80)}`,
483
+ count: onMain.length,
484
+ evidence: { worstMs: worst.ms, sql: str(worst.data.sql) },
485
+ window: { from: worst.startedAt ?? worst.endedAt, to: worst.endedAt },
486
+ });
487
+ }
488
+
489
+ const stalls = events.filter((e) => e.event === "blocked");
490
+ if (stalls.length > 0) {
491
+ const worst = stalls.reduce((a, b) =>
492
+ num(a.data.durationMs) >= num(b.data.durationMs) ? a : b,
493
+ );
494
+ findings.push({
495
+ id: "main-thread-stall",
496
+ severity: "error",
497
+ confidence: "observed",
498
+ title: `main thread blocked for ${num(worst.data.durationMs)}ms`,
499
+ detail: str(worst.data.top).split("\n")[0] || undefined,
500
+ count: stalls.length,
501
+ during: markAt(marks, worst.t),
502
+ evidence: {
503
+ at: worst.t,
504
+ stack: str(worst.data.stack).split("\n").slice(0, 6),
505
+ },
506
+ // `blocked` is reported when the stall ends, so `worst.t` is its end and
507
+ // the start is however long before that its own duration says.
508
+ window: { from: worst.t - num(worst.data.durationMs), to: worst.t },
509
+ });
510
+ }
511
+
512
+ // Completed only — see the same reasoning as `onMain` above: a status or an
513
+ // error is reported by the *end* event, so an open span cannot be a failure
514
+ // yet, only a hang (`http-still-open` already covers that). Filtering here
515
+ // also gives `first` a real `endedAt`/`startedAt` to place a window with.
516
+ const failed = http.filter(isCompleted).filter((c) => num(c.data.status) >= 400 || c.data.error !== undefined);
517
+ if (failed.length > 0) {
518
+ const first = failed[0];
519
+ findings.push({
520
+ id: "http-failed",
521
+ severity: "error",
522
+ confidence: "observed",
523
+ title: `${failed.length} HTTP ${failed.length === 1 ? "call" : "calls"} failed`,
524
+ detail: `${str(first.data.method)} ${str(first.data.url)} → ${str(first.data.status) || str(first.data.error)}`,
525
+ count: failed.length,
526
+ window: { from: first.startedAt ?? first.endedAt, to: first.endedAt },
527
+ });
528
+ }
529
+
530
+ // The hang lanes. A capture is most often run *because* something hung, so
531
+ // these are the findings least able to afford being absent.
532
+ const open = [
533
+ stillOpenFinding(
534
+ http,
535
+ "http-still-open",
536
+ { one: "HTTP call", many: "HTTP calls" },
537
+ (data) => ({
538
+ label: `${str(data.method)} ${str(data.url)}`.trim() || "unidentified call",
539
+ evidence: { method: str(data.method), url: str(data.url) },
540
+ }),
541
+ marks,
542
+ ),
543
+ stillOpenFinding(
544
+ db,
545
+ "db-still-open",
546
+ { one: "database query", many: "database queries" },
547
+ (data) => ({
548
+ label: str(data.sql).slice(0, 80) || "unidentified query",
549
+ evidence: { sql: str(data.sql) },
550
+ }),
551
+ marks,
552
+ ),
553
+ stillOpenFinding(
554
+ work,
555
+ "work-still-open",
556
+ { one: "background job", many: "background jobs" },
557
+ (data) => ({
558
+ label: str(data.name) || str(data.id) || "unidentified job",
559
+ evidence: { name: str(data.name), id: str(data.id) },
560
+ }),
561
+ marks,
562
+ ),
563
+ ].filter((finding): finding is Finding => finding !== undefined);
564
+ findings.push(...open);
565
+
566
+ const frames = events.filter((e) => e.event === "frame");
567
+ if (frames.length > 0) {
568
+ const missed = frames.reduce((sum, e) => sum + num(e.data.missedFrames, 1), 0);
569
+ const worst = frames.reduce((a, b) => (num(a.data.totalMs) >= num(b.data.totalMs) ? a : b));
570
+ const phases = new Map<string, number>();
571
+ for (const frame of frames) {
572
+ const phase = str(frame.data.worstPhase);
573
+ if (phase) phases.set(phase, (phases.get(phase) ?? 0) + 1);
574
+ }
575
+ const commonest = [...phases.entries()].sort((a, b) => b[1] - a[1])[0];
576
+ findings.push({
577
+ id: "frames-dropped",
578
+ severity: "warning",
579
+ confidence: assumed ? "correlated" : "observed",
580
+ title: `${missed} frames missed their deadline (budget ${describeBudget({ refreshHz, assumed })})`,
581
+ detail: commonest
582
+ ? `worst ${num(worst.data.totalMs)}ms · most often in ${commonest[0]}`
583
+ : undefined,
584
+ count: missed,
585
+ during: markAt(marks, worst.t),
586
+ // A `frame` event is posted when the frame finishes, so `worst.t` is its
587
+ // end and its own totalMs backdates the start — the same reasoning as
588
+ // `main-thread-stall`.
589
+ window: { from: worst.t - num(worst.data.totalMs), to: worst.t },
590
+ });
591
+ }
592
+
593
+ const gc = events.filter((e) => e.event === "gc" && num(e.data.blocking) > 0);
594
+ if (gc.length > 0) {
595
+ const paused = gc.reduce((sum, e) => sum + num(e.data.pausedMs), 0);
596
+ findings.push({
597
+ id: "blocking-gc",
598
+ severity: "warning",
599
+ confidence: "observed",
600
+ title: `${gc.length} blocking collections paused the app for ${paused}ms`,
601
+ count: gc.length,
602
+ // Not `spanning`: unlike a trace-side aggregate that sums duration with
603
+ // no timestamp of its own, every contributing collection here is a real
604
+ // event with a real `t`, so a window from the earliest to the latest is
605
+ // exactly where the count came from, not an invented range.
606
+ window: eventWindow(gc),
607
+ });
608
+ }
609
+
610
+ const trims = events.filter((e) => e.event === "device" && str(e.data.kind) === "trimMemory");
611
+ if (trims.length > 0) {
612
+ findings.push({
613
+ id: "trim-memory",
614
+ severity: "warning",
615
+ confidence: "observed",
616
+ title: `the system asked for memory back ${trims.length} ${trims.length === 1 ? "time" : "times"}`,
617
+ detail: `worst level: ${str(trims[trims.length - 1].data.level)}`,
618
+ count: trims.length,
619
+ window: eventWindow(trims),
620
+ });
621
+ }
622
+
623
+ const retried = work.filter((w) => w.data.retrying === "true");
624
+ if (retried.length > 0) {
625
+ findings.push({
626
+ id: "work-retried",
627
+ severity: "warning",
628
+ confidence: "observed",
629
+ title: `${retried.length} background ${retried.length === 1 ? "job" : "jobs"} retried`,
630
+ count: retried.length,
631
+ window: spanWindow(retried),
632
+ });
633
+ }
634
+
635
+ // The only correlated one, and a note for that reason.
636
+ const recompose = events.filter((e) => e.event === "recompose");
637
+ if (recompose.length > 0) {
638
+ const byName = new Map<string, number>();
639
+ const triggers = new Map<string, number>();
640
+ for (const event of recompose) {
641
+ const name = str(event.data.name);
642
+ if (name) byName.set(name, (byName.get(name) ?? 0) + 1);
643
+ for (const key of (event.data.triggeredBy as string[] | undefined) ?? []) {
644
+ triggers.set(key, (triggers.get(key) ?? 0) + 1);
645
+ }
646
+ }
647
+ const hottest = [...byName.entries()].sort((a, b) => b[1] - a[1])[0];
648
+ const trigger = [...triggers.entries()].sort((a, b) => b[1] - a[1])[0];
649
+ if (hottest && hottest[1] >= 100) {
650
+ findings.push({
651
+ id: "recompose-hotspot",
652
+ severity: "note",
653
+ confidence: "correlated",
654
+ title: `${hottest[0]} recomposed ${hottest[1]} times`,
655
+ detail: trigger
656
+ ? `most often within a frame of ${trigger[0]} — ordering, not proof`
657
+ : undefined,
658
+ count: hottest[1],
659
+ // Scoped to the hottest component's own recompositions, not every
660
+ // recompose in the run, so the window is as tight as the count it
661
+ // labels rather than as wide as the whole capture.
662
+ window: eventWindow(recompose.filter((e) => str(e.data.name) === hottest[0])),
663
+ });
664
+ }
665
+ }
666
+
667
+ // exit-findings (GRA-58): a death the device reported, at the severity the
668
+ // ticket named explicitly — ANR, crash, native crash, low memory and
669
+ // excessive resource usage are `error` (each means the system killed the
670
+ // app, for a reason worth an agent's attention); a user-requested exit is
671
+ // a `note` (informational: the app is not running, but nothing is wrong).
672
+ // Everything else — REASON_OTHER, a signal, a background kill — produces
673
+ // no finding at all: those are the normal shape of an app's process being
674
+ // recycled, not evidence of anything.
675
+ const exits = events.filter((e) => e.event === "exit");
676
+ for (const exit of exits) {
677
+ const reason = str(exit.data.reason);
678
+ const severity: Severity | undefined = EXIT_ERROR_REASONS.has(reason)
679
+ ? "error"
680
+ : reason === "REASON_USER_REQUESTED"
681
+ ? "note"
682
+ : undefined;
683
+ if (!severity) continue;
684
+
685
+ const versionName = exit.data.versionName != null ? str(exit.data.versionName) : null;
686
+ const versionAssumed = exit.data.versionAssumed === true;
687
+ const build = versionName ? `${versionName}${versionAssumed ? " (assumed)" : ""}` : "an unknown build";
688
+ const topFrame = str(exit.data.mainStack).split("\n")[0] || undefined;
689
+
690
+ // GRA-113: `spanning`, not `window`. `exit.t` is a timestamp in *this*
691
+ // process's uptime clock — the one reporting the death, at its next
692
+ // check-in — not the dead process's, whose own uptime clock reset with
693
+ // it and is gone. Placing the finding at `exit.t` would draw the death as
694
+ // though it happened just now, in the wrong process's session.
695
+ const predates = "the death predates this process's uptime clock, so it cannot be placed on this session's timeline";
696
+ const description = str(exit.data.description) || undefined;
697
+
698
+ findings.push({
699
+ id: `exit-${num(exit.data.timestamp)}`,
700
+ severity,
701
+ confidence: "observed",
702
+ title: `${reason} — ${build}` + (topFrame ? ` — ${topFrame}` : ""),
703
+ detail: description ? `${description} (${predates})` : predates,
704
+ during: markAt(marks, exit.t),
705
+ evidence: {
706
+ reason,
707
+ versionName,
708
+ versionAssumed,
709
+ timestamp: num(exit.data.timestamp),
710
+ ...(topFrame ? { topFrame } : {}),
711
+ },
712
+ spanning: true,
713
+ });
714
+ }
715
+
716
+ const order: Record<Severity, number> = { error: 0, warning: 1, note: 2 };
717
+ return findings.sort((a, b) => order[a.severity] - order[b.severity]);
718
+ }
719
+
720
+ /** `error`-severity exit reasons (GRA-58#exit-findings) — see `findingsOf`'s own comment for the rest of the mapping. */
721
+ const EXIT_ERROR_REASONS = new Set([
722
+ "REASON_ANR",
723
+ "REASON_CRASH",
724
+ "REASON_CRASH_NATIVE",
725
+ "REASON_LOW_MEMORY",
726
+ "REASON_EXCESSIVE_RESOURCE_USAGE",
727
+ ]);
728
+
729
+ export function buildTrace(options: {
730
+ scenario: string;
731
+ driver?: string;
732
+ events: DeviceEvent[];
733
+ hello: Record<string, unknown> | null;
734
+ durationMs: number;
735
+ withEvents: boolean;
736
+ /** GRA-185: resolved once, by `resolveProfile` below, and handed in rather than re-derived here — see that function's own doc comment for why every caller must resolve it the same way. */
737
+ profile: ResolvedProfile;
738
+ }): Trace {
739
+ const { events, hello, profile } = options;
740
+
741
+ const device = profile.assumed
742
+ ? { model: str(hello?.device), sdkInt: num(hello?.sdkInt), refreshHz: profile.refreshHz }
743
+ : { ...profile.full, refreshHz: profile.refreshHz };
744
+
745
+ const marks = events
746
+ .filter((e) => e.event === "mark")
747
+ .map((e) => ({
748
+ at: e.t,
749
+ label: str(e.data.label),
750
+ detail: str(e.data.detail) || undefined,
751
+ }));
752
+
753
+ return {
754
+ porthole: TRACE_VERSION,
755
+ scenario: options.scenario,
756
+ capturedAt: new Date().toISOString(),
757
+ durationMs: Math.round(options.durationMs),
758
+ driver: options.driver,
759
+ app: {
760
+ packageName: str(hello?.packageName),
761
+ versionName: hello?.versionName ?? null,
762
+ },
763
+ device,
764
+ marks,
765
+ metrics: metricsOf(events),
766
+ findings: findingsOf(events, marks, profile.refreshHz, profile.assumed),
767
+ events: options.withEvents ? events : undefined,
768
+ };
769
+ }