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_json::Value;
59use std::any::Any;
60use std::sync::Arc;
61
62/// Options for starting a trace recording.
63///
64/// See: <https://playwright.dev/docs/api/class-tracing#tracing-start>
65#[derive(Debug, Clone, Default)]
66#[non_exhaustive]
67pub struct TracingStartOptions {
68 /// Custom name for the trace. Shown in trace viewer as the trace title.
69 pub name: Option<String>,
70 /// Whether to capture screenshots during tracing. Screenshots are used as
71 /// a timeline preview in the trace viewer.
72 pub screenshots: Option<bool>,
73 /// Whether to capture DOM snapshots on each action.
74 pub snapshots: Option<bool>,
75 /// Whether to enable live trace updates while recording. When `true`,
76 /// the trace viewer can attach and observe the trace as it is being
77 /// captured, rather than waiting for the recording to finish. Useful
78 /// for debugging long-running flows.
79 ///
80 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-option-live>
81 pub live: Option<bool>,
82}
83
84impl TracingStartOptions {
85 /// Trace name (affects file naming in the traces directory).
86 pub fn name(mut self, name: impl Into<String>) -> Self {
87 self.name = Some(name.into());
88 self
89 }
90 /// Capture screenshots during tracing.
91 pub fn screenshots(mut self, screenshots: bool) -> Self {
92 self.screenshots = Some(screenshots);
93 self
94 }
95 /// Capture DOM snapshots during tracing.
96 pub fn snapshots(mut self, snapshots: bool) -> Self {
97 self.snapshots = Some(snapshots);
98 self
99 }
100 /// Enable live tracing (view in the trace viewer while running).
101 pub fn live(mut self, live: bool) -> Self {
102 self.live = Some(live);
103 self
104 }
105}
106
107/// Options for stopping a trace recording.
108///
109/// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop>
110#[derive(Debug, Clone, Default)]
111#[non_exhaustive]
112pub struct TracingStopOptions {
113 /// Path to export the trace file to. If not provided, the trace is discarded.
114 /// The file is written as a `.zip` archive.
115 pub path: Option<String>,
116}
117
118impl TracingStopOptions {
119 /// Export the trace to the given path.
120 pub fn path(mut self, path: impl Into<String>) -> Self {
121 self.path = Some(path.into());
122 self
123 }
124}
125
126/// In-flight HAR recording state, captured by `start_har` for `stop_har`.
127struct HarRecording {
128 har_id: Option<String>,
129 path: String,
130 resources_dir: Option<String>,
131}
132
133/// Tracing — records Playwright traces for debugging and inspection.
134///
135/// Trace files can be opened in the Playwright Trace Viewer.
136/// This is a Chromium-only feature; calling tracing methods on Firefox or
137/// WebKit contexts will fail.
138///
139/// See: <https://playwright.dev/docs/api/class-tracing>
140#[derive(Clone)]
141pub struct Tracing {
142 base: ChannelOwnerImpl,
143 /// Shared across clones so `start_har`/`stop_har` on the same context's
144 /// `Tracing` see one recording. `stop_har` takes no path (matching the
145 /// upstream API), so the path and `harId` are stashed here at start.
146 har: Arc<parking_lot::Mutex<Option<HarRecording>>>,
147}
148
149impl Tracing {
150 /// Creates a new Tracing from protocol initialization.
151 ///
152 /// Called by the object factory when the server sends a `__create__` message.
153 pub fn new(
154 parent: ParentOrConnection,
155 type_name: String,
156 guid: Arc<str>,
157 initializer: Value,
158 ) -> Result<Self> {
159 Ok(Self {
160 base: ChannelOwnerImpl::new(parent, type_name, guid, initializer),
161 har: Arc::new(parking_lot::Mutex::new(None)),
162 })
163 }
164
165 /// Start tracing.
166 ///
167 /// Playwright implements tracing as a two-step process: `tracingStart` to
168 /// configure the trace, then `tracingStartChunk` to begin recording.
169 ///
170 /// # Arguments
171 ///
172 /// * `options` - Optional trace configuration (name, screenshots, snapshots)
173 ///
174 /// # Errors
175 ///
176 /// Returns error if:
177 /// - Tracing is already active
178 /// - Communication with browser process fails
179 ///
180 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start>
181 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
182 pub async fn start(&self, options: Option<TracingStartOptions>) -> Result<()> {
183 let opts = options.unwrap_or_default();
184
185 // Step 1: tracingStart — configure the trace
186 let mut start_params = serde_json::json!({});
187 if let Some(ref name) = opts.name {
188 start_params["name"] = serde_json::Value::String(name.clone());
189 }
190 if let Some(screenshots) = opts.screenshots {
191 start_params["screenshots"] = serde_json::Value::Bool(screenshots);
192 }
193 if let Some(snapshots) = opts.snapshots {
194 start_params["snapshots"] = serde_json::Value::Bool(snapshots);
195 }
196 if let Some(live) = opts.live {
197 start_params["live"] = serde_json::Value::Bool(live);
198 }
199
200 self.channel()
201 .send_no_result("tracingStart", start_params)
202 .await?;
203
204 // Step 2: tracingStartChunk — begin the chunk/recording
205 let mut chunk_params = serde_json::json!({});
206 if let Some(name) = opts.name {
207 chunk_params["name"] = serde_json::Value::String(name);
208 }
209
210 self.channel()
211 .send_no_result("tracingStartChunk", chunk_params)
212 .await
213 }
214
215 /// Stop tracing.
216 ///
217 /// Playwright implements stopping as a two-step process: `tracingStopChunk`
218 /// to finalize the recording, then `tracingStop` to tear down.
219 ///
220 /// If `options.path` is provided, the trace is exported to that file as a
221 /// `.zip` archive. If no path is provided, the trace is discarded.
222 ///
223 /// # Arguments
224 ///
225 /// * `options` - Optional stop options; set `path` to save the trace to a file
226 ///
227 /// # Errors
228 ///
229 /// Returns error if:
230 /// - Tracing was not active
231 /// - Communication with browser process fails
232 ///
233 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop>
234 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
235 pub async fn stop(&self, options: Option<TracingStopOptions>) -> Result<()> {
236 let path = options.and_then(|o| o.path);
237
238 // Step 1: tracingStopChunk — mode "entries" collects trace data
239 // mode "archive" or "compressedTrace" would export, but "entries" is simpler
240 let mode = if path.is_some() { "archive" } else { "discard" };
241 let stop_chunk_params = serde_json::json!({ "mode": mode });
242
243 let chunk_result: Value = self
244 .channel()
245 .send("tracingStopChunk", stop_chunk_params)
246 .await?;
247
248 // Step 2: tracingStop — tear down
249 self.channel()
250 .send_no_result("tracingStop", serde_json::json!({}))
251 .await?;
252
253 // If a path was requested, save the artifact
254 if let Some(dest_path) = path
255 && let Some(artifact_guid) = chunk_result
256 .get("artifact")
257 .and_then(|a| a.get("guid"))
258 .and_then(|g| g.as_str())
259 {
260 // Resolve the artifact and save it
261 self.save_artifact(artifact_guid, &dest_path).await?;
262 }
263
264 Ok(())
265 }
266
267 /// Save a trace artifact to a file path.
268 async fn save_artifact(&self, artifact_guid: &str, dest_path: &str) -> Result<()> {
269 use crate::protocol::artifact::Artifact;
270 use crate::server::connection::ConnectionExt;
271
272 let artifact = self
273 .connection()
274 .get_typed::<Artifact>(artifact_guid)
275 .await?;
276
277 artifact.save_as(dest_path).await
278 }
279
280 /// Start recording a HAR (HTTP Archive) of network traffic to `path`.
281 ///
282 /// The HAR is written when [`stop_har`](Self::stop_har) is called. A `.zip`
283 /// path bundles resource bodies as separate entries (`Attach`); a plain
284 /// path inlines them (`Embed`). The recorded HAR can be opened in browser
285 /// devtools or replayed in tests via `route_from_har`.
286 ///
287 /// # Errors
288 ///
289 /// Returns an error if communication with the browser process fails.
290 ///
291 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-start-har>
292 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
293 pub async fn start_har(
294 &self,
295 path: impl Into<String>,
296 options: Option<StartHarOptions>,
297 ) -> Result<()> {
298 let path = path.into();
299 let opts = options.unwrap_or_default();
300 let rec_options = opts.to_record_har_json(&path);
301
302 let result: Value = self
303 .channel()
304 .send("harStart", serde_json::json!({ "options": rec_options }))
305 .await?;
306 let har_id = result
307 .get("harId")
308 .and_then(|v| v.as_str())
309 .map(str::to_owned);
310
311 *self.har.lock() = Some(HarRecording {
312 har_id,
313 path,
314 resources_dir: opts.resources_dir,
315 });
316 Ok(())
317 }
318
319 /// Stop the HAR recording started by [`start_har`](Self::start_har) and
320 /// write it to the path given there.
321 ///
322 /// # Errors
323 ///
324 /// Returns an error if `start_har` was not called first, or if
325 /// communication with the browser process fails.
326 ///
327 /// See: <https://playwright.dev/docs/api/class-tracing#tracing-stop-har>
328 #[tracing::instrument(level = "info", skip_all, fields(guid = %self.guid()))]
329 pub async fn stop_har(&self) -> Result<()> {
330 let Some(recording) = self.har.lock().take() else {
331 return Err(crate::error::Error::InvalidArgument(
332 "stop_har called without a matching start_har".to_string(),
333 ));
334 };
335
336 let mut params = serde_json::json!({ "mode": "archive" });
337 if let Some(id) = &recording.har_id {
338 params["harId"] = Value::String(id.clone());
339 }
340
341 let result: Value = self.channel().send("harExport", params).await?;
342
343 let Some(artifact_guid) = result
344 .get("artifact")
345 .and_then(|a| a.get("guid"))
346 .and_then(|g| g.as_str())
347 else {
348 return Ok(());
349 };
350
351 // harExport always yields a zip archive. A `.zip` destination takes it
352 // verbatim; any other path gets the `.har` JSON extracted out of it.
353 if recording.path.ends_with(".zip") {
354 self.save_artifact(artifact_guid, &recording.path).await?;
355 } else {
356 let tmp_zip = format!("{}.tmp.zip", recording.path);
357 self.save_artifact(artifact_guid, &tmp_zip).await?;
358 let local_utils = self.find_local_utils()?;
359 local_utils
360 .har_unzip(
361 &tmp_zip,
362 &recording.path,
363 recording.resources_dir.as_deref(),
364 )
365 .await?;
366 let _ = std::fs::remove_file(&tmp_zip);
367 }
368
369 Ok(())
370 }
371
372 /// Locate the connection's `LocalUtils` (used to extract a `.har` from the
373 /// exported zip archive).
374 fn find_local_utils(&self) -> Result<crate::protocol::LocalUtils> {
375 let connection = self.connection();
376 connection
377 .all_objects_sync()
378 .into_iter()
379 .find(|o| o.type_name() == "LocalUtils")
380 .and_then(|o| {
381 o.as_any()
382 .downcast_ref::<crate::protocol::LocalUtils>()
383 .cloned()
384 })
385 .ok_or_else(|| {
386 crate::error::Error::ProtocolError(
387 "stop_har: LocalUtils not found in connection registry".to_string(),
388 )
389 })
390 }
391}
392
393impl ChannelOwner for Tracing {
394 fn guid(&self) -> &str {
395 self.base.guid()
396 }
397
398 fn type_name(&self) -> &str {
399 self.base.type_name()
400 }
401
402 fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
403 self.base.parent()
404 }
405
406 fn connection(&self) -> Arc<dyn ConnectionLike> {
407 self.base.connection()
408 }
409
410 fn initializer(&self) -> &Value {
411 self.base.initializer()
412 }
413
414 fn channel(&self) -> &Channel {
415 self.base.channel()
416 }
417
418 fn dispose(&self, reason: DisposeReason) {
419 self.base.dispose(reason)
420 }
421
422 fn adopt(&self, child: Arc<dyn ChannelOwner>) {
423 self.base.adopt(child)
424 }
425
426 fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
427 self.base.add_child(guid, child)
428 }
429
430 fn remove_child(&self, guid: &str) {
431 self.base.remove_child(guid)
432 }
433
434 fn on_event(&self, method: &str, params: Value) {
435 self.base.on_event(method, params)
436 }
437
438 fn was_collected(&self) -> bool {
439 self.base.was_collected()
440 }
441
442 fn as_any(&self) -> &dyn Any {
443 self
444 }
445}
446
447impl std::fmt::Debug for Tracing {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 f.debug_struct("Tracing")
450 .field("guid", &self.guid())
451 .finish()
452 }
453}