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 | // everywhere), per-step screenshots, and a failure screenshot. The scenario | 4 | // 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. |
| 6 | import { execFile } from "node:child_process"; | 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 | import { join } from "node:path"; | 8 | import { join } from "node:path"; |
| 9 | import { promisify } from "node:util"; | 9 | import { 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 the | 70 | // 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 recording | 118 | await context.close(); // flushes the recording |
| @@ -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. | ||
| 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 | const RunView = ({ target, slug }: { target: string; slug: string }) => { | 151 | const 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 | }; |