History

Run pages link every API call to its distributed trace

The browser surface harvests the W3C traceparent the web app already sends on each API request into the run's traces.json (id + offset + url). The runs viewer renders them as a Distributed traces table — one row per call, linking into the local motel's per-trace waterfall — so 'why was that page slow in this recording' goes from the recording to the exact click→server→DB trace in two clicks. Spans exist in motel when the run exported there (E2E_MOTEL=1); ids harvest regardless.

Rhys Sullivan committed 5 months ago

Commit fff6f2b1 parent d26579a2 files · +76 −3Browse files

Unified · Split

2 files
  1. e2e/src/surfaces/browser.ts +19 −3
  2. e2e/viewer/src/App.tsx +57 −0
e2e/src/surfaces/browser.ts+19−3
@@ -4,7 +4,7 @@ · 3 unmodified lines
44 // everywhere), per-step screenshots, and a failure screenshot. The scenario
55 // drives `page` directly; assertions are vitest's job.
66 import { execFile } from "node:child_process";
7−import { copyFileSync, mkdirSync, rmSync } from "node:fs";
7+import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
88 import { join } from "node:path";
99 import { promisify } from "node:util";
1010
@@ -70,7 +70,20 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 59 unmodified lines
7070 // The session video's clock starts with the page; anchor it for the
7171 // run's focus timeline (scripts/film.ts cuts on these).
7272 markRecordingStart(dir, "browser");
73− return { browser, context, page, videoTmp, shots: { count: 0 } };
73+ // Harvest distributed-trace ids: every app API request carries a W3C
74+ // traceparent (Effect's HttpClient), and each id names one
75+ // click→server→DB trace in whatever OTLP store the run exported to
76+ // (motel locally). Written to traces.json so the runs viewer can
77+ // link a recording to its traces.
78+ const traceIds: { id: string; at: number; url: string }[] = [];
79+ page.on("request", (request) => {
80+ const traceparent = request.headers()["traceparent"];
81+ const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null;
82+ if (match?.[1]) {
83+ traceIds.push({ id: match[1], at: Date.now(), url: request.url() });
84+ }
85+ });
86+ return { browser, context, page, videoTmp, shots: { count: 0 }, traceIds };
7487 }),
7588 ({ page, context, shots }) =>
7689 Effect.promise(async () => {
@@ -95,8 +108,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 18 unmodified lines
95108 throw error;
96109 }
97110 }),
98− ({ browser, context, page, videoTmp }) =>
111+ ({ browser, context, page, videoTmp, traceIds }) =>
99112 Effect.promise(async () => {
113+ if (traceIds.length > 0) {
114+ writeFileSync(join(dir, "traces.json"), JSON.stringify(traceIds, null, 1));
115+ }
100116 await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {});
101117 const video = page.video();
102118 await context.close(); // flushes the recording
e2e/viewer/src/App.tsx+57−0
@@ -137,17 +137,33 @@ const Matrix = () => { · 136 unmodified lines
137137 // viewer (trace.playwright.dev fetches the zip from this server, client-side).
138138 // ---------------------------------------------------------------------------
139139
140+// Where this machine's motel (local OTLP store) serves its viewer. Runs
141+// export distributed traces there when E2E_MOTEL=1; the run page links its
142+// harvested trace ids straight into motel's per-trace waterfall.
143+const MOTEL_VIEWER = "http://127.0.0.1:27686";
144+
145+interface RunTraceRef {
146+ id: string;
147+ at: number;
148+ url: string;
149+}
150+
140151 const RunView = ({ target, slug }: { target: string; slug: string }) => {
141152 const base = `${target}/${slug}`;
142153 const [result, setResult] = useState<RunResult | null>(null);
143154 const [error, setError] = useState<string | null>(null);
144155 const [tab, setTab] = useState<"media" | "source">("media");
156+ const [traces, setTraces] = useState<RunTraceRef[]>([]);
145157
146158 useEffect(() => {
147159 fetch(`${base}/result.json`)
148160 .then((r) => r.json())
149161 .then(setResult)
150162 .catch((e) => setError(String(e)));
163+ fetch(`${base}/traces.json`)
164+ .then((r) => (r.ok ? r.json() : []))
165+ .then(setTraces)
166+ .catch(() => setTraces([]));
151167 }, [base]);
152168
153169 if (error) return <div className="page error-text">failed to load run: {error}</div>;
@@ -258,6 +274,47 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 104 unmodified lines
258274 No visual artifacts — this surface's source of truth is the test code and its assertions.
259275 </p>
260276 )}
277+ {traces.length > 0 && (
278+ <>
279+ <h2 className="section">Distributed traces</h2>
280+ <p className="hint">
281+ Every API call the recording made, as a click→server→DB waterfall. Served by your local
282+ motel (`bun run motel`); runs recorded without E2E_MOTEL=1 still list ids, but motel
283+ won't have the spans.
284+ </p>
285+ <table>
286+ <thead>
287+ <tr>
288+ <th>when</th>
289+ <th>request</th>
290+ <th>trace</th>
291+ </tr>
292+ </thead>
293+ <tbody>
294+ {traces.map((trace, index) => (
295+ <tr key={`${trace.id}-${index}`}>
296+ <td className="dim">
297+ {result.startedAt
298+ ? `${((trace.at - result.startedAt) / 1000).toFixed(1)}s`
299+ : new Date(trace.at).toLocaleTimeString()}
300+ </td>
301+ <td className="dim">{trace.url.replace(/^https?:\/\/[^/]+/, "")}</td>
302+ <td>
303+ <a
304+ className="tool-link"
305+ href={`${MOTEL_VIEWER}/trace/${trace.id}`}
306+ target="_blank"
307+ rel="noreferrer"
308+ >
309+ {trace.id.slice(0, 8)}…
310+ </a>
311+ </td>
312+ </tr>
313+ ))}
314+ </tbody>
315+ </table>
316+ </>
317+ )}
261318 </div>
262319 );
263320 };