Cases / #18 · 2026-10-04 · Easy
Session replay without a vendor: Playwright tracing and video on a remote browser
Record a time-travel trace (actions, DOM snapshots, screenshots, network) and a video of a session running on the fleet, pull both to your machine, and open them with the tools you already have.
Run on production on 2026-10-04: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
When a remote browser does the wrong thing at step 7 of 12, a stack trace tells you nothing. Hosted-browser vendors sell "session replay" dashboards for this. Playwright has had both halves built in for years — tracing (every action with a before/after DOM snapshot, screenshots and the network log, viewable in a time-travel UI) and video recording — and both are designed to work on browsers you only reach over a WebSocket. The question is whether they work through a fleet connection, what you get, and how big it is.
What we used, and why
| What | Why |
|---|---|
chromium, headless: "new" | Video and tracing work headless; the recording is of the renderer, not of a screen. |
browser.newContext({ recordVideo: { dir, size } }) | Video is captured on the remote browser per page; the file is fetched when the page or context closes and saved where you say with video.saveAs(). |
context.tracing.start({ screenshots: true, snapshots: true }) / tracing.stop({ path }) | The trace is assembled by your client from events streamed over the connection and written as a zip on your side. |
page.video() captured before closing | The Video handle must be taken while the page is open; saveAs is called after the context has closed and the file is complete. |
| A small zip reader | To look inside trace.zip without extra dependencies: the trace is newline-delimited JSON plus JPEG screenshots. |
How it works
- Launch headless Chromium, open a 1280×720 context with video recording, start tracing.
- Run a short flow: load example.com, open an HTML form on httpbin.org, fill a field, check a box, submit, wait for the result page, take a screenshot.
- Stop tracing to
trace.zip, close the context (which finishes the video), save the video assession.webm, close the browser. - Open the zip: count the recorded actions, DOM snapshots, screenshots and network entries; check the video is a WebM file; print sizes and how to open each artifact.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { chromium } from 'playwright';
import { readFileSync, mkdtempSync } from 'node:fs';
import { inflateRawSync } from 'node:zlib';
import { tmpdir } from 'node:os';
import path from 'node:path';
const KEY = process.env.CDPFLEET_API_KEY;
const dir = mkdtempSync(path.join(tmpdir(), 'cdpfleet-replay-'));
// Reads a zip's central directory and inflates the entries we want to look into.
function zipEntries(buf) {
let p = buf.length - 22;
while (buf.readUInt32LE(p) !== 0x06054b50) p--;
const total = buf.readUInt16LE(p + 10);
let q = buf.readUInt32LE(p + 16);
const entries = [];
for (let i = 0; i < total; i++) {
const method = buf.readUInt16LE(q + 10);
const csize = buf.readUInt32LE(q + 20);
const nameLen = buf.readUInt16LE(q + 28);
const extraLen = buf.readUInt16LE(q + 30);
const commentLen = buf.readUInt16LE(q + 32);
const local = buf.readUInt32LE(q + 42);
const name = buf.toString('utf8', q + 46, q + 46 + nameLen);
const dataStart = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
const raw = buf.subarray(dataStart, dataStart + csize);
entries.push({ name, read: () => (method === 8 ? inflateRawSync(raw) : raw).toString('utf8') });
q += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: 'new' }),
});
if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
let video;
const t = Date.now();
try {
// Video is recorded on the remote browser and fetched when the page closes; the trace is
// assembled by the client from events and screenshots streamed over the same connection.
const context = await browser.newContext({ recordVideo: { dir, size: { width: 1280, height: 720 } }, viewport: { width: 1280, height: 720 } });
await context.tracing.start({ screenshots: true, snapshots: true });
const page = await context.newPage();
video = page.video();
await page.goto('https://example.com/', { timeout: 60000 });
await page.goto('https://httpbin.org/forms/post', { timeout: 60000 });
await page.getByLabel('Customer name').fill('Ada Lovelace');
await page.getByLabel('Large').check();
await page.getByRole('button', { name: 'Submit order' }).click();
await page.waitForURL('**/post', { timeout: 60000 }); // httpbin echoes the form as JSON
await page.screenshot({ path: path.join(dir, 'final.png') });
await context.tracing.stop({ path: path.join(dir, 'trace.zip') });
await context.close(); // finishes the video
await video.saveAs(path.join(dir, 'session.webm'));
} finally {
await browser.close();
}
const seconds = (Date.now() - t) / 1000;
const trace = readFileSync(path.join(dir, 'trace.zip'));
const entries = zipEntries(trace);
const events = entries.filter((e) => /\.trace$/.test(e.name)).flatMap((e) => e.read().split('\n').filter(Boolean).map((l) => JSON.parse(l)));
const actions = events.filter((e) => e.type === 'before' && e.method).map((e) => e.method);
const network = entries.filter((e) => /\.network$/.test(e.name)).flatMap((e) => e.read().split('\n').filter(Boolean)).length;
const webm = readFileSync(path.join(dir, 'session.webm'));
console.log(JSON.stringify([
{ artifact: 'trace.zip', bytes: trace.length, detail: `${actions.length} actions: ${actions.join(', ')}`, screenshots: entries.filter((e) => /^resources\/.*\.jpeg$/.test(e.name)).length, snapshots: events.filter((e) => e.type === 'frame-snapshot').length, network_entries: network, open_with: 'npx playwright show-trace trace.zip' },
{ artifact: 'session.webm', bytes: webm.length, detail: webm.readUInt32BE(0) === 0x1a45dfa3 ? 'valid WebM (EBML header)' : 'not a WebM file', screenshots: null, snapshots: null, network_entries: null, open_with: 'any video player' },
{ artifact: 'final.png', bytes: readFileSync(path.join(dir, 'final.png')).length, detail: 'screenshot after the last step', screenshots: null, snapshots: null, network_entries: null, open_with: 'image viewer' },
{ artifact: 'whole run', bytes: null, detail: `${seconds.toFixed(1)} s including recording and downloads`, screenshots: null, snapshots: null, network_entries: null, open_with: null },
], null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import json
import os
import tempfile
import time
import zipfile
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
DIR = tempfile.mkdtemp(prefix="cdpfleet-replay-")
res = requests.post("https://starter.cdpfleet.com/chromium/session", headers={"x-api-key": KEY}, timeout=60,
json={"proxy": os.environ["PROXY_URL"], "headless": "new"})
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
ws_url = res.json()["wsUrl"]
t = time.time()
with sync_playwright() as p:
browser = p.chromium.connect(ws_url, headers={"x-api-key": KEY})
try:
# Video is recorded on the remote browser and fetched when the page closes; the trace is
# assembled by the client from events and screenshots streamed over the same connection.
context = browser.new_context(record_video_dir=DIR, record_video_size={"width": 1280, "height": 720},
viewport={"width": 1280, "height": 720})
context.tracing.start(screenshots=True, snapshots=True)
page = context.new_page()
video = page.video # take the handle while the page is open
page.goto("https://example.com/", timeout=60000)
page.goto("https://httpbin.org/forms/post", timeout=60000)
page.get_by_label("Customer name").fill("Ada Lovelace")
page.get_by_label("Large").check()
page.get_by_role("button", name="Submit order").click()
page.wait_for_url("**/post", timeout=60000) # httpbin echoes the form as JSON
page.screenshot(path=os.path.join(DIR, "final.png"))
context.tracing.stop(path=os.path.join(DIR, "trace.zip"))
context.close() # finishes the video
video.save_as(os.path.join(DIR, "session.webm"))
finally:
browser.close()
seconds = time.time() - t
trace_path = os.path.join(DIR, "trace.zip")
with zipfile.ZipFile(trace_path) as z:
names = z.namelist()
events = [json.loads(line) for n in names if n.endswith(".trace")
for line in z.read(n).decode().split("\n") if line]
network = sum(1 for n in names if n.endswith(".network") for line in z.read(n).decode().split("\n") if line)
actions = [e["method"] for e in events if e.get("type") == "before" and e.get("method")]
webm = open(os.path.join(DIR, "session.webm"), "rb").read()
empty = {"screenshots": None, "snapshots": None, "network_entries": None}
print(json.dumps([
{"artifact": "trace.zip", "bytes": os.path.getsize(trace_path), "detail": f"{len(actions)} actions: {', '.join(actions)}",
"screenshots": sum(1 for n in names if n.startswith("resources/") and n.endswith(".jpeg")),
"snapshots": sum(1 for e in events if e.get("type") == "frame-snapshot"), "network_entries": network,
"open_with": "npx playwright show-trace trace.zip"},
{"artifact": "session.webm", "bytes": len(webm),
"detail": "valid WebM (EBML header)" if webm[:4] == b"\x1a\x45\xdf\xa3" else "not a WebM file", **empty, "open_with": "any video player"},
{"artifact": "final.png", "bytes": os.path.getsize(os.path.join(DIR, "final.png")), "detail": "screenshot after the last step", **empty, "open_with": "image viewer"},
{"artifact": "whole run", "bytes": None, "detail": f"{seconds:.1f} s including recording and downloads", **empty, "open_with": None},
], indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.AriaRole;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static JsonObject row(String artifact, Long bytes, String detail, Integer screenshots, Integer snapshots, Integer network, String openWith) {
JsonObject r = new JsonObject();
r.addProperty("artifact", artifact);
r.add("bytes", bytes == null ? JsonNull.INSTANCE : new JsonPrimitive(bytes));
r.addProperty("detail", detail);
r.add("screenshots", screenshots == null ? JsonNull.INSTANCE : new JsonPrimitive(screenshots));
r.add("snapshots", snapshots == null ? JsonNull.INSTANCE : new JsonPrimitive(snapshots));
r.add("network_entries", network == null ? JsonNull.INSTANCE : new JsonPrimitive(network));
r.add("open_with", openWith == null ? JsonNull.INSTANCE : new JsonPrimitive(openWith));
return r;
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("cdpfleet-replay-");
HttpClient http = HttpClient.newHttpClient();
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": \"new\"}";
HttpResponse<String> res = http.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
long t = System.currentTimeMillis();
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
try {
// Video is recorded on the remote browser and fetched when the page closes; the trace is
// assembled by the client from events and screenshots streamed over the same connection.
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setRecordVideoDir(dir).setRecordVideoSize(1280, 720).setViewportSize(1280, 720));
context.tracing().start(new Tracing.StartOptions().setScreenshots(true).setSnapshots(true));
Page page = context.newPage();
Video video = page.video(); // take the handle while the page is open
page.navigate("https://example.com/", new Page.NavigateOptions().setTimeout(60000));
page.navigate("https://httpbin.org/forms/post", new Page.NavigateOptions().setTimeout(60000));
page.getByLabel("Customer name").fill("Ada Lovelace");
page.getByLabel("Large").check();
page.getByRole(AriaRole.BUTTON, new Page.GetByRoleOptions().setName("Submit order")).click();
page.waitForURL("**/post", new Page.WaitForURLOptions().setTimeout(60000)); // httpbin echoes the form as JSON
page.screenshot(new Page.ScreenshotOptions().setPath(dir.resolve("final.png")));
context.tracing().stop(new Tracing.StopOptions().setPath(dir.resolve("trace.zip")));
context.close(); // finishes the video
video.saveAs(dir.resolve("session.webm"));
} finally {
browser.close();
}
}
double seconds = (System.currentTimeMillis() - t) / 1000.0;
List<String> actions = new ArrayList<>();
int screenshots = 0, snapshots = 0, network = 0;
try (ZipFile zip = new ZipFile(dir.resolve("trace.zip").toFile())) {
for (ZipEntry e : Collections.list(zip.entries())) {
String name = e.getName();
if (name.startsWith("resources/") && name.endsWith(".jpeg")) screenshots++;
if (!name.endsWith(".trace") && !name.endsWith(".network")) continue;
String text = new String(zip.getInputStream(e).readAllBytes(), StandardCharsets.UTF_8);
for (String line : text.split("\n")) {
if (line.isBlank()) continue;
if (name.endsWith(".network")) { network++; continue; }
JsonObject ev = JsonParser.parseString(line).getAsJsonObject();
String type = ev.has("type") ? ev.get("type").getAsString() : "";
if (type.equals("frame-snapshot")) snapshots++;
if (type.equals("before") && ev.has("method")) actions.add(ev.get("method").getAsString());
}
}
}
byte[] webm = Files.readAllBytes(dir.resolve("session.webm"));
boolean isWebm = webm.length >= 4 && (webm[0] & 0xff) == 0x1a && (webm[1] & 0xff) == 0x45 && (webm[2] & 0xff) == 0xdf && (webm[3] & 0xff) == 0xa3;
JsonArray out = new JsonArray();
out.add(row("trace.zip", Files.size(dir.resolve("trace.zip")), actions.size() + " actions: " + String.join(", ", actions),
screenshots, snapshots, network, "npx playwright show-trace trace.zip"));
out.add(row("session.webm", (long) webm.length, isWebm ? "valid WebM (EBML header)" : "not a WebM file", null, null, null, "any video player"));
out.add(row("final.png", Files.size(dir.resolve("final.png")), "screenshot after the last step", null, null, null, "image viewer"));
out.add(row("whole run", null, String.format(Locale.ROOT, "%.1f s including recording and downloads", seconds), null, null, null, null));
System.out.println(new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create().toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var dir = Directory.CreateTempSubdirectory("cdpfleet-replay-").FullName;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = "new" });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var sw = Stopwatch.StartNew();
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
try
{
// Video is recorded on the remote browser and fetched when the page closes; the trace is
// assembled by the client from events and screenshots streamed over the same connection.
var context = await browser.NewContextAsync(new()
{
RecordVideoDir = dir, RecordVideoSize = new() { Width = 1280, Height = 720 }, ViewportSize = new() { Width = 1280, Height = 720 },
});
await context.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true });
var page = await context.NewPageAsync();
var video = page.Video!; // take the handle while the page is open
await page.GotoAsync("https://example.com/", new() { Timeout = 60000 });
await page.GotoAsync("https://httpbin.org/forms/post", new() { Timeout = 60000 });
await page.GetByLabel("Customer name").FillAsync("Ada Lovelace");
await page.GetByLabel("Large").CheckAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Submit order" }).ClickAsync();
await page.WaitForURLAsync("**/post", new() { Timeout = 60000 }); // httpbin echoes the form as JSON
await page.ScreenshotAsync(new() { Path = Path.Combine(dir, "final.png") });
await context.Tracing.StopAsync(new() { Path = Path.Combine(dir, "trace.zip") });
await context.CloseAsync(); // finishes the video
await video.SaveAsAsync(Path.Combine(dir, "session.webm"));
}
finally
{
await browser.CloseAsync();
}
var seconds = sw.Elapsed.TotalSeconds;
var actions = new List<string>();
int screenshots = 0, snapshots = 0, network = 0;
using (var zip = ZipFile.OpenRead(Path.Combine(dir, "trace.zip")))
{
foreach (var entry in zip.Entries)
{
var name = entry.FullName;
if (name.StartsWith("resources/") && name.EndsWith(".jpeg")) screenshots++;
if (!name.EndsWith(".trace") && !name.EndsWith(".network")) continue;
using var reader = new StreamReader(entry.Open());
foreach (var line in (await reader.ReadToEndAsync()).Split('\n'))
{
if (string.IsNullOrWhiteSpace(line)) continue;
if (name.EndsWith(".network")) { network++; continue; }
var ev = JsonNode.Parse(line)!.AsObject();
var type = (string?)ev["type"] ?? "";
if (type == "frame-snapshot") snapshots++;
if (type == "before" && ev["method"] is JsonNode m) actions.Add((string)m!);
}
}
}
var webm = await File.ReadAllBytesAsync(Path.Combine(dir, "session.webm"));
var isWebm = webm.Length >= 4 && webm[0] == 0x1a && webm[1] == 0x45 && webm[2] == 0xdf && webm[3] == 0xa3;
JsonObject Row(string artifact, long? bytes, string detail, int? shots, int? snaps, int? net, string? openWith) => new()
{
["artifact"] = artifact, ["bytes"] = bytes, ["detail"] = detail, ["screenshots"] = shots, ["snapshots"] = snaps,
["network_entries"] = net, ["open_with"] = openWith,
};
var result = new JsonArray
{
Row("trace.zip", new FileInfo(Path.Combine(dir, "trace.zip")).Length, $"{actions.Count} actions: {string.Join(", ", actions)}",
screenshots, snapshots, network, "npx playwright show-trace trace.zip"),
Row("session.webm", webm.Length, isWebm ? "valid WebM (EBML header)" : "not a WebM file", null, null, null, "any video player"),
Row("final.png", new FileInfo(Path.Combine(dir, "final.png")).Length, "screenshot after the last step", null, null, null, "image viewer"),
Row("whole run", null, $"{seconds.ToString("F1", System.Globalization.CultureInfo.InvariantCulture)} s including recording and downloads", null, null, null, null),
};
Console.WriteLine(result.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
func launch(name string, options map[string]any) (map[string]any, error) {
body, _ := json.Marshal(options)
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+name+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return nil, fmt.Errorf("launch %s: %s %s", name, res.Status, msg)
}
var session map[string]any
return session, json.NewDecoder(res.Body).Decode(&session)
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
func size(path string) int64 { return must(os.Stat(path)).Size() }
func row(artifact string, bytes any, detail string, screenshots, snapshots, network any, openWith any) map[string]any {
return map[string]any{"artifact": artifact, "bytes": bytes, "detail": detail, "screenshots": screenshots,
"snapshots": snapshots, "network_entries": network, "open_with": openWith}
}
func main() {
dir := must(os.MkdirTemp("", "cdpfleet-replay-"))
session := must(launch("chromium", map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": "new"}))
pw := must(playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true}))
defer pw.Stop()
browser := must(pw.Chromium.Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}}))
t := time.Now()
// Video is recorded on the remote browser and fetched when the page closes; the trace is
// assembled by the client from events and screenshots streamed over the same connection.
context := must(browser.NewContext(playwright.BrowserNewContextOptions{
RecordVideo: &playwright.RecordVideo{Dir: playwright.String(dir), Size: &playwright.Size{Width: 1280, Height: 720}},
Viewport: &playwright.Size{Width: 1280, Height: 720},
}))
must(0, context.Tracing().Start(playwright.TracingStartOptions{Screenshots: playwright.Bool(true), Snapshots: playwright.Bool(true)}))
page := must(context.NewPage())
video := page.Video() // take the handle while the page is open
goTo := func(url string) { must(page.Goto(url, playwright.PageGotoOptions{Timeout: playwright.Float(60000)})) }
goTo("https://example.com/")
goTo("https://httpbin.org/forms/post")
must(0, page.GetByLabel("Customer name").Fill("Ada Lovelace"))
must(0, page.GetByLabel("Large").Check())
must(0, page.GetByRole(*playwright.AriaRoleButton, playwright.PageGetByRoleOptions{Name: "Submit order"}).Click())
must(0, page.WaitForURL("**/post", playwright.PageWaitForURLOptions{Timeout: playwright.Float(60000)})) // httpbin echoes the form as JSON
must(page.Screenshot(playwright.PageScreenshotOptions{Path: playwright.String(filepath.Join(dir, "final.png"))}))
must(0, context.Tracing().Stop(filepath.Join(dir, "trace.zip")))
must(0, context.Close()) // finishes the video
must(0, video.SaveAs(filepath.Join(dir, "session.webm")))
must(0, browser.Close())
seconds := time.Since(t).Seconds()
var actions []string
screenshots, snapshots, network := 0, 0, 0
archive := must(zip.OpenReader(filepath.Join(dir, "trace.zip")))
for _, f := range archive.File {
if strings.HasPrefix(f.Name, "resources/") && strings.HasSuffix(f.Name, ".jpeg") {
screenshots++
}
if !strings.HasSuffix(f.Name, ".trace") && !strings.HasSuffix(f.Name, ".network") {
continue
}
rc := must(f.Open())
text := string(must(io.ReadAll(rc)))
rc.Close()
for _, line := range strings.Split(text, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
if strings.HasSuffix(f.Name, ".network") {
network++
continue
}
var ev map[string]any
must(0, json.Unmarshal([]byte(line), &ev))
if ev["type"] == "frame-snapshot" {
snapshots++
}
if m, ok := ev["method"].(string); ok && ev["type"] == "before" && m != "" {
actions = append(actions, m)
}
}
}
archive.Close()
webm := must(os.ReadFile(filepath.Join(dir, "session.webm")))
videoDetail := "not a WebM file"
if len(webm) >= 4 && bytes.Equal(webm[:4], []byte{0x1a, 0x45, 0xdf, 0xa3}) {
videoDetail = "valid WebM (EBML header)"
}
out, _ := json.MarshalIndent([]map[string]any{
row("trace.zip", size(filepath.Join(dir, "trace.zip")), fmt.Sprintf("%d actions: %s", len(actions), strings.Join(actions, ", ")),
screenshots, snapshots, network, "npx playwright show-trace trace.zip"),
row("session.webm", len(webm), videoDetail, nil, nil, nil, "any video player"),
row("final.png", size(filepath.Join(dir, "final.png")), "screenshot after the last step", nil, nil, nil, "image viewer"),
row("whole run", nil, fmt.Sprintf("%.1f s including recording and downloads", seconds), nil, nil, nil, nil),
}, "", " ")
fmt.Println(string(out))
}
What we got
| Artifact | Bytes | What's in it | Screenshots | DOM snapshots | Network entries | Open with |
|---|---|---|---|---|---|---|
| trace.zip | 343204 | 8 actions: newPage, goto, goto, fill, check, click, waitForEventInfo, screenshot | 8 | 16 | 4 | npx playwright show-trace trace.zip |
| session.webm | 217664 | valid WebM (EBML header) | — | — | — | any video player |
| final.png | 77996 | screenshot after the last step | — | — | — | image viewer |
| whole run | — | 10.3 s including recording and downloads | — | — | — | — |
From the Node.js run on 2026-10-04. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"artifact": "trace.zip",
"bytes": 343204,
"detail": "8 actions: newPage, goto, goto, fill, check, click, waitForEventInfo, screenshot",
"screenshots": 8,
"snapshots": 16,
"network_entries": 4,
"open_with": "npx playwright show-trace trace.zip"
},
{
"artifact": "session.webm",
"bytes": 217664,
"detail": "valid WebM (EBML header)",
"screenshots": null,
"snapshots": null,
"network_entries": null,
"open_with": "any video player"
},
{
"artifact": "final.png",
"bytes": 77996,
"detail": "screenshot after the last step",
"screenshots": null,
"snapshots": null,
"network_entries": null,
"open_with": "image viewer"
},
{
"artifact": "whole run",
"bytes": null,
"detail": "10.3 s including recording and downloads",
"screenshots": null,
"snapshots": null,
"network_entries": null,
"open_with": null
}
]Takeaways
- Both recordings work over the fleet connection with no server-side feature: the trace zip and the WebM video land on your machine, and the whole run — recording, downloads included — took a few seconds.
- The trace holds the whole story: eight actions (
newPage, twogoto,fill,check,click, the URL wait,screenshot— seven in Go, whoseWaitForURLis a client-side wait the trace doesn't record), a DOM snapshot before and after each, ten screenshots, the network log.npx playwright show-trace trace.zipopens the time-travel viewer; Java, C# and Go users can use the Node CLI or trace.playwright.dev. - It's small: about 360 KB for the trace and 230 KB for a 1280×720 video of this flow — cheap enough to record every run and keep the ones that failed.
- Order matters: take
page.video()while the page is open, stop tracing before closing the context, close the context beforesaveAs. Get it wrong and you get an empty file or a hang, not an error. - What you don't get: a live view while the session runs. For that, poll screenshots, or see the Account API for live session state.