cdp_core/
tracing.rs

1use std::{path::PathBuf, sync::Arc};
2
3use base64::Engine as _;
4use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5use cdp_protocol::{io, tracing as tracing_cdp, tracing::StartTransferModeOption};
6use tokio::{fs, sync::Mutex};
7
8use crate::{
9    error::{CdpError, Result},
10    page::Page,
11};
12
13const DEFAULT_TRACE_CATEGORIES: &[&str] = &[
14    "-*",
15    "devtools.timeline",
16    "v8.execute",
17    "disabled-by-default-devtools.timeline",
18    "disabled-by-default-devtools.timeline.frame",
19    "disabled-by-default-devtools.timeline.stack",
20    "disabled-by-default-v8.cpu_profiler",
21];
22
23/// Tracing lifecycle status.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub(crate) enum TracingStatus {
26    #[default]
27    Idle,
28    Active,
29    Stopping,
30}
31
32/// Page-scoped cache that records tracing state transitions.
33#[derive(Debug, Default)]
34pub(crate) struct TracingSessionState {
35    pub status: TracingStatus,
36}
37
38/// Configuration used when starting a tracing session.
39#[derive(Debug, Clone)]
40pub struct TracingStartOptions {
41    /// Categories to trace; defaults to a curated set that captures page activity.
42    pub categories: Option<Vec<String>>,
43    /// Whether to capture screenshots in addition to trace events.
44    pub screenshots: bool,
45    /// Trace record mode (see `TraceConfig.record_mode`).
46    pub record_mode: tracing_cdp::TraceConfigRecordMode,
47    /// Optional buffer usage reporting interval in milliseconds.
48    pub buffer_usage_reporting_interval_ms: Option<f64>,
49    /// Requested stream format for trace data.
50    pub stream_format: tracing_cdp::StreamFormat,
51    /// Requested compression format for the streamed trace.
52    pub stream_compression: tracing_cdp::StreamCompression,
53    /// Optional tracing backend to use.
54    pub tracing_backend: Option<tracing_cdp::TracingBackend>,
55}
56
57impl Default for TracingStartOptions {
58    fn default() -> Self {
59        Self {
60            categories: None,
61            screenshots: false,
62            record_mode: tracing_cdp::TraceConfigRecordMode::RecordAsMuchAsPossible,
63            buffer_usage_reporting_interval_ms: None,
64            stream_format: tracing_cdp::StreamFormat::Json,
65            stream_compression: tracing_cdp::StreamCompression::None,
66            tracing_backend: None,
67        }
68    }
69}
70
71/// Result returned after stopping tracing.
72#[derive(Debug, Clone)]
73pub struct TracingStopResult {
74    /// Raw trace data gathered during the session.
75    pub data: Vec<u8>,
76    /// Indicates whether Chrome reported data loss.
77    pub data_loss_occurred: bool,
78    /// Reported stream format.
79    pub format: Option<tracing_cdp::StreamFormat>,
80    /// Reported compression algorithm.
81    pub compression: Option<tracing_cdp::StreamCompression>,
82    /// Path to the saved file if trace data was persisted to disk.
83    pub saved_path: Option<PathBuf>,
84}
85
86/// High-level controller for Chrome tracing on a single `Page`.
87///
88/// # Examples
89/// ```no_run
90/// # use cdp_core::Page;
91/// # use cdp_core::TracingStartOptions;
92/// # use std::sync::Arc;
93/// # async fn example(page: Arc<Page>) -> anyhow::Result<()> {
94/// let tracing = page.tracing();
95/// tracing.start(Some(TracingStartOptions::default())).await?;
96/// // ... perform actions to record ...
97/// let result = tracing.stop(None).await?;
98/// assert!(!result.data.is_empty());
99/// # Ok(())
100/// # }
101/// ```
102pub struct TracingController {
103    page: Arc<Page>,
104    state: Arc<Mutex<TracingSessionState>>,
105}
106
107impl TracingController {
108    pub(crate) fn new(page: Arc<Page>, state: Arc<Mutex<TracingSessionState>>) -> Self {
109        Self { page, state }
110    }
111
112    /// Returns `true` if tracing has been started and not yet fully stopped.
113    pub async fn is_recording(&self) -> bool {
114        !matches!(self.state.lock().await.status, TracingStatus::Idle)
115    }
116
117    /// Queries the browser for supported tracing categories.
118    pub async fn categories(&self) -> Result<Vec<String>> {
119        let result: tracing_cdp::GetCategoriesReturnObject = self
120            .page
121            .session
122            .send_command(tracing_cdp::GetCategories(None), None)
123            .await?;
124        Ok(result.categories)
125    }
126
127    /// Starts tracing with the provided options (or defaults when `None`).
128    pub async fn start(&self, options: Option<TracingStartOptions>) -> Result<()> {
129        let options = options.unwrap_or_default();
130
131        {
132            let mut state = self.state.lock().await;
133            match state.status {
134                TracingStatus::Idle => state.status = TracingStatus::Active,
135                TracingStatus::Active => {
136                    return Err(CdpError::tool(
137                        "Tracing is already active; call stop() before starting again",
138                    ));
139                }
140                TracingStatus::Stopping => {
141                    return Err(CdpError::tool(
142                        "Tracing stop is still in progress; try again after it completes",
143                    ));
144                }
145            }
146        }
147
148        let start_params = build_start_command(&options);
149        let result = self
150            .page
151            .session
152            .send_command::<_, tracing_cdp::StartReturnObject>(start_params, None)
153            .await;
154
155        if let Err(err) = result {
156            let mut state = self.state.lock().await;
157            state.status = TracingStatus::Idle;
158            return Err(err);
159        }
160
161        Ok(())
162    }
163
164    /// Stops tracing and optionally persists the trace to disk.
165    pub async fn stop(&self, save_path: Option<PathBuf>) -> Result<TracingStopResult> {
166        {
167            let mut state = self.state.lock().await;
168            match state.status {
169                TracingStatus::Active => state.status = TracingStatus::Stopping,
170                TracingStatus::Idle => {
171                    return Err(CdpError::tool(
172                        "Tracing has not been started; call start() before stop()",
173                    ));
174                }
175                TracingStatus::Stopping => {
176                    return Err(CdpError::tool(
177                        "Tracing stop is already in progress; do not call stop() again",
178                    ));
179                }
180            }
181        }
182
183        let stop_result = self.finish_stop(save_path).await;
184
185        let mut state = self.state.lock().await;
186        state.status = TracingStatus::Idle;
187
188        stop_result
189    }
190
191    async fn finish_stop(&self, save_path: Option<PathBuf>) -> Result<TracingStopResult> {
192        let _: tracing_cdp::EndReturnObject = self
193            .page
194            .session
195            .send_command(tracing_cdp::End(None), None)
196            .await?;
197
198        let event = self
199            .page
200            .wait_for::<tracing_cdp::events::TracingCompleteEvent>()
201            .await?;
202        let params = event.params;
203
204        let stream_handle = params.stream.ok_or_else(|| {
205            CdpError::tool(
206                "TracingComplete event did not include a stream handle; trace data unavailable",
207            )
208        })?;
209
210        let mut data = self.drain_stream(stream_handle.clone()).await?;
211        self.close_stream(stream_handle).await?;
212
213        let saved_path = if let Some(path) = save_path {
214            if let Some(parent) = path.parent()
215                && !parent.as_os_str().is_empty()
216            {
217                fs::create_dir_all(parent).await?;
218            }
219            fs::write(&path, &data).await?;
220            Some(path)
221        } else {
222            None
223        };
224
225        if params.data_loss_occurred {
226            tracing::warn!("TracingComplete reported data loss; trace output may be incomplete");
227        }
228
229        Ok(TracingStopResult {
230            data: std::mem::take(&mut data),
231            data_loss_occurred: params.data_loss_occurred,
232            format: params.trace_format,
233            compression: params.stream_compression,
234            saved_path,
235        })
236    }
237
238    async fn drain_stream(&self, handle: String) -> Result<Vec<u8>> {
239        let mut buffer = Vec::new();
240        loop {
241            let chunk: io::ReadReturnObject = self
242                .page
243                .session
244                .send_command(
245                    io::Read {
246                        handle: handle.clone(),
247                        offset: None,
248                        size: None,
249                    },
250                    None,
251                )
252                .await?;
253
254            if chunk.base_64_encoded.unwrap_or(false) {
255                let mut decoded = BASE64_STANDARD
256                    .decode(chunk.data.as_bytes())
257                    .map_err(|err| {
258                        CdpError::protocol(format!(
259                            "Failed to decode base64-encoded tracing chunk: {err}"
260                        ))
261                    })?;
262                buffer.append(&mut decoded);
263            } else {
264                buffer.extend_from_slice(chunk.data.as_bytes());
265            }
266
267            if chunk.eof {
268                break;
269            }
270        }
271        Ok(buffer)
272    }
273
274    async fn close_stream(&self, handle: String) -> Result<()> {
275        let _: io::CloseReturnObject = self
276            .page
277            .session
278            .send_command(io::Close { handle }, None)
279            .await?;
280        Ok(())
281    }
282}
283
284fn build_start_command(options: &TracingStartOptions) -> tracing_cdp::Start {
285    let mut categories: Vec<String> = options.categories.clone().unwrap_or_else(|| {
286        DEFAULT_TRACE_CATEGORIES
287            .iter()
288            .map(|c| c.to_string())
289            .collect()
290    });
291
292    if options.screenshots
293        && !categories
294            .iter()
295            .any(|cat| cat == "disabled-by-default-devtools.screenshot")
296    {
297        categories.push("disabled-by-default-devtools.screenshot".to_string());
298    }
299
300    tracing_cdp::Start {
301        categories: None,
302        options: Some("record-as-much-as-possible".to_string()),
303        buffer_usage_reporting_interval: options.buffer_usage_reporting_interval_ms,
304        transfer_mode: Some(StartTransferModeOption::ReturnAsStream),
305        stream_format: Some(options.stream_format.clone()),
306        stream_compression: Some(options.stream_compression.clone()),
307        trace_config: Some(tracing_cdp::TraceConfig {
308            record_mode: Some(options.record_mode.clone()),
309            trace_buffer_size_in_kb: None,
310            enable_sampling: Some(true),
311            enable_systrace: None,
312            enable_argument_filter: None,
313            included_categories: if categories.is_empty() {
314                None
315            } else {
316                Some(categories)
317            },
318            excluded_categories: None,
319            synthetic_delays: None,
320            memory_dump_config: None,
321        }),
322        perfetto_config: None,
323        tracing_backend: options.tracing_backend.clone(),
324    }
325}