playwright_rs/protocol/tracing.rs
1// Copyright 2026 Paul Adamson
2// Licensed under the Apache License, Version 2.0
3//
4// Tracing — Playwright trace recording
5//
6// Architecture Reference:
7// - Python: playwright-python/playwright/_impl/_tracing.py
8// - JavaScript: playwright/packages/playwright-core/src/client/tracing.ts
9// - Docs: https://playwright.dev/docs/api/class-tracing
10
11//! Tracing — record Playwright traces for debugging
12//!
13//! Tracing is a per-context feature. Access the Tracing object via
14//! [`BrowserContext::tracing`](crate::protocol::BrowserContext::tracing).
15//!
16//! # Example
17//!
18//! ```no_run
19//! use playwright_rs::protocol::{Playwright, TracingStartOptions};
20//!
21//! #[tokio::main]
22//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
23//! let playwright = Playwright::launch().await?;
24//! let browser = playwright.chromium().launch().await?;
25//! let context = browser.new_context().await?;
26//!
27//! let tracing = context.tracing().await?;
28//!
29//! // Start tracing with options
30//! tracing.start(Some(TracingStartOptions::default()
31//! .name("my-trace")
32//! .screenshots(true)
33//! .snapshots(true))).await?;
34//!
35//! let page = context.new_page().await?;
36//! page.goto("https://example.com", None).await?;
37//!
38//! // Stop and save the trace
39//! use playwright_rs::protocol::TracingStopOptions;
40//! tracing.stop(Some(TracingStopOptions::default()
41//! .path("/tmp/trace.zip"))).await?;
42//!
43//! context.close().await?;
44//! browser.close().await?;
45//! Ok(())
46//! }
47//! ```
48//!
49//! See: <https://playwright.dev/docs/api/class-tracing>
50
51use crate::error::Result;
52use crate::protocol::har_options::StartHarOptions;
53use crate::server::channel::Channel;
54use crate::server::channel_owner::{
55 ChannelOwner, ChannelOwnerImpl, DisposeReason, ParentOrConnection,
56};
57use crate::server::connection::ConnectionLike;
58use serde::Serialize;
59use serde_json::Value;
60use std::any::Any;
61use std::sync::Arc;
62
63/// Options for starting a trace recording.
64///
65/// Serializes straight to the driver's `tracingStart` parameters. The
66/// driver ignores keys it does not know rather than rejecting them, so a
67/// stale spelling costs every trace its snapshots and its timeline with no
68/// error anywhere; the unit tests below pin the current spellings.
69///
70/// See: <https://playwright.dev/docs/api/class-tracing#tracing-start>
71#[derive(Debug, Clone, Default, Serialize)]
72#[serde(rename_all = "camelCase")]
73#[non_exhaustive]
74pub struct TracingStartOptions {
75 /// Custom name for the trace. Shown in trace viewer as the trace title.
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub name: Option<String>,
78 /// Whether to record a screencast during tracing. Its frames are the
79 /// timeline preview in the trace viewer.
80 #[serde(rename = "screencast", skip_serializing_if = "Option::is_none")]
81 pub screenshots: Option<bool>,
82 /// Which snapshots to capture on each action. `snapshots(true)` is the
83 /// DOM snapshot alone; a [`TraceSnapshots`] value selects DOM,
84 /// accessibility, and screen captures individually.
85 #[serde(flatten)]
86 pub snapshots: Option<TraceSnapshots>,
87 /// Whether to enable live trace updates while recording. When `true`,
88 /// the trace viewer can attach and observe the trace as it is being
89 /// captured, rather than waiting for the recording to finish. Useful
90 /// for debugging long-running flows.
91 ///
92 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-option-live>
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub live: Option<bool>,
95}
96
97impl TracingStartOptions {
98 /// Trace name (affects file naming in the traces directory).
99 pub fn name(mut self, name: impl Into<String>) -> Self {
100 self.name = Some(name.into());
101 self
102 }
103 /// Capture screenshots during tracing.
104 pub fn screenshots(mut self, screenshots: bool) -> Self {
105 self.screenshots = Some(screenshots);
106 self
107 }
108 /// Capture snapshots on each action: `true` for the DOM snapshot, or a
109 /// [`TraceSnapshots`] selecting DOM, accessibility, and screen captures.
110 pub fn snapshots(mut self, snapshots: impl Into<TraceSnapshots>) -> Self {
111 self.snapshots = Some(snapshots.into());
112 self
113 }
114 /// Enable live tracing (view in the trace viewer while running).
115 pub fn live(mut self, live: bool) -> Self {
116 self.live = Some(live);
117 self
118 }
119}
120
121impl TracingStartOptions {
122 /// Convert options to JSON value for protocol
123 pub(crate) fn to_json(&self) -> Value {
124 serde_json::to_value(self).expect("TracingStartOptions serialization cannot fail")
125 }
126}
127
128/// Which snapshots a trace captures on each action.
129///
130/// `true` converts to the DOM snapshot alone, which is what
131/// `snapshots(true)` has always meant; the accessibility and screen
132/// captures are what the trace viewer's Display Aria mode shows side by
133/// side.
134///
135/// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-option-snapshots>
136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
137#[non_exhaustive]
138pub struct TraceSnapshots {
139 /// Capture the DOM on each action, replayed as the trace viewer's DOM view.
140 #[serde(rename = "snapshotDom", skip_serializing_if = "Option::is_none")]
141 pub dom: Option<bool>,
142 /// Capture an accessibility snapshot on each action.
143 #[serde(rename = "snapshotAria", skip_serializing_if = "Option::is_none")]
144 pub aria: Option<bool>,
145 /// Capture a screen snapshot on each action.
146 #[serde(rename = "snapshotScreen", skip_serializing_if = "Option::is_none")]
147 pub screen: Option<bool>,
148}
149
150impl TraceSnapshots {
151 /// Capture the DOM on each action.
152 pub fn dom(mut self, dom: bool) -> Self {
153 self.dom = Some(dom);
154 self
155 }
156 /// Capture an accessibility snapshot on each action.
157 pub fn aria(mut self, aria: bool) -> Self {
158 self.aria = Some(aria);
159 self
160 }
161 /// Capture a screen snapshot on each action.
162 pub fn screen(mut self, screen: bool) -> Self {
163 self.screen = Some(screen);
164 self
165 }
166}
167
168impl From<bool> for TraceSnapshots {
169 fn from(dom: bool) -> Self {
170 Self::default().dom(dom)
171 }
172}
173
174#[cfg(test)]
175mod to_json_tests {
176 use super::*;
177
178 #[test]
179 fn keys_are_the_driver_spellings() {
180 let params = TracingStartOptions::default()
181 .name("t")
182 .screenshots(true)
183 .snapshots(TraceSnapshots::default().dom(true).aria(false).screen(true))
184 .live(false)
185 .to_json();
186
187 assert_eq!(
188 params,
189 serde_json::json!({
190 "name": "t",
191 "screencast": true,
192 "snapshotDom": true,
193 "snapshotAria": false,
194 "snapshotScreen": true,
195 "live": false,
196 })
197 );
198 }
199
200 #[test]
201 fn a_bool_selects_the_dom_snapshot_alone() {
202 let params = TracingStartOptions::default().snapshots(true).to_json();
203
204 assert_eq!(params, serde_json::json!({ "snapshotDom": true }));
205 }
206
207 #[test]
208 fn unset_options_send_no_keys() {
209 assert_eq!(
210 TracingStartOptions::default().to_json(),
211 serde_json::json!({})
212 );
213 }
214}
215
216/// Options for stopping a trace recording.
217///
218/// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop>
219#[derive(Debug, Clone, Default)]
220#[non_exhaustive]
221pub struct TracingStopOptions {
222 /// Path to export the trace file to. If not provided, the trace is discarded.
223 /// The file is written as a `.zip` archive.
224 pub path: Option<String>,
225}
226
227impl TracingStopOptions {
228 /// Export the trace to the given path.
229 pub fn path(mut self, path: impl Into<String>) -> Self {
230 self.path = Some(path.into());
231 self
232 }
233}
234
235/// In-flight HAR recording state, captured by `start_har` for `stop_har`.
236struct HarRecording {
237 har_id: Option<String>,
238 path: String,
239 resources_dir: Option<String>,
240}
241
242/// Tracing — records Playwright traces for debugging and inspection.
243///
244/// Trace files can be opened in the Playwright Trace Viewer.
245/// This is a Chromium-only feature; calling tracing methods on Firefox or
246/// WebKit contexts will fail.
247///
248/// See: <https://playwright.dev/docs/api/class-tracing>
249#[derive(Clone)]
250pub struct Tracing {
251 base: ChannelOwnerImpl,
252 /// Shared across clones so `start_har`/`stop_har` on the same context's
253 /// `Tracing` see one recording. `stop_har` takes no path (matching the
254 /// upstream API), so the path and `harId` are stashed here at start.
255 har: Arc<parking_lot::Mutex<Option<HarRecording>>>,
256}
257
258impl Tracing {
259 /// Creates a new Tracing from protocol initialization.
260 ///
261 /// Called by the object factory when the server sends a `__create__` message.
262 pub fn new(
263 parent: ParentOrConnection,
264 type_name: String,
265 guid: Arc<str>,
266 initializer: Value,
267 ) -> Result<Self> {
268 Ok(Self {
269 base: ChannelOwnerImpl::new(parent, type_name, guid, initializer),
270 har: Arc::new(parking_lot::Mutex::new(None)),
271 })
272 }
273
274 /// Start tracing.
275 ///
276 /// Playwright implements tracing as a two-step process: `tracingStart` to
277 /// configure the trace, then `tracingStartChunk` to begin recording.
278 ///
279 /// # Arguments
280 ///
281 /// * `options` - Optional trace configuration (name, screenshots, snapshots, live)
282 ///
283 /// # Errors
284 ///
285 /// Returns error if:
286 /// - Tracing is already active
287 /// - Communication with browser process fails
288 ///
289 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start>
290 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
291 pub async fn start(&self, options: impl Into<Option<TracingStartOptions>>) -> Result<()> {
292 let options = options.into();
293 let opts = options.unwrap_or_default();
294
295 // Step 1: tracingStart — configure the trace
296 self.channel()
297 .send_no_result("tracingStart", opts.to_json())
298 .await?;
299
300 // Step 2: tracingStartChunk — begin the chunk/recording
301 let mut chunk_params = serde_json::json!({});
302 if let Some(name) = opts.name {
303 chunk_params["name"] = serde_json::Value::String(name);
304 }
305
306 self.channel()
307 .send_no_result("tracingStartChunk", chunk_params)
308 .await
309 }
310
311 /// Stop tracing.
312 ///
313 /// Playwright implements stopping as a two-step process: `tracingStopChunk`
314 /// to finalize the recording, then `tracingStop` to tear down.
315 ///
316 /// If `options.path` is provided, the trace is exported to that file as a
317 /// `.zip` archive. If no path is provided, the trace is discarded.
318 ///
319 /// # Arguments
320 ///
321 /// * `options` - Optional stop options; set `path` to save the trace to a file
322 ///
323 /// # Errors
324 ///
325 /// Returns error if:
326 /// - Tracing was not active
327 /// - Communication with browser process fails
328 ///
329 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop>
330 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
331 pub async fn stop(&self, options: impl Into<Option<TracingStopOptions>>) -> Result<()> {
332 let options = options.into();
333 let path = options.and_then(|o| o.path);
334
335 // Step 1: tracingStopChunk — mode "entries" collects trace data
336 // mode "archive" or "compressedTrace" would export, but "entries" is simpler
337 let mode = if path.is_some() { "archive" } else { "discard" };
338 let stop_chunk_params = serde_json::json!({ "mode": mode });
339
340 let chunk_result: Value = self
341 .channel()
342 .send("tracingStopChunk", stop_chunk_params)
343 .await?;
344
345 // Step 2: tracingStop — tear down
346 self.channel()
347 .send_no_result("tracingStop", serde_json::json!({}))
348 .await?;
349
350 // If a path was requested, save the artifact
351 if let Some(dest_path) = path
352 && let Some(artifact_guid) = chunk_result
353 .get("artifact")
354 .and_then(|a| a.get("guid"))
355 .and_then(|g| g.as_str())
356 {
357 // Resolve the artifact and save it
358 self.save_artifact(artifact_guid, &dest_path).await?;
359 }
360
361 Ok(())
362 }
363
364 /// Save a trace artifact to a file path.
365 async fn save_artifact(&self, artifact_guid: &str, dest_path: &str) -> Result<()> {
366 use crate::protocol::artifact::Artifact;
367 use crate::server::connection::ConnectionExt;
368
369 let artifact = self
370 .connection()
371 .get_typed::<Artifact>(artifact_guid)
372 .await?;
373
374 artifact.save_as(dest_path).await
375 }
376
377 /// Start recording a HAR (HTTP Archive) of network traffic to `path`.
378 ///
379 /// The HAR is written when [`stop_har`](Self::stop_har) is called. A `.zip`
380 /// path bundles resource bodies as separate entries (`Attach`); a plain
381 /// path inlines them (`Embed`). The recorded HAR can be opened in browser
382 /// devtools or replayed in tests via `route_from_har`.
383 ///
384 /// # Errors
385 ///
386 /// Returns an error if communication with the browser process fails.
387 ///
388 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-har>
389 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
390 pub async fn start_har(
391 &self,
392 path: impl Into<String>,
393 options: impl Into<Option<StartHarOptions>>,
394 ) -> Result<()> {
395 let options = options.into();
396 let path = path.into();
397 let opts = options.unwrap_or_default();
398 let rec_options = opts.to_record_har_json(&path);
399
400 let result: Value = self
401 .channel()
402 .send("harStart", serde_json::json!({ "options": rec_options }))
403 .await?;
404 let har_id = result
405 .get("harId")
406 .and_then(|v| v.as_str())
407 .map(str::to_owned);
408
409 *self.har.lock() = Some(HarRecording {
410 har_id,
411 path,
412 resources_dir: opts.resources_dir,
413 });
414 Ok(())
415 }
416
417 /// Stop the HAR recording started by [`start_har`](Self::start_har) and
418 /// write it to the path given there.
419 ///
420 /// # Errors
421 ///
422 /// Returns an error if `start_har` was not called first, or if
423 /// communication with the browser process fails.
424 ///
425 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop-har>
426 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
427 pub async fn stop_har(&self) -> Result<()> {
428 let Some(recording) = self.har.lock().take() else {
429 return Err(crate::error::Error::InvalidArgument(
430 "stop_har called without a matching start_har".to_string(),
431 ));
432 };
433
434 let mut params = serde_json::json!({ "mode": "archive" });
435 if let Some(id) = &recording.har_id {
436 params["harId"] = Value::String(id.clone());
437 }
438
439 let result: Value = self.channel().send("harExport", params).await?;
440
441 let Some(artifact_guid) = result
442 .get("artifact")
443 .and_then(|a| a.get("guid"))
444 .and_then(|g| g.as_str())
445 else {
446 return Ok(());
447 };
448
449 // harExport always yields a zip archive. A `.zip` destination takes it
450 // verbatim; any other path gets the `.har` JSON extracted out of it.
451 if recording.path.ends_with(".zip") {
452 self.save_artifact(artifact_guid, &recording.path).await?;
453 } else {
454 let tmp_zip = format!("{}.tmp.zip", recording.path);
455 self.save_artifact(artifact_guid, &tmp_zip).await?;
456 let local_utils = self.find_local_utils()?;
457 local_utils
458 .har_unzip(
459 &tmp_zip,
460 &recording.path,
461 recording.resources_dir.as_deref(),
462 )
463 .await?;
464 let _ = std::fs::remove_file(&tmp_zip);
465 }
466
467 Ok(())
468 }
469
470 /// Locate the connection's `LocalUtils` (used to extract a `.har` from the
471 /// exported zip archive).
472 fn find_local_utils(&self) -> Result<crate::protocol::LocalUtils> {
473 let connection = self.connection();
474 connection
475 .all_objects_sync()
476 .into_iter()
477 .find(|o| o.type_name() == "LocalUtils")
478 .and_then(|o| {
479 o.as_any()
480 .downcast_ref::<crate::protocol::LocalUtils>()
481 .cloned()
482 })
483 .ok_or_else(|| {
484 crate::error::Error::ProtocolError(
485 "stop_har: LocalUtils not found in connection registry".to_string(),
486 )
487 })
488 }
489}
490
491impl ChannelOwner for Tracing {
492 fn guid(&self) -> &str {
493 self.base.guid()
494 }
495
496 fn type_name(&self) -> &str {
497 self.base.type_name()
498 }
499
500 fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
501 self.base.parent()
502 }
503
504 fn connection(&self) -> Arc<dyn ConnectionLike> {
505 self.base.connection()
506 }
507
508 fn initializer(&self) -> &Value {
509 self.base.initializer()
510 }
511
512 fn channel(&self) -> &Channel {
513 self.base.channel()
514 }
515
516 fn dispose(&self, reason: DisposeReason) {
517 self.base.dispose(reason)
518 }
519
520 fn adopt(&self, child: Arc<dyn ChannelOwner>) {
521 self.base.adopt(child)
522 }
523
524 fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
525 self.base.add_child(guid, child)
526 }
527
528 fn remove_child(&self, guid: &str) {
529 self.base.remove_child(guid)
530 }
531
532 fn on_event(&self, method: &str, params: Value) {
533 self.base.on_event(method, params)
534 }
535
536 fn was_collected(&self) -> bool {
537 self.base.was_collected()
538 }
539
540 fn as_any(&self) -> &dyn Any {
541 self
542 }
543}
544
545impl std::fmt::Debug for Tracing {
546 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547 f.debug_struct("Tracing")
548 .field("guid", &self.guid())
549 .finish()
550 }
551}