@vincentt-xr/harness 0.1.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 +87 -0
- package/dist/client/HarnessProvider.d.ts +21 -0
- package/dist/client/HarnessProvider.js +64 -0
- package/dist/client/buffer.d.ts +28 -0
- package/dist/client/buffer.js +66 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +5 -0
- package/dist/client/instrument.d.ts +13 -0
- package/dist/client/instrument.js +160 -0
- package/dist/client/sampler.d.ts +11 -0
- package/dist/client/sampler.js +72 -0
- package/dist/client/serialize.d.ts +32 -0
- package/dist/client/serialize.js +99 -0
- package/dist/client/trace.d.ts +31 -0
- package/dist/client/trace.js +87 -0
- package/dist/mcp/backend.d.ts +52 -0
- package/dist/mcp/backend.js +146 -0
- package/dist/mcp/cli.d.ts +2 -0
- package/dist/mcp/cli.js +10 -0
- package/dist/mcp/diagnostics.d.ts +13 -0
- package/dist/mcp/diagnostics.js +61 -0
- package/dist/mcp/server.d.ts +14 -0
- package/dist/mcp/server.js +140 -0
- package/dist/preview/cloudflared.d.ts +13 -0
- package/dist/preview/cloudflared.js +37 -0
- package/dist/preview/index.d.ts +15 -0
- package/dist/preview/index.js +30 -0
- package/dist/relay/cli.d.ts +2 -0
- package/dist/relay/cli.js +7 -0
- package/dist/relay/server.d.ts +12 -0
- package/dist/relay/server.js +85 -0
- package/dist/relay/store.d.ts +13 -0
- package/dist/relay/store.js +68 -0
- package/dist/scaffold/index.d.ts +19 -0
- package/dist/scaffold/index.js +72 -0
- package/dist/shared/config.d.ts +33 -0
- package/dist/shared/config.js +76 -0
- package/dist/shared/events.d.ts +78 -0
- package/dist/shared/events.js +6 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @vincentt-xr/harness
|
|
2
|
+
|
|
3
|
+
The Vincentt AR dev-loop harness. It is **one always-on diagnostics channel**
|
|
4
|
+
between the AR app running on a phone and the coding agent running on a laptop,
|
|
5
|
+
so the agent can see what the phone sees — console logs, network requests,
|
|
6
|
+
performance — without the developer hand-ferrying DevTools traces or pasting
|
|
7
|
+
console output.
|
|
8
|
+
|
|
9
|
+
Agent-agnostic: the agent side is a plain **MCP server**, so any MCP-capable
|
|
10
|
+
agent (Claude Code, Codex, Cursor, Cline, …) uses it the same way.
|
|
11
|
+
|
|
12
|
+
## The three parts
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
PHONE (preview app) RELAY (laptop) AGENT (laptop)
|
|
16
|
+
┌──────────────────┐ ┌────────────────┐ ┌────────────────────┐
|
|
17
|
+
│ HarnessProvider │ │ harness-relay │ │ harness-mcp │
|
|
18
|
+
│ • patch console │─ws→│ ring buffer │←──→│ diag_logs │
|
|
19
|
+
│ • wrap fetch/XHR │ │ per session │http│ diag_network │
|
|
20
|
+
│ • sample perf │ │ │ │ diag_trace │
|
|
21
|
+
└──────────────────┘ └────────────────┘ └────────────────────┘
|
|
22
|
+
src/client (npm pkg) src/relay (bin) src/mcp (bin)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **`src/client`** — the in-app half; the published npm package. `HarnessProvider`
|
|
26
|
+
patches `console.*`, wraps `fetch`/`XHR`, and samples `performance`, shipping
|
|
27
|
+
events to the relay over a WebSocket. Auto-on in a dev build, no-op (and
|
|
28
|
+
tree-shaken) in production.
|
|
29
|
+
- **`src/relay`** — a small WebSocket + HTTP server beside Vite. Holds a bounded
|
|
30
|
+
ring buffer of recent events per session. The phone pushes; the agent pulls.
|
|
31
|
+
- **`src/mcp`** — the agent-agnostic MCP server. Exposes `diag_logs`,
|
|
32
|
+
`diag_network`, `diag_trace` that pull from the relay.
|
|
33
|
+
|
|
34
|
+
- **`src/shared/events.ts`** — the wire contract all three import, so they can't
|
|
35
|
+
drift on the event shape.
|
|
36
|
+
|
|
37
|
+
## Use it
|
|
38
|
+
|
|
39
|
+
**In the app (once):**
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
import { HarnessProvider } from "@vincentt-xr/harness";
|
|
43
|
+
|
|
44
|
+
<HarnessProvider>
|
|
45
|
+
<App />
|
|
46
|
+
</HarnessProvider>;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That's the whole author-facing surface. It self-activates in dev and disappears
|
|
50
|
+
in the production build.
|
|
51
|
+
|
|
52
|
+
**Run the relay** beside your dev server (the preview tunnel carries its socket
|
|
53
|
+
to the phone):
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
npx harness-relay # listens on :7331, ws path /__harness
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Point your agent at the MCP server.** In the agent's MCP config:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"vincentt-harness": { "command": "npx", "args": ["harness-mcp"] }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then, while the app is open on the phone, the agent calls `diag_logs`,
|
|
70
|
+
`diag_network`, `diag_trace` to read what the device is doing — no DevTools, no
|
|
71
|
+
copy-paste.
|
|
72
|
+
|
|
73
|
+
## Develop
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pnpm install
|
|
77
|
+
pnpm test # unit tests (pure event-shaping, buffer, ring store, formatting, fps)
|
|
78
|
+
pnpm typecheck
|
|
79
|
+
pnpm build # emits dist/ (client = npm surface; relay/mcp = bins)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Status
|
|
83
|
+
|
|
84
|
+
Local-first. The diagnostics limb is built and proven end-to-end
|
|
85
|
+
(phone → relay → agent). The platform lifecycle (per-project server-minted
|
|
86
|
+
tunnels, `createProject`/`publish`, auth) is a separate later phase; the same
|
|
87
|
+
client + relay also feed the production client-review service.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface HarnessProviderProps {
|
|
3
|
+
children: ReactNode;
|
|
4
|
+
/**
|
|
5
|
+
* Force diagnostics on/off. Defaults to `import.meta.env.DEV` — on in the dev
|
|
6
|
+
* server + preview build, off in the production bundle.
|
|
7
|
+
*/
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* WebSocket URL of the relay. Defaults to the same host the app is served
|
|
11
|
+
* from on port 7331 (what `harness-relay` listens on, tunneled alongside the
|
|
12
|
+
* app). Override for the production review-service sink.
|
|
13
|
+
*/
|
|
14
|
+
relayUrl?: string;
|
|
15
|
+
/** Session id grouping this preview run's events. Defaults to a per-load id. */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
captureConsole?: boolean;
|
|
18
|
+
captureNetwork?: boolean;
|
|
19
|
+
captureTrace?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function HarnessProvider(props: HarnessProviderProps): ReactNode;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The one thing an app author touches. Wrap the app once:
|
|
2
|
+
//
|
|
3
|
+
// import { HarnessProvider } from "@vincentt-xr/harness";
|
|
4
|
+
// <HarnessProvider><App /></HarnessProvider>
|
|
5
|
+
//
|
|
6
|
+
// It self-activates in a dev build and is a no-op (and tree-shakes to nothing)
|
|
7
|
+
// in production, so there is no way to ship diagnostics to real users by
|
|
8
|
+
// accident. The relay URL and session id default to what the preview loop sets,
|
|
9
|
+
// but both are overridable for the production review-service path later.
|
|
10
|
+
import { useEffect } from "react";
|
|
11
|
+
function defaultRelayUrl() {
|
|
12
|
+
if (typeof window === "undefined")
|
|
13
|
+
return "ws://localhost:7331";
|
|
14
|
+
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
15
|
+
// Default: the relay rides the same tunnel host as the app on a fixed path,
|
|
16
|
+
// so one cloudflared tunnel carries both. The relay CLI serves this path.
|
|
17
|
+
return `${proto}://${window.location.host}/__harness`;
|
|
18
|
+
}
|
|
19
|
+
function defaultSessionId() {
|
|
20
|
+
if (typeof window === "undefined")
|
|
21
|
+
return "server";
|
|
22
|
+
const key = "__vincentt_harness_session";
|
|
23
|
+
const existing = window.sessionStorage?.getItem(key);
|
|
24
|
+
if (existing)
|
|
25
|
+
return existing;
|
|
26
|
+
// Per-load id; not security-sensitive, just needs to be distinct enough that
|
|
27
|
+
// two phones on one relay don't collide. No Math.random dependency in the
|
|
28
|
+
// pure layer — this imperative shell may use it.
|
|
29
|
+
const id = `s_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
30
|
+
window.sessionStorage?.setItem(key, id);
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
const DEV = typeof import.meta !== "undefined" &&
|
|
34
|
+
import.meta.env?.DEV;
|
|
35
|
+
export function HarnessProvider(props) {
|
|
36
|
+
const enabled = props.enabled ?? Boolean(DEV);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!enabled)
|
|
39
|
+
return undefined;
|
|
40
|
+
let teardown;
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
// Dynamic import: the instrumentation module (and its whole dependency
|
|
43
|
+
// chain) is only fetched when enabled, so prod builds never include it.
|
|
44
|
+
void import("./instrument.js").then(({ installInstrumentation }) => {
|
|
45
|
+
if (cancelled)
|
|
46
|
+
return;
|
|
47
|
+
teardown = installInstrumentation({
|
|
48
|
+
sessionId: props.sessionId ?? defaultSessionId(),
|
|
49
|
+
relayUrl: props.relayUrl ?? defaultRelayUrl(),
|
|
50
|
+
captureConsole: props.captureConsole,
|
|
51
|
+
captureNetwork: props.captureNetwork,
|
|
52
|
+
captureTrace: props.captureTrace,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
teardown?.();
|
|
58
|
+
};
|
|
59
|
+
// Instrumentation is installed once for the provider's lifetime; option
|
|
60
|
+
// changes mid-session are not a supported case.
|
|
61
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
|
+
}, [enabled]);
|
|
63
|
+
return props.children;
|
|
64
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ClientToRelay, DiagEvent } from "../shared/events.js";
|
|
2
|
+
export interface BufferOptions {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
/** Coalesce window: events within this many ms flush together. */
|
|
5
|
+
flushIntervalMs?: number;
|
|
6
|
+
/** Hard cap on pending events; oldest dropped past this (backpressure). */
|
|
7
|
+
maxPending?: number;
|
|
8
|
+
/** Injected transport. Returns true if the batch was accepted (socket open). */
|
|
9
|
+
send: (batch: ClientToRelay) => boolean;
|
|
10
|
+
/** Injected timer so tests are deterministic; defaults to setTimeout. */
|
|
11
|
+
schedule?: (fn: () => void, ms: number) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare class EventBuffer {
|
|
14
|
+
private seq;
|
|
15
|
+
private pending;
|
|
16
|
+
private flushArmed;
|
|
17
|
+
private readonly opts;
|
|
18
|
+
constructor(options: BufferOptions);
|
|
19
|
+
/** Next sequence number without consuming it — for makeXEvent(seq, ...). */
|
|
20
|
+
nextSeq(): number;
|
|
21
|
+
/** Stamp and enqueue an event (seq already baked in via nextSeq). */
|
|
22
|
+
add(event: DiagEvent): void;
|
|
23
|
+
private armFlush;
|
|
24
|
+
/** Send everything pending; keep it buffered if the transport rejects it. */
|
|
25
|
+
flush(): void;
|
|
26
|
+
/** Test/introspection helper. */
|
|
27
|
+
pendingCount(): number;
|
|
28
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The client's outbound buffer: assigns each event a monotonic seq, holds them
|
|
2
|
+
// until a flush, and coalesces flushes so a burst of console.logs becomes one
|
|
3
|
+
// socket message. Pure and transport-agnostic — you inject a `send` function,
|
|
4
|
+
// and a `now`/`schedule` so tests control time. instrument.ts wires the real
|
|
5
|
+
// WebSocket + setTimeout in.
|
|
6
|
+
export class EventBuffer {
|
|
7
|
+
seq = 0;
|
|
8
|
+
pending = [];
|
|
9
|
+
flushArmed = false;
|
|
10
|
+
opts;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.opts = {
|
|
13
|
+
flushIntervalMs: 250,
|
|
14
|
+
maxPending: 500,
|
|
15
|
+
schedule: (fn, ms) => setTimeout(fn, ms),
|
|
16
|
+
...options,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Next sequence number without consuming it — for makeXEvent(seq, ...). */
|
|
20
|
+
nextSeq() {
|
|
21
|
+
return this.seq;
|
|
22
|
+
}
|
|
23
|
+
/** Stamp and enqueue an event (seq already baked in via nextSeq). */
|
|
24
|
+
add(event) {
|
|
25
|
+
// Trust the caller used nextSeq(); advance the counter to match.
|
|
26
|
+
this.seq = Math.max(this.seq, event.seq + 1);
|
|
27
|
+
this.pending.push(event);
|
|
28
|
+
// Drop oldest on overflow rather than grow unbounded on a runaway logger.
|
|
29
|
+
if (this.pending.length > this.opts.maxPending) {
|
|
30
|
+
this.pending.splice(0, this.pending.length - this.opts.maxPending);
|
|
31
|
+
}
|
|
32
|
+
this.armFlush();
|
|
33
|
+
}
|
|
34
|
+
armFlush() {
|
|
35
|
+
if (this.flushArmed)
|
|
36
|
+
return;
|
|
37
|
+
this.flushArmed = true;
|
|
38
|
+
this.opts.schedule(() => {
|
|
39
|
+
this.flushArmed = false;
|
|
40
|
+
this.flush();
|
|
41
|
+
}, this.opts.flushIntervalMs);
|
|
42
|
+
}
|
|
43
|
+
/** Send everything pending; keep it buffered if the transport rejects it. */
|
|
44
|
+
flush() {
|
|
45
|
+
if (this.pending.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
const batch = {
|
|
48
|
+
type: "events",
|
|
49
|
+
sessionId: this.opts.sessionId,
|
|
50
|
+
events: this.pending,
|
|
51
|
+
};
|
|
52
|
+
const accepted = this.opts.send(batch);
|
|
53
|
+
if (accepted) {
|
|
54
|
+
this.pending = [];
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Socket not open yet — keep the events, re-arm so we retry. Bounded by
|
|
58
|
+
// maxPending so a never-connecting socket can't leak memory.
|
|
59
|
+
this.armFlush();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Test/introspection helper. */
|
|
63
|
+
pendingCount() {
|
|
64
|
+
return this.pending.length;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The published surface of @vincentt-xr/harness — the in-app half. Everything
|
|
2
|
+
// here is imported by app code and ships in the app bundle (dev only; prod
|
|
3
|
+
// tree-shakes it). The relay and MCP server are NOT exported here; they are
|
|
4
|
+
// run-from-bin, not imported.
|
|
5
|
+
export { HarnessProvider } from "./HarnessProvider.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface InstrumentOptions {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
relayUrl: string;
|
|
4
|
+
captureConsole?: boolean;
|
|
5
|
+
captureNetwork?: boolean;
|
|
6
|
+
captureTrace?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Install the diagnostics instrumentation. Returns a teardown that fully
|
|
10
|
+
* restores the originals — important so dev double-mounts (React StrictMode)
|
|
11
|
+
* don't stack patches. Idempotent per window via a guard.
|
|
12
|
+
*/
|
|
13
|
+
export declare function installInstrumentation(opts: InstrumentOptions): () => void;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The imperative shell: patch console.* and wrap fetch/XHR so every log and
|
|
2
|
+
// request becomes a DiagEvent handed to the buffer. Kept deliberately thin —
|
|
3
|
+
// all the formatting/batching decisions live in serialize.ts + buffer.ts. This
|
|
4
|
+
// file only does the monkey-patching and owns the WebSocket.
|
|
5
|
+
import { EventBuffer } from "./buffer.js";
|
|
6
|
+
import { startSampler } from "./sampler.js";
|
|
7
|
+
import { describeRequest, makeLogEvent, makeNetworkEvent } from "./serialize.js";
|
|
8
|
+
const LEVELS = ["log", "info", "warn", "error", "debug"];
|
|
9
|
+
/**
|
|
10
|
+
* Install the diagnostics instrumentation. Returns a teardown that fully
|
|
11
|
+
* restores the originals — important so dev double-mounts (React StrictMode)
|
|
12
|
+
* don't stack patches. Idempotent per window via a guard.
|
|
13
|
+
*/
|
|
14
|
+
export function installInstrumentation(opts) {
|
|
15
|
+
const w = window;
|
|
16
|
+
if (w.__vincenttHarness)
|
|
17
|
+
return () => undefined;
|
|
18
|
+
w.__vincenttHarness = true;
|
|
19
|
+
const socket = openSocket(opts.relayUrl);
|
|
20
|
+
const buffer = new EventBuffer({
|
|
21
|
+
sessionId: opts.sessionId,
|
|
22
|
+
send: (batch) => {
|
|
23
|
+
if (socket.ready()) {
|
|
24
|
+
socket.send(JSON.stringify(batch));
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const restores = [];
|
|
31
|
+
if (opts.captureConsole !== false) {
|
|
32
|
+
restores.push(patchConsole(buffer));
|
|
33
|
+
}
|
|
34
|
+
if (opts.captureNetwork !== false) {
|
|
35
|
+
restores.push(patchFetch(buffer));
|
|
36
|
+
restores.push(patchXHR(buffer));
|
|
37
|
+
}
|
|
38
|
+
if (opts.captureTrace !== false) {
|
|
39
|
+
restores.push(startSampler(buffer));
|
|
40
|
+
}
|
|
41
|
+
return () => {
|
|
42
|
+
restores.forEach((r) => r());
|
|
43
|
+
socket.close();
|
|
44
|
+
w.__vincenttHarness = false;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A tiny reconnecting WebSocket wrapper. */
|
|
48
|
+
function openSocket(url) {
|
|
49
|
+
let ws = null;
|
|
50
|
+
let closed = false;
|
|
51
|
+
const connect = () => {
|
|
52
|
+
if (closed)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
ws = new WebSocket(url);
|
|
56
|
+
ws.onclose = () => {
|
|
57
|
+
ws = null;
|
|
58
|
+
if (!closed)
|
|
59
|
+
setTimeout(connect, 1000);
|
|
60
|
+
};
|
|
61
|
+
ws.onerror = () => ws?.close();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
if (!closed)
|
|
65
|
+
setTimeout(connect, 1000);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
connect();
|
|
69
|
+
return {
|
|
70
|
+
ready: () => ws?.readyState === WebSocket.OPEN,
|
|
71
|
+
send: (data) => ws?.send(data),
|
|
72
|
+
close: () => {
|
|
73
|
+
closed = true;
|
|
74
|
+
ws?.close();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function patchConsole(buffer) {
|
|
79
|
+
const original = {};
|
|
80
|
+
for (const level of LEVELS) {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
const orig = console[level];
|
|
83
|
+
original[level] = orig;
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console[level] = (...args) => {
|
|
86
|
+
try {
|
|
87
|
+
const seq = buffer.nextSeq();
|
|
88
|
+
buffer.add(makeLogEvent(level, args, seq, Date.now()));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Never let instrumentation break the app's own logging.
|
|
92
|
+
}
|
|
93
|
+
orig.apply(console, args);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return () => {
|
|
97
|
+
for (const level of LEVELS) {
|
|
98
|
+
if (original[level]) {
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console[level] = original[level];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function patchFetch(buffer) {
|
|
106
|
+
if (typeof window.fetch !== "function")
|
|
107
|
+
return () => undefined;
|
|
108
|
+
const orig = window.fetch.bind(window);
|
|
109
|
+
window.fetch = async (input, init) => {
|
|
110
|
+
const desc = describeRequest(input, init);
|
|
111
|
+
const start = Date.now();
|
|
112
|
+
try {
|
|
113
|
+
const res = await orig(input, init);
|
|
114
|
+
record(buffer, desc, res.status, Date.now() - start);
|
|
115
|
+
return res;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
record(buffer, desc, 0, Date.now() - start, err instanceof Error ? err.message : String(err));
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return () => {
|
|
123
|
+
window.fetch = orig;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function patchXHR(buffer) {
|
|
127
|
+
const OrigXHR = window.XMLHttpRequest;
|
|
128
|
+
if (typeof OrigXHR !== "function")
|
|
129
|
+
return () => undefined;
|
|
130
|
+
const openOrig = OrigXHR.prototype.open;
|
|
131
|
+
const sendOrig = OrigXHR.prototype.send;
|
|
132
|
+
OrigXHR.prototype.open = function (method, url) {
|
|
133
|
+
this.__h = describeRequest(typeof url === "string" ? url : url.toString(), { method });
|
|
134
|
+
// eslint-disable-next-line prefer-rest-params
|
|
135
|
+
return openOrig.apply(this, arguments);
|
|
136
|
+
};
|
|
137
|
+
OrigXHR.prototype.send = function (...rest) {
|
|
138
|
+
const meta = this.__h;
|
|
139
|
+
const start = Date.now();
|
|
140
|
+
if (meta) {
|
|
141
|
+
this.addEventListener("loadend", () => {
|
|
142
|
+
record(buffer, meta, this.status, Date.now() - start, this.status === 0 ? "network error" : undefined);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return sendOrig.apply(this, rest);
|
|
146
|
+
};
|
|
147
|
+
return () => {
|
|
148
|
+
OrigXHR.prototype.open = openOrig;
|
|
149
|
+
OrigXHR.prototype.send = sendOrig;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function record(buffer, desc, status, durationMs, error) {
|
|
153
|
+
try {
|
|
154
|
+
const seq = buffer.nextSeq();
|
|
155
|
+
buffer.add(makeNetworkEvent(desc, status, durationMs, seq, Date.now(), error));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// swallow — diagnostics must never break the request path
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { EventBuffer } from "./buffer.js";
|
|
2
|
+
export interface SamplerOptions {
|
|
3
|
+
/** How often to emit a TraceEvent, ms. */
|
|
4
|
+
windowMs?: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Start sampling. Returns a teardown. Degrades gracefully where APIs are
|
|
8
|
+
* missing (PerformanceObserver / longtask are not universal) — fps still works
|
|
9
|
+
* from rAF alone, which is the signal that mattered for the readback finding.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startSampler(buffer: EventBuffer, opts?: SamplerOptions): () => void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The imperative perf sampler: drive a TraceAccumulator from real browser
|
|
2
|
+
// signals (rAF for frames, PerformanceObserver for long tasks + marks) and
|
|
3
|
+
// flush one TraceEvent per window into the buffer. Thin shell — all the math is
|
|
4
|
+
// in trace.ts. instrument.ts installs this alongside the console/network
|
|
5
|
+
// patches when the trace tier is on.
|
|
6
|
+
import { TraceAccumulator } from "./trace.js";
|
|
7
|
+
/**
|
|
8
|
+
* Start sampling. Returns a teardown. Degrades gracefully where APIs are
|
|
9
|
+
* missing (PerformanceObserver / longtask are not universal) — fps still works
|
|
10
|
+
* from rAF alone, which is the signal that mattered for the readback finding.
|
|
11
|
+
*/
|
|
12
|
+
export function startSampler(buffer, opts = {}) {
|
|
13
|
+
if (typeof requestAnimationFrame !== "function")
|
|
14
|
+
return () => undefined;
|
|
15
|
+
const windowMs = opts.windowMs ?? 5000;
|
|
16
|
+
const acc = new TraceAccumulator();
|
|
17
|
+
let rafId = null;
|
|
18
|
+
let stopped = false;
|
|
19
|
+
const onFrame = (t) => {
|
|
20
|
+
if (stopped)
|
|
21
|
+
return;
|
|
22
|
+
acc.frame(t);
|
|
23
|
+
rafId = requestAnimationFrame(onFrame);
|
|
24
|
+
};
|
|
25
|
+
rafId = requestAnimationFrame(onFrame);
|
|
26
|
+
let observer = null;
|
|
27
|
+
if (typeof PerformanceObserver === "function") {
|
|
28
|
+
try {
|
|
29
|
+
observer = new PerformanceObserver((list) => {
|
|
30
|
+
for (const entry of list.getEntries()) {
|
|
31
|
+
if (entry.entryType === "longtask")
|
|
32
|
+
acc.task(entry.duration);
|
|
33
|
+
else if (entry.entryType === "mark")
|
|
34
|
+
acc.mark(entry.name);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
// longtask isn't supported everywhere (notably iOS Safari) — try each
|
|
38
|
+
// type independently so one unsupported type doesn't kill the others.
|
|
39
|
+
trySubscribe(observer, "longtask");
|
|
40
|
+
trySubscribe(observer, "mark");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
observer = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const timer = setInterval(() => {
|
|
47
|
+
if (!acc.hasSamples())
|
|
48
|
+
return;
|
|
49
|
+
const seq = buffer.nextSeq();
|
|
50
|
+
buffer.add(acc.summarize(performanceNow(), seq));
|
|
51
|
+
}, windowMs);
|
|
52
|
+
return () => {
|
|
53
|
+
stopped = true;
|
|
54
|
+
if (rafId !== null)
|
|
55
|
+
cancelAnimationFrame(rafId);
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
observer?.disconnect();
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function trySubscribe(observer, type) {
|
|
61
|
+
try {
|
|
62
|
+
observer.observe({ type, buffered: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// this entry type isn't supported here; the others still work
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function performanceNow() {
|
|
69
|
+
return typeof performance !== "undefined"
|
|
70
|
+
? performance.timeOrigin + performance.now()
|
|
71
|
+
: Date.now();
|
|
72
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { LogEvent, NetworkEvent } from "../shared/events.js";
|
|
2
|
+
/**
|
|
3
|
+
* Safely stringify one console argument. The phone's console is fed anything —
|
|
4
|
+
* Errors, DOM nodes, cyclic objects, huge arrays — and this has to survive all
|
|
5
|
+
* of them without throwing (a throwing logger would take the app down) and
|
|
6
|
+
* without shipping megabytes over the socket.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stringifyArg(arg: unknown, maxLen?: number): string;
|
|
9
|
+
/** Join console.* varargs into one message the way the devtools console would. */
|
|
10
|
+
export declare function formatLogArgs(args: unknown[], maxLen?: number): string;
|
|
11
|
+
/** Build a LogEvent from a console call. seq/t are supplied by the buffer. */
|
|
12
|
+
export declare function makeLogEvent(level: LogEvent["level"], args: unknown[], seq: number, t: number): LogEvent;
|
|
13
|
+
/** First stack frame that isn't runtime/library noise, best-effort. */
|
|
14
|
+
export declare function firstAppFrame(err?: Error): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Normalize the many shapes fetch() is called with (string, URL, Request) into
|
|
17
|
+
* a {method, url} pair without consuming the body.
|
|
18
|
+
*/
|
|
19
|
+
export declare function describeRequest(input: string | URL | {
|
|
20
|
+
url?: string;
|
|
21
|
+
method?: string;
|
|
22
|
+
}, init?: {
|
|
23
|
+
method?: string;
|
|
24
|
+
}): {
|
|
25
|
+
method: string;
|
|
26
|
+
url: string;
|
|
27
|
+
};
|
|
28
|
+
/** Build a NetworkEvent from a settled request. */
|
|
29
|
+
export declare function makeNetworkEvent(desc: {
|
|
30
|
+
method: string;
|
|
31
|
+
url: string;
|
|
32
|
+
}, status: number, durationMs: number, seq: number, t: number, error?: string): NetworkEvent;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Pure event-shaping for the in-app client. The imperative shell (patching
|
|
2
|
+
// console, wrapping fetch) lives in instrument.ts and calls into here, so all
|
|
3
|
+
// the fiddly formatting logic is testable without a browser.
|
|
4
|
+
/**
|
|
5
|
+
* Safely stringify one console argument. The phone's console is fed anything —
|
|
6
|
+
* Errors, DOM nodes, cyclic objects, huge arrays — and this has to survive all
|
|
7
|
+
* of them without throwing (a throwing logger would take the app down) and
|
|
8
|
+
* without shipping megabytes over the socket.
|
|
9
|
+
*/
|
|
10
|
+
export function stringifyArg(arg, maxLen = 2000) {
|
|
11
|
+
if (typeof arg === "string")
|
|
12
|
+
return truncate(arg, maxLen);
|
|
13
|
+
if (arg instanceof Error) {
|
|
14
|
+
return truncate(`${arg.name}: ${arg.message}${arg.stack ? `\n${arg.stack}` : ""}`, maxLen);
|
|
15
|
+
}
|
|
16
|
+
if (typeof arg === "number" || typeof arg === "boolean" || arg == null) {
|
|
17
|
+
return String(arg);
|
|
18
|
+
}
|
|
19
|
+
if (typeof arg === "function")
|
|
20
|
+
return `[Function: ${arg.name || "anonymous"}]`;
|
|
21
|
+
try {
|
|
22
|
+
return truncate(JSON.stringify(arg, cyclicSafeReplacer()), maxLen);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return truncate(String(arg), maxLen);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Join console.* varargs into one message the way the devtools console would. */
|
|
29
|
+
export function formatLogArgs(args, maxLen = 2000) {
|
|
30
|
+
return truncate(args.map((a) => stringifyArg(a, maxLen)).join(" "), maxLen);
|
|
31
|
+
}
|
|
32
|
+
function truncate(s, maxLen) {
|
|
33
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}… (${s.length - maxLen} more)` : s;
|
|
34
|
+
}
|
|
35
|
+
/** A JSON.stringify replacer that renders cyclic refs as a marker, not a throw. */
|
|
36
|
+
function cyclicSafeReplacer() {
|
|
37
|
+
const seen = new WeakSet();
|
|
38
|
+
return (_key, value) => {
|
|
39
|
+
if (typeof value === "object" && value !== null) {
|
|
40
|
+
if (seen.has(value))
|
|
41
|
+
return "[Circular]";
|
|
42
|
+
seen.add(value);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Build a LogEvent from a console call. seq/t are supplied by the buffer. */
|
|
48
|
+
export function makeLogEvent(level, args, seq, t) {
|
|
49
|
+
const message = formatLogArgs(args);
|
|
50
|
+
// For errors, surface the first meaningful stack frame so the agent can jump
|
|
51
|
+
// to a file:line without the whole trace.
|
|
52
|
+
const origin = level === "error"
|
|
53
|
+
? firstAppFrame(args.find((a) => a instanceof Error))
|
|
54
|
+
: undefined;
|
|
55
|
+
return origin
|
|
56
|
+
? { kind: "log", level, message, seq, t, origin }
|
|
57
|
+
: { kind: "log", level, message, seq, t };
|
|
58
|
+
}
|
|
59
|
+
/** First stack frame that isn't runtime/library noise, best-effort. */
|
|
60
|
+
export function firstAppFrame(err) {
|
|
61
|
+
if (!err?.stack)
|
|
62
|
+
return undefined;
|
|
63
|
+
const lines = err.stack.split("\n").slice(1);
|
|
64
|
+
const appLine = lines.find((l) => !l.includes("node_modules") &&
|
|
65
|
+
!l.includes("/harness/") &&
|
|
66
|
+
!l.includes("node:") &&
|
|
67
|
+
l.trim().startsWith("at "));
|
|
68
|
+
return appLine?.trim();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Normalize the many shapes fetch() is called with (string, URL, Request) into
|
|
72
|
+
* a {method, url} pair without consuming the body.
|
|
73
|
+
*/
|
|
74
|
+
export function describeRequest(input, init) {
|
|
75
|
+
let url = "";
|
|
76
|
+
let method = init?.method;
|
|
77
|
+
if (typeof input === "string")
|
|
78
|
+
url = input;
|
|
79
|
+
else if (input instanceof URL)
|
|
80
|
+
url = input.toString();
|
|
81
|
+
else {
|
|
82
|
+
url = input.url ?? "";
|
|
83
|
+
method = method ?? input.method;
|
|
84
|
+
}
|
|
85
|
+
return { method: (method ?? "GET").toUpperCase(), url };
|
|
86
|
+
}
|
|
87
|
+
/** Build a NetworkEvent from a settled request. */
|
|
88
|
+
export function makeNetworkEvent(desc, status, durationMs, seq, t, error) {
|
|
89
|
+
const base = {
|
|
90
|
+
kind: "network",
|
|
91
|
+
method: desc.method,
|
|
92
|
+
url: desc.url,
|
|
93
|
+
status,
|
|
94
|
+
durationMs: Math.round(durationMs),
|
|
95
|
+
seq,
|
|
96
|
+
t,
|
|
97
|
+
};
|
|
98
|
+
return error ? { ...base, error } : base;
|
|
99
|
+
}
|