@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.
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-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -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/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
+ }