Skip to main content

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: what `harExport` and the write need once
236/// the recording stops.
237pub(crate) struct HarRecording {
238    har_id: Option<String>,
239    path: String,
240    resources_dir: Option<String>,
241}
242
243/// Tracing — records Playwright traces for debugging and inspection.
244///
245/// Trace files can be opened in the Playwright Trace Viewer.
246/// This is a Chromium-only feature; calling tracing methods on Firefox or
247/// WebKit contexts will fail.
248///
249/// See: <https://playwright.dev/docs/api/class-tracing>
250#[derive(Clone)]
251pub struct Tracing {
252    base: ChannelOwnerImpl,
253    /// Shared across clones so `start_har`/`stop_har` on the same context's
254    /// `Tracing` see one recording. `stop_har` takes no path (matching the
255    /// upstream API), so the path and `harId` are stashed here at start.
256    har: Arc<parking_lot::Mutex<Option<HarRecording>>>,
257}
258
259impl Tracing {
260    /// Creates a new Tracing from protocol initialization.
261    ///
262    /// Called by the object factory when the server sends a `__create__` message.
263    pub fn new(
264        parent: ParentOrConnection,
265        type_name: String,
266        guid: Arc<str>,
267        initializer: Value,
268    ) -> Result<Self> {
269        Ok(Self {
270            base: ChannelOwnerImpl::new(parent, type_name, guid, initializer),
271            har: Arc::new(parking_lot::Mutex::new(None)),
272        })
273    }
274
275    /// Start tracing.
276    ///
277    /// Playwright implements tracing as a two-step process: `tracingStart` to
278    /// configure the trace, then `tracingStartChunk` to begin recording.
279    ///
280    /// # Arguments
281    ///
282    /// * `options` - Optional trace configuration (name, screenshots, snapshots, live)
283    ///
284    /// # Errors
285    ///
286    /// Returns error if:
287    /// - Tracing is already active
288    /// - Communication with browser process fails
289    ///
290    /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start>
291    #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
292    pub async fn start(&self, options: impl Into<Option<TracingStartOptions>>) -> Result<()> {
293        let options = options.into();
294        let opts = options.unwrap_or_default();
295
296        // Step 1: tracingStart — configure the trace
297        self.channel()
298            .send_no_result("tracingStart", opts.to_json())
299            .await?;
300
301        // Step 2: tracingStartChunk — begin the chunk/recording
302        let mut chunk_params = serde_json::json!({});
303        if let Some(name) = opts.name {
304            chunk_params["name"] = serde_json::Value::String(name);
305        }
306
307        self.channel()
308            .send_no_result("tracingStartChunk", chunk_params)
309            .await
310    }
311
312    /// Stop tracing.
313    ///
314    /// Playwright implements stopping as a two-step process: `tracingStopChunk`
315    /// to finalize the recording, then `tracingStop` to tear down.
316    ///
317    /// If `options.path` is provided, the trace is exported to that file as a
318    /// `.zip` archive. If no path is provided, the trace is discarded.
319    ///
320    /// # Arguments
321    ///
322    /// * `options` - Optional stop options; set `path` to save the trace to a file
323    ///
324    /// # Errors
325    ///
326    /// Returns error if:
327    /// - Tracing was not active
328    /// - Communication with browser process fails
329    ///
330    /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop>
331    #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
332    pub async fn stop(&self, options: impl Into<Option<TracingStopOptions>>) -> Result<()> {
333        let options = options.into();
334        let path = options.and_then(|o| o.path);
335
336        // Step 1: tracingStopChunk — mode "entries" collects trace data
337        // mode "archive" or "compressedTrace" would export, but "entries" is simpler
338        let mode = if path.is_some() { "archive" } else { "discard" };
339        let stop_chunk_params = serde_json::json!({ "mode": mode });
340
341        let chunk_result: Value = self
342            .channel()
343            .send("tracingStopChunk", stop_chunk_params)
344            .await?;
345
346        // Step 2: tracingStop — tear down
347        self.channel()
348            .send_no_result("tracingStop", serde_json::json!({}))
349            .await?;
350
351        // If a path was requested, save the artifact
352        if let Some(dest_path) = path
353            && let Some(artifact_guid) = chunk_result
354                .get("artifact")
355                .and_then(|a| a.get("guid"))
356                .and_then(|g| g.as_str())
357        {
358            // Resolve the artifact and save it
359            self.save_artifact(artifact_guid, &dest_path).await?;
360        }
361
362        Ok(())
363    }
364
365    /// Save a trace artifact to a file path.
366    async fn save_artifact(&self, artifact_guid: &str, dest_path: &str) -> Result<()> {
367        use crate::protocol::artifact::Artifact;
368        use crate::server::connection::ConnectionExt;
369
370        let artifact = self
371            .connection()
372            .get_typed::<Artifact>(artifact_guid)
373            .await?;
374
375        artifact.save_as(dest_path).await
376    }
377
378    /// Start recording a HAR (HTTP Archive) of network traffic to `path`.
379    ///
380    /// The HAR is written when [`stop_har`](Self::stop_har) is called. A `.zip`
381    /// path bundles resource bodies as separate entries (`Attach`); a plain
382    /// path inlines them (`Embed`). The recorded HAR can be opened in browser
383    /// devtools or replayed in tests via `route_from_har`.
384    ///
385    /// # Errors
386    ///
387    /// Returns an error if communication with the browser process fails.
388    ///
389    /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-har>
390    #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
391    pub async fn start_har(
392        &self,
393        path: impl Into<String>,
394        options: impl Into<Option<StartHarOptions>>,
395    ) -> Result<()> {
396        let recording = self
397            .begin_har(path.into(), options.into().unwrap_or_default())
398            .await?;
399        *self.har.lock() = Some(recording);
400        Ok(())
401    }
402
403    /// Sends `harStart` for a recording to `path` and returns what
404    /// [`export_har`](Self::export_har) needs to write it.
405    pub(crate) async fn begin_har(
406        &self,
407        path: String,
408        opts: StartHarOptions,
409    ) -> Result<HarRecording> {
410        let rec_options = opts.to_record_har_json(&path);
411        let result: Value = self
412            .channel()
413            .send("harStart", serde_json::json!({ "options": rec_options }))
414            .await?;
415        let har_id = result
416            .get("harId")
417            .and_then(|v| v.as_str())
418            .map(str::to_owned);
419        Ok(HarRecording {
420            har_id,
421            path,
422            resources_dir: opts.resources_dir,
423        })
424    }
425
426    /// Stop the HAR recording started by [`start_har`](Self::start_har) and
427    /// write it to the path given there.
428    ///
429    /// # Errors
430    ///
431    /// Returns an error if `start_har` was not called first, or if
432    /// communication with the browser process fails.
433    ///
434    /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop-har>
435    #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
436    pub async fn stop_har(&self) -> Result<()> {
437        let Some(recording) = self.har.lock().take() else {
438            return Err(crate::error::Error::InvalidArgument(
439                "stop_har called without a matching start_har".to_string(),
440            ));
441        };
442        if let Some(artifact) = self.har_export(&recording).await? {
443            self.write_har(&artifact, &recording).await?;
444        }
445        Ok(())
446    }
447
448    /// Sends `harExport` for `recording` and returns the guid of the archive
449    /// it produced, if any. The driver forgets a recording once it is
450    /// exported, so this succeeds at most once per recording.
451    pub(crate) async fn har_export(&self, recording: &HarRecording) -> Result<Option<String>> {
452        let mut params = serde_json::json!({ "mode": "archive" });
453        if let Some(id) = &recording.har_id {
454            params["harId"] = Value::String(id.clone());
455        }
456
457        let result: Value = self.channel().send("harExport", params).await?;
458        Ok(result
459            .get("artifact")
460            .and_then(|a| a.get("guid"))
461            .and_then(|g| g.as_str())
462            .map(str::to_owned))
463    }
464
465    /// Writes the archive [`har_export`](Self::har_export) produced to the
466    /// recording's path: a `.zip` path takes it as-is, any other path gets
467    /// the `.har` JSON extracted out of it.
468    pub(crate) async fn write_har(
469        &self,
470        artifact_guid: &str,
471        recording: &HarRecording,
472    ) -> Result<()> {
473        if recording.path.ends_with(".zip") {
474            return self.save_artifact(artifact_guid, &recording.path).await;
475        }
476        let local_utils = self.find_local_utils()?;
477        let tmp_zip = format!("{}.tmp.zip", recording.path);
478        self.save_artifact(artifact_guid, &tmp_zip).await?;
479        let unzipped = local_utils
480            .har_unzip(
481                &tmp_zip,
482                &recording.path,
483                recording.resources_dir.as_deref(),
484            )
485            .await;
486        let _ = std::fs::remove_file(&tmp_zip);
487        unzipped
488    }
489
490    /// Locate the connection's `LocalUtils` (used to extract a `.har` from the
491    /// exported zip archive).
492    fn find_local_utils(&self) -> Result<crate::protocol::LocalUtils> {
493        let connection = self.connection();
494        connection
495            .all_objects_sync()
496            .into_iter()
497            .find(|o| o.type_name() == "LocalUtils")
498            .and_then(|o| {
499                o.as_any()
500                    .downcast_ref::<crate::protocol::LocalUtils>()
501                    .cloned()
502            })
503            .ok_or_else(|| {
504                crate::error::Error::ProtocolError(
505                    "stop_har: LocalUtils not found in connection registry".to_string(),
506                )
507            })
508    }
509}
510
511impl ChannelOwner for Tracing {
512    fn guid(&self) -> &str {
513        self.base.guid()
514    }
515
516    fn type_name(&self) -> &str {
517        self.base.type_name()
518    }
519
520    fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
521        self.base.parent()
522    }
523
524    fn connection(&self) -> Arc<dyn ConnectionLike> {
525        self.base.connection()
526    }
527
528    fn initializer(&self) -> &Value {
529        self.base.initializer()
530    }
531
532    fn channel(&self) -> &Channel {
533        self.base.channel()
534    }
535
536    fn dispose(&self, reason: DisposeReason) {
537        self.base.dispose(reason)
538    }
539
540    fn adopt(&self, child: Arc<dyn ChannelOwner>) {
541        self.base.adopt(child)
542    }
543
544    fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
545        self.base.add_child(guid, child)
546    }
547
548    fn remove_child(&self, guid: &str) {
549        self.base.remove_child(guid)
550    }
551
552    fn on_event(&self, method: &str, params: Value) {
553        self.base.on_event(method, params)
554    }
555
556    fn was_collected(&self) -> bool {
557        self.base.was_collected()
558    }
559
560    fn as_any(&self) -> &dyn Any {
561        self
562    }
563}
564
565impl std::fmt::Debug for Tracing {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        f.debug_struct("Tracing")
568            .field("guid", &self.guid())
569            .finish()
570    }
571}