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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub(crate) enum TracingStatus {
26 #[default]
27 Idle,
28 Active,
29 Stopping,
30}
31
32#[derive(Debug, Default)]
34pub(crate) struct TracingSessionState {
35 pub status: TracingStatus,
36}
37
38#[derive(Debug, Clone)]
40pub struct TracingStartOptions {
41 pub categories: Option<Vec<String>>,
43 pub screenshots: bool,
45 pub record_mode: tracing_cdp::TraceConfigRecordMode,
47 pub buffer_usage_reporting_interval_ms: Option<f64>,
49 pub stream_format: tracing_cdp::StreamFormat,
51 pub stream_compression: tracing_cdp::StreamCompression,
53 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#[derive(Debug, Clone)]
73pub struct TracingStopResult {
74 pub data: Vec<u8>,
76 pub data_loss_occurred: bool,
78 pub format: Option<tracing_cdp::StreamFormat>,
80 pub compression: Option<tracing_cdp::StreamCompression>,
82 pub saved_path: Option<PathBuf>,
84}
85
86pub 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 pub async fn is_recording(&self) -> bool {
114 !matches!(self.state.lock().await.status, TracingStatus::Idle)
115 }
116
117 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 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 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}