@gravitylabsllc/porthole 0.1.0 → 0.2.1
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.
- package/README.md +123 -0
- package/dist/adb.js +430 -21
- package/dist/adb.js.map +1 -1
- package/dist/args.js +144 -0
- package/dist/args.js.map +1 -0
- package/dist/capture.js +139 -30
- package/dist/capture.js.map +1 -1
- package/dist/cli.js +221 -62
- package/dist/cli.js.map +1 -1
- package/dist/device.js +337 -4
- package/dist/device.js.map +1 -1
- package/dist/index.js +2030 -377
- package/dist/index.js.map +1 -1
- package/dist/moment.js +240 -0
- package/dist/moment.js.map +1 -0
- package/dist/perfetto.js +826 -0
- package/dist/perfetto.js.map +1 -0
- package/dist/report.js +68 -7
- package/dist/report.js.map +1 -1
- package/dist/save.js +252 -0
- package/dist/save.js.map +1 -0
- package/dist/sessions.js +704 -0
- package/dist/sessions.js.map +1 -0
- package/dist/system.js +169 -0
- package/dist/system.js.map +1 -0
- package/dist/systrace.js +198 -0
- package/dist/systrace.js.map +1 -0
- package/dist/timeline.js +731 -29
- package/dist/timeline.js.map +1 -1
- package/dist/trace.js +317 -27
- package/dist/trace.js.map +1 -1
- package/dist/watermark.js +220 -0
- package/dist/watermark.js.map +1 -0
- package/package.json +10 -4
- package/src/adb.ts +583 -0
- package/src/args.ts +177 -0
- package/src/capture.ts +292 -0
- package/src/cli.ts +367 -0
- package/src/device.ts +635 -0
- package/src/index.ts +2545 -0
- package/src/moment.ts +306 -0
- package/src/perfetto.ts +972 -0
- package/src/report.ts +285 -0
- package/src/save.ts +322 -0
- package/src/sessions.ts +894 -0
- package/src/system.ts +221 -0
- package/src/systrace.ts +258 -0
- package/src/timeline.ts +1036 -0
- package/src/trace.ts +769 -0
- package/src/watermark.ts +337 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -0
- package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index--1mlZuNZ.css +0 -1
- 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
|
+
}
|