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.
Unified · Split
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | // everywhere), per-step screenshots, and a failure screenshot. The scenario |
| 5 | 5 | // drives `page` directly; assertions are vitest's job. |
| 6 | 6 | import { execFile } from "node:child_process"; |
| 7 | −import { copyFileSync, mkdirSync, rmSync } from "node:fs"; | |
| 7 | +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { promisify } from "node:util"; |
| 10 | 10 | |
| @@ -70,7 +70,20 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 59 unmodified lines | ||
| 70 | 70 | // The session video's clock starts with the page; anchor it for the |
| 71 | 71 | // run's focus timeline (scripts/film.ts cuts on these). |
| 72 | 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 | 88 | ({ page, context, shots }) => |
| 76 | 89 | Effect.promise(async () => { |
| @@ -95,8 +108,11 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface · 18 unmodified lines | ||
| 95 | 108 | throw error; |
| 96 | 109 | } |
| 97 | 110 | }), |
| 98 | − ({ browser, context, page, videoTmp }) => | |
| 111 | + ({ browser, context, page, videoTmp, traceIds }) => | |
| 99 | 112 | Effect.promise(async () => { |
| 113 | + if (traceIds.length > 0) { | |
| 114 | + writeFileSync(join(dir, "traces.json"), JSON.stringify(traceIds, null, 1)); | |
| 115 | + } | |
| 100 | 116 | await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); |
| 101 | 117 | const video = page.video(); |
| 102 | 118 | await context.close(); // flushes the recording |
| @@ -137,17 +137,33 @@ const Matrix = () => { · 136 unmodified lines | ||
| 137 | 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. | |
| 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 | + | |
| 140 | 151 | const RunView = ({ target, slug }: { target: string; slug: string }) => { |
| 141 | 152 | const base = `${target}/${slug}`; |
| 142 | 153 | const [result, setResult] = useState<RunResult | null>(null); |
| 143 | 154 | const [error, setError] = useState<string | null>(null); |
| 144 | 155 | const [tab, setTab] = useState<"media" | "source">("media"); |
| 156 | + const [traces, setTraces] = useState<RunTraceRef[]>([]); | |
| 145 | 157 | |
| 146 | 158 | useEffect(() => { |
| 147 | 159 | fetch(`${base}/result.json`) |
| 148 | 160 | .then((r) => r.json()) |
| 149 | 161 | .then(setResult) |
| 150 | 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 | 167 | }, [base]); |
| 152 | 168 | |
| 153 | 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 | 274 | No visual artifacts — this surface's source of truth is the test code and its assertions. |
| 259 | 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 | 318 | </div> |
| 262 | 319 | ); |
| 263 | 320 | }; |