Skip to content

Commit 3ffc511

Browse files
committed
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.
1 parent e437743 commit 3ffc511

2 files changed

Lines changed: 76 additions & 3 deletions

File tree

e2e/src/surfaces/browser.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
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
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
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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,33 @@ const Matrix = () => {
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 }) => {
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
};

0 commit comments

Comments
 (0)