@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.
- 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-BzqwnvoU.js +70 -0
- package/ui/dist/assets/index-DtnyBXCM.css +1 -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/moment.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Copyright 2026 Gravity Labs
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import type { DeviceEvent } from "./device.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the app was doing at one moment.
|
|
7
|
+
*
|
|
8
|
+
* This exists for a specific experience: staring at a slice in a Perfetto
|
|
9
|
+
* capture and trying to remember what you did at that exact time. Perfetto can
|
|
10
|
+
* say which threads ran and for how long. It cannot say that you had just
|
|
11
|
+
* navigated to the cart, that a checkout call was open, or that the query on
|
|
12
|
+
* the main thread was the one behind the stall — and those are the things a
|
|
13
|
+
* person actually needs in order to recognise the moment.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately a narrative rather than a dump. The tools that return everything
|
|
16
|
+
* in a window already exist; what was missing was an answer shaped like the
|
|
17
|
+
* question, which is "where was I and what was happening".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const str = (value: unknown, fallback = ""): string =>
|
|
21
|
+
typeof value === "string" ? value : value == null ? fallback : String(value);
|
|
22
|
+
|
|
23
|
+
const num = (value: unknown, fallback = 0): number => {
|
|
24
|
+
if (typeof value === "number") return value;
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface OpenSpan {
|
|
30
|
+
kind: "http" | "db" | "work";
|
|
31
|
+
label: string;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
/** Elapsed at the moment asked about, not the span's full duration. */
|
|
34
|
+
openForMs: number;
|
|
35
|
+
endedAt: number | null;
|
|
36
|
+
data: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Moment {
|
|
40
|
+
at: number;
|
|
41
|
+
window: { from: number; to: number };
|
|
42
|
+
/** How `at` was arrived at, when it came from another clock. */
|
|
43
|
+
clock: { bootMs: number; sleepMs: number; sampledAt: number } | null;
|
|
44
|
+
screen: { route: string; args: string; enteredAt: number; agoMs: number } | null;
|
|
45
|
+
inFlight: OpenSpan[];
|
|
46
|
+
stateWrites: Array<{ key: string; at: number }>;
|
|
47
|
+
recompositions: number;
|
|
48
|
+
stalls: Array<{ durationMs: number; top: string; at: number }>;
|
|
49
|
+
frames: { missed: number; worstMs: number };
|
|
50
|
+
logs: Array<{ level: string; tag: string; message: string; at: number }>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A CLOCK_BOOTTIME reading — what a Perfetto trace stamps with — in the clock
|
|
55
|
+
* every Porthole event carries.
|
|
56
|
+
*
|
|
57
|
+
* Uses the most recent `clocks` sample at or before the moment asked about,
|
|
58
|
+
* because the gap between the two clocks is accumulated deep sleep and grows
|
|
59
|
+
* whenever the device dozes. Taking the newest sample instead would apply a
|
|
60
|
+
* later device's sleep total to an earlier moment.
|
|
61
|
+
*/
|
|
62
|
+
export function fromBootMs(
|
|
63
|
+
events: DeviceEvent[],
|
|
64
|
+
bootMs: number,
|
|
65
|
+
): { at: number; sleepMs: number; sampledAt: number } | null {
|
|
66
|
+
const samples = events.filter((e) => e.event === "clocks");
|
|
67
|
+
if (samples.length === 0) return null;
|
|
68
|
+
|
|
69
|
+
// Pick by boot time, since that is the axis the caller is speaking in.
|
|
70
|
+
let chosen = samples[0];
|
|
71
|
+
for (const sample of samples) {
|
|
72
|
+
if (num(sample.data.bootMs) <= bootMs) chosen = sample;
|
|
73
|
+
}
|
|
74
|
+
const sleepMs = num(chosen.data.sleepMs);
|
|
75
|
+
return { at: bootMs - sleepMs, sleepMs, sampledAt: chosen.t };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The reverse of fromBootMs: a Porthole uptime-ms moment, converted to the
|
|
80
|
+
* CLOCK_BOOTTIME ns a trace stamps with — what a caller scoping a
|
|
81
|
+
* trace_processor query to a window named in Porthole's own clock needs.
|
|
82
|
+
* `timeline.ts`'s `/api/findings?trace=` and `index.ts`'s `ask_system_trace`
|
|
83
|
+
* both do exactly this, and both used to open-code it separately, each
|
|
84
|
+
* picking whichever `clocks` sample the search happened to find first
|
|
85
|
+
* rather than the one in force at `atMs` — the same bug `fromBootMs` was
|
|
86
|
+
* written to avoid on the other leg of the trip. Mirroring `fromBootMs`'s
|
|
87
|
+
* own selection (the most recent sample at or before the moment, by
|
|
88
|
+
* Porthole's own clock this time: `sample.t`, not a boot-time field) is what
|
|
89
|
+
* fixes it, and living here rather than at either call site is what keeps it
|
|
90
|
+
* fixed: every place that needs the offset between the two clocks reads it
|
|
91
|
+
* the same way, once.
|
|
92
|
+
*
|
|
93
|
+
* Never refuses. A caller scoping a query needs *a* bound to hand
|
|
94
|
+
* trace_processor even before the run has sampled the offset at all, and
|
|
95
|
+
* assuming no accumulated sleep — the same default both open-coded versions
|
|
96
|
+
* used — is the conservative placeholder: it is wrong only by however long
|
|
97
|
+
* the device has actually slept, and only until a real sample arrives.
|
|
98
|
+
*
|
|
99
|
+
* Returns the offset alongside the answer, not just the ns: `ask_system_trace`
|
|
100
|
+
* reports `sleepMs` back to whoever asked, the same way `fromBootMs`'s own
|
|
101
|
+
* `{at, sleepMs, sampledAt}` already does for the reverse trip — and doing
|
|
102
|
+
* that by re-deriving it at the call site is exactly the duplication this
|
|
103
|
+
* function exists to close off. `toBootNs` below is this, minus the
|
|
104
|
+
* bookkeeping, for the caller (`timeline.ts`) that only ever wants the number.
|
|
105
|
+
*/
|
|
106
|
+
export function toBoot(
|
|
107
|
+
events: DeviceEvent[],
|
|
108
|
+
atMs: number,
|
|
109
|
+
): { ns: number; sleepMs: number; sampledAt: number | null } {
|
|
110
|
+
const samples = events.filter((e) => e.event === "clocks" && e.t <= atMs);
|
|
111
|
+
const chosen = samples.length ? samples[samples.length - 1] : undefined;
|
|
112
|
+
const sleepMs = chosen ? num(chosen.data.sleepMs) : 0;
|
|
113
|
+
return { ns: (atMs + sleepMs) * 1e6, sleepMs, sampledAt: chosen ? chosen.t : null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `toBoot(events, atMs).ns` — see `toBoot` for the full story. */
|
|
117
|
+
export function toBootNs(events: DeviceEvent[], atMs: number): number {
|
|
118
|
+
return toBoot(events, atMs).ns;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Coverage's variant of fromBootMs: `GET /api/traces` has no live device
|
|
123
|
+
* session, so there is no Porthole-recorded `clocks` sample to read an offset
|
|
124
|
+
* from. A trace's own `clock_snapshot` carries the same information anyway —
|
|
125
|
+
* CLOCK_BOOTTIME (Perfetto's clock_id 6) and CLOCK_MONOTONIC (clock_id 3, the
|
|
126
|
+
* same clock `SystemClock.uptimeMillis()` reads) sampled at the same instant
|
|
127
|
+
* — so the offset can be read directly out of the trace instead of out of a
|
|
128
|
+
* session that may not exist. This is that same {bootMs, sleepMs} pair
|
|
129
|
+
* `fromBootMs` already knows how to apply, computed from a different source;
|
|
130
|
+
* it stays in this file rather than at its call site for the same reason
|
|
131
|
+
* `toBootNs` does.
|
|
132
|
+
*
|
|
133
|
+
* One snapshot is enough for the same reason `fromBootMs` only needs the
|
|
134
|
+
* nearest one: the offset changes only across a stretch of deep sleep, and an
|
|
135
|
+
* 11-second capture window is far too short for that to move it (verified on
|
|
136
|
+
* hardware: 183ns of drift across nine minutes).
|
|
137
|
+
*/
|
|
138
|
+
export function fromTraceClockSnapshot(
|
|
139
|
+
snapshot: { bootNs: number; monotonicNs: number },
|
|
140
|
+
atNs: number,
|
|
141
|
+
): number {
|
|
142
|
+
const sleepNs = snapshot.bootNs - snapshot.monotonicNs;
|
|
143
|
+
return Math.round((atNs - sleepNs) / 1e6);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Spans open across `at`, plus those that closed inside the window. */
|
|
147
|
+
function spansAcross(
|
|
148
|
+
events: DeviceEvent[],
|
|
149
|
+
prefix: "http" | "db" | "work",
|
|
150
|
+
at: number,
|
|
151
|
+
from: number,
|
|
152
|
+
to: number,
|
|
153
|
+
): OpenSpan[] {
|
|
154
|
+
const open = new Map<string, DeviceEvent>();
|
|
155
|
+
const out: OpenSpan[] = [];
|
|
156
|
+
|
|
157
|
+
for (const event of events) {
|
|
158
|
+
const id = str(event.data.id);
|
|
159
|
+
if (event.event === `${prefix}_start`) {
|
|
160
|
+
open.set(id, event);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (event.event !== `${prefix}_end`) continue;
|
|
164
|
+
|
|
165
|
+
const start = open.get(id);
|
|
166
|
+
open.delete(id);
|
|
167
|
+
if (!start) continue;
|
|
168
|
+
|
|
169
|
+
// Open across the moment, or finished within the window either side of it.
|
|
170
|
+
const straddles = start.t <= at && event.t >= at;
|
|
171
|
+
const nearby = event.t >= from && event.t <= to;
|
|
172
|
+
if (!straddles && !nearby) continue;
|
|
173
|
+
|
|
174
|
+
out.push({
|
|
175
|
+
kind: prefix,
|
|
176
|
+
label: labelOf(prefix, start.data, event.data),
|
|
177
|
+
startedAt: start.t,
|
|
178
|
+
openForMs: Math.max(0, Math.min(at, event.t) - start.t),
|
|
179
|
+
endedAt: event.t,
|
|
180
|
+
data: { ...start.data, ...event.data },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Anything still open never got an end event, which is itself the finding:
|
|
185
|
+
// a call that was in flight and stayed that way.
|
|
186
|
+
for (const start of open.values()) {
|
|
187
|
+
if (start.t > at) continue;
|
|
188
|
+
out.push({
|
|
189
|
+
kind: prefix,
|
|
190
|
+
label: labelOf(prefix, start.data, {}),
|
|
191
|
+
startedAt: start.t,
|
|
192
|
+
openForMs: at - start.t,
|
|
193
|
+
endedAt: null,
|
|
194
|
+
data: start.data,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return out.sort((a, b) => b.openForMs - a.openForMs);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function labelOf(
|
|
202
|
+
prefix: string,
|
|
203
|
+
start: Record<string, unknown>,
|
|
204
|
+
end: Record<string, unknown>,
|
|
205
|
+
): string {
|
|
206
|
+
if (prefix === "http") {
|
|
207
|
+
const status = end.status !== undefined ? ` → ${str(end.status)}` : "";
|
|
208
|
+
return `${str(start.method)} ${str(start.url)}${status}`.trim();
|
|
209
|
+
}
|
|
210
|
+
if (prefix === "db") {
|
|
211
|
+
const main = start.onMainThread === "true" || start.onMainThread === true ? " (main thread)" : "";
|
|
212
|
+
return `${str(start.sql)}${main}`;
|
|
213
|
+
}
|
|
214
|
+
return str(start.name) || str(start.id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param at the moment, in Porthole's clock
|
|
219
|
+
* @param spreadMs how far either side to look for context. Small on purpose:
|
|
220
|
+
* the question is "what was happening here", and widening it turns the answer
|
|
221
|
+
* back into the dump the other tools already provide.
|
|
222
|
+
*/
|
|
223
|
+
export function momentOf(events: DeviceEvent[], at: number, spreadMs = 2_000): Moment {
|
|
224
|
+
const from = at - spreadMs;
|
|
225
|
+
const to = at + spreadMs;
|
|
226
|
+
const within = (e: DeviceEvent) => e.t >= from && e.t <= to;
|
|
227
|
+
|
|
228
|
+
// The screen is the last navigation at or before the moment — not one within
|
|
229
|
+
// the window, since you can sit on a screen far longer than the spread.
|
|
230
|
+
const navs = events.filter((e) => e.event === "nav" && e.t <= at);
|
|
231
|
+
const lastNav = navs.length ? navs[navs.length - 1] : null;
|
|
232
|
+
|
|
233
|
+
const frames = events.filter((e) => e.event === "frame" && within(e));
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
at,
|
|
237
|
+
window: { from, to },
|
|
238
|
+
clock: null,
|
|
239
|
+
screen: lastNav
|
|
240
|
+
? {
|
|
241
|
+
route: str(lastNav.data.route),
|
|
242
|
+
args: str(lastNav.data.args),
|
|
243
|
+
enteredAt: lastNav.t,
|
|
244
|
+
agoMs: at - lastNav.t,
|
|
245
|
+
}
|
|
246
|
+
: null,
|
|
247
|
+
inFlight: [
|
|
248
|
+
...spansAcross(events, "http", at, from, to),
|
|
249
|
+
...spansAcross(events, "db", at, from, to),
|
|
250
|
+
...spansAcross(events, "work", at, from, to),
|
|
251
|
+
],
|
|
252
|
+
stateWrites: events
|
|
253
|
+
.filter((e) => e.event === "state_write" && e.t <= at && e.t >= at - spreadMs)
|
|
254
|
+
.map((e) => ({ key: str(e.data.key), at: e.t })),
|
|
255
|
+
recompositions: events.filter((e) => e.event === "recompose" && within(e)).length,
|
|
256
|
+
stalls: events
|
|
257
|
+
.filter((e) => e.event === "blocked" && within(e))
|
|
258
|
+
.map((e) => ({ durationMs: num(e.data.durationMs), top: str(e.data.top), at: e.t })),
|
|
259
|
+
frames: {
|
|
260
|
+
missed: frames.reduce((sum, e) => sum + num(e.data.missedFrames), 0),
|
|
261
|
+
worstMs: frames.reduce((worst, e) => Math.max(worst, num(e.data.totalMs)), 0),
|
|
262
|
+
},
|
|
263
|
+
logs: events
|
|
264
|
+
.filter((e) => e.event === "log" && within(e))
|
|
265
|
+
.map((e) => ({
|
|
266
|
+
level: str(e.data.level),
|
|
267
|
+
tag: str(e.data.tag),
|
|
268
|
+
message: str(e.data.message).slice(0, 300),
|
|
269
|
+
at: e.t,
|
|
270
|
+
})),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One sentence, because the summary is usually the whole answer. */
|
|
275
|
+
export function describe(moment: Moment): string {
|
|
276
|
+
const parts: string[] = [];
|
|
277
|
+
|
|
278
|
+
parts.push(
|
|
279
|
+
moment.screen
|
|
280
|
+
? `On ${moment.screen.route}${moment.screen.args ? ` ${moment.screen.args}` : ""}` +
|
|
281
|
+
` (entered ${Math.round(moment.screen.agoMs / 100) / 10}s earlier).`
|
|
282
|
+
: "No navigation recorded before this moment.",
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (moment.inFlight.length) {
|
|
286
|
+
const worst = moment.inFlight[0];
|
|
287
|
+
parts.push(
|
|
288
|
+
`${moment.inFlight.length} in flight, longest ${worst.label} ` +
|
|
289
|
+
`open ${worst.openForMs}ms${worst.endedAt === null ? " and never finished" : ""}.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (moment.stalls.length) {
|
|
293
|
+
const worst = moment.stalls.reduce((a, b) => (b.durationMs > a.durationMs ? b : a));
|
|
294
|
+
parts.push(`Main thread blocked ${worst.durationMs}ms in ${worst.top}.`);
|
|
295
|
+
}
|
|
296
|
+
if (moment.frames.missed) {
|
|
297
|
+
parts.push(`${moment.frames.missed} refreshes missed, worst frame ${moment.frames.worstMs}ms.`);
|
|
298
|
+
}
|
|
299
|
+
if (moment.recompositions) parts.push(`${moment.recompositions} recompositions.`);
|
|
300
|
+
if (moment.stateWrites.length) {
|
|
301
|
+
const keys = [...new Set(moment.stateWrites.map((w) => w.key))].slice(0, 3);
|
|
302
|
+
parts.push(`State written just before: ${keys.join(", ")}.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return parts.join(" ");
|
|
306
|
+
}
|