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
4// everywhere), per-step screenshots, and a failure screenshot. The scenario4// everywhere), per-step screenshots, and a failure screenshot. The scenario
5// drives `page` directly; assertions are vitest's job.5// drives `page` directly; assertions are vitest's job.
6import { execFile } from "node:child_process";6import { execFile } from "node:child_process";
7import { copyFileSync, mkdirSync, rmSync } from "node:fs";7import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8import { join } from "node:path";8import { join } from "node:path";
9import { promisify } from "node:util";9import { promisify } from "node:util";
10 10
@@ -70,7 +70,20 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 59 unmodified lines
70 // The session video's clock starts with the page; anchor it for the70 // The session video's clock starts with the page; anchor it for the
71 // run's focus timeline (scripts/film.ts cuts on these).71 // run's focus timeline (scripts/film.ts cuts on these).
72 markRecordingStart(dir, "browser");72 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 };
74 }),87 }),
75 ({ page, context, shots }) =>88 ({ page, context, shots }) =>
76 Effect.promise(async () => {89 Effect.promise(async () => {
@@ -95,8 +108,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 18 unmodified lines
95 throw error;108 throw error;
96 }109 }
97 }),110 }),
98 ({ browser, context, page, videoTmp }) =>111 ({ browser, context, page, videoTmp, traceIds }) =>
99 Effect.promise(async () => {112 Effect.promise(async () => {
113 if (traceIds.length > 0) {
114 writeFileSync(join(dir, "traces.json"), JSON.stringify(traceIds, null, 1));
115 }
100 await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {});116 await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {});
101 const video = page.video();117 const video = page.video();
102 await context.close(); // flushes the recording118 await context.close(); // flushes the recording
e2e/viewer/src/App.tsx+57−0
@@ -137,17 +137,33 @@ const Matrix = () => { · 136 unmodified lines
137// viewer (trace.playwright.dev fetches the zip from this server, client-side).137// viewer (trace.playwright.dev fetches the zip from this server, client-side).
138// ---------------------------------------------------------------------------138// ---------------------------------------------------------------------------
139 139
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.
143const MOTEL_VIEWER = "http://127.0.0.1:27686";
144
145interface RunTraceRef {
146 id: string;
147 at: number;
148 url: string;
149}
150
140const RunView = ({ target, slug }: { target: string; slug: string }) => {151const RunView = ({ target, slug }: { target: string; slug: string }) => {
141 const base = `${target}/${slug}`;152 const base = `${target}/${slug}`;
142 const [result, setResult] = useState<RunResult | null>(null);153 const [result, setResult] = useState<RunResult | null>(null);
143 const [error, setError] = useState<string | null>(null);154 const [error, setError] = useState<string | null>(null);
144 const [tab, setTab] = useState<"media" | "source">("media");155 const [tab, setTab] = useState<"media" | "source">("media");
156 const [traces, setTraces] = useState<RunTraceRef[]>([]);
145 157
146 useEffect(() => {158 useEffect(() => {
147 fetch(`${base}/result.json`)159 fetch(`${base}/result.json`)
148 .then((r) => r.json())160 .then((r) => r.json())
149 .then(setResult)161 .then(setResult)
150 .catch((e) => setError(String(e)));162 .catch((e) => setError(String(e)));
163 fetch(`${base}/traces.json`)
164 .then((r) => (r.ok ? r.json() : []))
165 .then(setTraces)
166 .catch(() => setTraces([]));
151 }, [base]);167 }, [base]);
152 168
153 if (error) return <div className="page error-text">failed to load run: {error}</div>;169 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
258 No visual artifacts — this surface's source of truth is the test code and its assertions.274 No visual artifacts — this surface's source of truth is the test code and its assertions.
259 </p>275 </p>
260 )}276 )}
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 )}
261 </div>318 </div>
262 );319 );
263};320};