viewpoint_core/context/trace/
mod.rs1mod capture;
10mod network;
11mod sources;
12mod types;
13mod writer;
14
15use std::sync::Arc;
16
17use chrono::Utc;
18use tokio::sync::RwLock;
19use tracing::{debug, info, instrument};
20
21use viewpoint_cdp::protocol::tracing as cdp_tracing;
22use viewpoint_cdp::CdpConnection;
23
24use crate::context::PageInfo;
25use crate::error::ContextError;
26use crate::network::har::HarPage;
27
28pub use types::{ActionEntry, TracingOptions};
29pub(crate) use types::TracingState;
30use types::SourceFileEntry;
31
32pub struct Tracing {
71 connection: Arc<CdpConnection>,
73 context_id: String,
75 pages: Arc<RwLock<Vec<PageInfo>>>,
77 state: Arc<RwLock<TracingState>>,
79}
80
81impl std::fmt::Debug for Tracing {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("Tracing")
84 .field("context_id", &self.context_id)
85 .finish_non_exhaustive()
86 }
87}
88
89impl Tracing {
90 pub(crate) fn new(
92 connection: Arc<CdpConnection>,
93 context_id: String,
94 pages: Arc<RwLock<Vec<PageInfo>>>,
95 state: Arc<RwLock<TracingState>>,
96 ) -> Self {
97 Self {
98 connection,
99 context_id,
100 pages,
101 state,
102 }
103 }
104
105 async fn get_session_ids(&self) -> Vec<String> {
107 let pages = self.pages.read().await;
108 pages
109 .iter()
110 .filter(|p| !p.session_id.is_empty())
111 .map(|p| p.session_id.clone())
112 .collect()
113 }
114
115 #[instrument(level = "info", skip(self, options))]
143 pub async fn start(&self, options: TracingOptions) -> Result<(), ContextError> {
144 let mut state = self.state.write().await;
145
146 if state.is_recording {
147 return Err(ContextError::Internal(
148 "Tracing is already active".to_string(),
149 ));
150 }
151
152 let session_ids = self.get_session_ids().await;
154 if session_ids.is_empty() {
155 return Err(ContextError::Internal(
156 "Cannot start tracing: no pages in context. Create a page first.".to_string(),
157 ));
158 }
159
160 info!(
161 screenshots = options.screenshots,
162 snapshots = options.snapshots,
163 "Starting trace"
164 );
165
166 let categories = [
168 "devtools.timeline",
169 "disabled-by-default-devtools.timeline",
170 "disabled-by-default-devtools.timeline.frame",
171 ];
172
173 for session_id in session_ids {
175 let params = cdp_tracing::StartParams {
176 categories: Some(categories.join(",")),
177 transfer_mode: Some(cdp_tracing::TransferMode::ReturnAsStream),
178 ..Default::default()
179 };
180
181 self.connection
182 .send_command::<_, serde_json::Value>(
183 "Tracing.start",
184 Some(params),
185 Some(&session_id),
186 )
187 .await?;
188
189 self.connection
191 .send_command::<_, serde_json::Value>(
192 "Network.enable",
193 Some(serde_json::json!({})),
194 Some(&session_id),
195 )
196 .await?;
197 }
198
199 state.is_recording = true;
201 state.options = options;
202 state.actions.clear();
203 state.events.clear();
204 state.screenshots.clear();
205 state.snapshots.clear();
206 state.pending_requests.clear();
207 state.network_entries.clear();
208 state.har_pages.clear();
209 state.source_files.clear();
210
211 drop(state); network::start_network_listener(
214 self.connection.clone(),
215 self.state.clone(),
216 self.pages.clone(),
217 );
218
219 Ok(())
220 }
221
222 #[instrument(level = "info", skip(self), fields(path = %path.as_ref().display()))]
239 pub async fn stop(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
240 let path = path.as_ref();
241 let mut state = self.state.write().await;
242
243 if !state.is_recording {
244 return Err(ContextError::Internal("Tracing is not active".to_string()));
245 }
246
247 info!("Stopping trace and saving");
248
249 for session_id in self.get_session_ids().await {
251 let _ = self
252 .connection
253 .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
254 .await;
255 }
256
257 state.is_recording = false;
258
259 writer::write_trace_zip(path, &state)?;
261
262 Ok(())
263 }
264
265 #[instrument(level = "info", skip(self))]
273 pub async fn stop_discard(&self) -> Result<(), ContextError> {
274 let mut state = self.state.write().await;
275
276 if !state.is_recording {
277 return Err(ContextError::Internal("Tracing is not active".to_string()));
278 }
279
280 info!("Stopping trace and discarding");
281
282 for session_id in self.get_session_ids().await {
284 let _ = self
285 .connection
286 .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
287 .await;
288 }
289
290 state.is_recording = false;
292 state.actions.clear();
293 state.events.clear();
294 state.screenshots.clear();
295 state.snapshots.clear();
296 state.pending_requests.clear();
297 state.network_entries.clear();
298 state.har_pages.clear();
299 state.source_files.clear();
300
301 Ok(())
302 }
303
304 #[instrument(level = "debug", skip(self))]
313 pub async fn start_chunk(&self) -> Result<(), ContextError> {
314 let state = self.state.read().await;
315
316 if !state.is_recording {
317 return Err(ContextError::Internal("Tracing is not active".to_string()));
318 }
319
320 debug!("Starting new trace chunk");
321
322 Ok(())
326 }
327
328 #[instrument(level = "debug", skip(self), fields(path = %path.as_ref().display()))]
334 pub async fn stop_chunk(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
335 let path = path.as_ref();
336 let state = self.state.read().await;
337
338 if !state.is_recording {
339 return Err(ContextError::Internal("Tracing is not active".to_string()));
340 }
341
342 debug!("Stopping trace chunk and saving");
343
344 writer::write_trace_zip(path, &state)?;
346
347 Ok(())
351 }
352
353 pub async fn is_recording(&self) -> bool {
355 self.state.read().await.is_recording
356 }
357
358 pub async fn add_source_file(&self, path: impl Into<String>, content: impl Into<String>) {
371 let mut state = self.state.write().await;
372 state.source_files.push(SourceFileEntry {
373 path: path.into(),
374 content: content.into(),
375 });
376 }
377
378 pub async fn collect_sources(
391 &self,
392 dir: impl AsRef<std::path::Path>,
393 extensions: &[&str],
394 ) -> Result<(), ContextError> {
395 let files = sources::collect_sources_from_dir(dir.as_ref(), extensions)?;
396
397 let mut state = self.state.write().await;
398 for (path, content) in files {
399 state.source_files.push(SourceFileEntry { path, content });
400 }
401
402 Ok(())
403 }
404
405 pub(crate) async fn record_action(
409 &self,
410 action_type: &str,
411 selector: Option<&str>,
412 page_id: Option<&str>,
413 ) -> ActionHandle<'_> {
414 let start_time = std::time::SystemTime::now()
415 .duration_since(std::time::UNIX_EPOCH)
416 .unwrap_or_default()
417 .as_secs_f64()
418 * 1000.0;
419
420 let action = ActionEntry {
421 action_type: action_type.to_string(),
422 selector: selector.map(ToString::to_string),
423 page_id: page_id.map(ToString::to_string),
424 start_time,
425 end_time: None,
426 result: None,
427 value: None,
428 url: None,
429 screenshot: None,
430 snapshot: None,
431 };
432
433 let mut state = self.state.write().await;
434 let index = state.actions.len();
435 state.actions.push(action);
436
437 ActionHandle {
438 tracing: self,
439 index,
440 }
441 }
442
443 pub(crate) async fn record_page(&self, page_id: &str, title: &str) {
445 let mut state = self.state.write().await;
446 let started_date_time = Utc::now().to_rfc3339();
447 let page = HarPage::new(page_id, title, &started_date_time);
448 state.har_pages.push(page);
449 state.current_page_id = Some(page_id.to_string());
450 }
451
452 pub(crate) async fn capture_screenshot(
454 &self,
455 session_id: &str,
456 name: Option<&str>,
457 ) -> Result<(), ContextError> {
458 capture::capture_screenshot(&self.connection, &self.state, session_id, name).await
459 }
460
461 pub(crate) async fn capture_dom_snapshot(
463 &self,
464 session_id: &str,
465 ) -> Result<(), ContextError> {
466 capture::capture_dom_snapshot(&self.connection, &self.state, session_id).await
467 }
468
469 pub(crate) async fn capture_action_context(
471 &self,
472 session_id: &str,
473 action_name: Option<&str>,
474 ) -> Result<(), ContextError> {
475 capture::capture_action_context(&self.connection, &self.state, session_id, action_name).await
476 }
477}
478
479pub struct ActionHandle<'a> {
481 tracing: &'a Tracing,
482 index: usize,
483}
484
485impl ActionHandle<'_> {
486 pub async fn complete(self, result: Option<serde_json::Value>) {
488 let end_time = std::time::SystemTime::now()
489 .duration_since(std::time::UNIX_EPOCH)
490 .unwrap_or_default()
491 .as_secs_f64()
492 * 1000.0;
493
494 let mut state = self.tracing.state.write().await;
495 if let Some(action) = state.actions.get_mut(self.index) {
496 action.end_time = Some(end_time);
497 action.result = result;
498 }
499 }
500
501 pub async fn fail(self, error: &str) {
503 let end_time = std::time::SystemTime::now()
504 .duration_since(std::time::UNIX_EPOCH)
505 .unwrap_or_default()
506 .as_secs_f64()
507 * 1000.0;
508
509 let mut state = self.tracing.state.write().await;
510 if let Some(action) = state.actions.get_mut(self.index) {
511 action.end_time = Some(end_time);
512 action.result = Some(serde_json::json!({ "error": error }));
513 }
514 }
515}