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::CdpConnection;
22use viewpoint_cdp::protocol::tracing as cdp_tracing;
23
24use crate::context::PageInfo;
25use crate::error::ContextError;
26use crate::network::har::HarPage;
27
28use types::SourceFileEntry;
29pub(crate) use types::TracingState;
30pub use types::{ActionEntry, TracingOptions};
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))]
149 pub async fn start(&self, options: TracingOptions) -> Result<(), ContextError> {
150 let mut state = self.state.write().await;
151
152 if state.is_recording {
153 return Err(ContextError::Internal(
154 "Tracing is already active".to_string(),
155 ));
156 }
157
158 let session_ids = self.get_session_ids().await;
160 if session_ids.is_empty() {
161 return Err(ContextError::Internal(
162 "Cannot start tracing: no pages in context. Create a page first.".to_string(),
163 ));
164 }
165
166 info!(
167 screenshots = options.screenshots,
168 snapshots = options.snapshots,
169 "Starting trace"
170 );
171
172 let categories = [
174 "devtools.timeline",
175 "disabled-by-default-devtools.timeline",
176 "disabled-by-default-devtools.timeline.frame",
177 ];
178
179 for session_id in session_ids {
181 let params = cdp_tracing::StartParams {
182 categories: Some(categories.join(",")),
183 transfer_mode: Some(cdp_tracing::TransferMode::ReturnAsStream),
184 ..Default::default()
185 };
186
187 self.connection
188 .send_command::<_, serde_json::Value>(
189 "Tracing.start",
190 Some(params),
191 Some(&session_id),
192 )
193 .await?;
194
195 self.connection
197 .send_command::<_, serde_json::Value>(
198 "Network.enable",
199 Some(serde_json::json!({})),
200 Some(&session_id),
201 )
202 .await?;
203 }
204
205 state.is_recording = true;
207 state.options = options;
208 state.actions.clear();
209 state.events.clear();
210 state.screenshots.clear();
211 state.snapshots.clear();
212 state.pending_requests.clear();
213 state.network_entries.clear();
214 state.har_pages.clear();
215 state.source_files.clear();
216
217 drop(state); network::start_network_listener(
220 self.connection.clone(),
221 self.state.clone(),
222 self.pages.clone(),
223 );
224
225 Ok(())
226 }
227
228 #[instrument(level = "info", skip(self), fields(path = %path.as_ref().display()))]
251 pub async fn stop(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
252 let path = path.as_ref();
253 let mut state = self.state.write().await;
254
255 if !state.is_recording {
256 return Err(ContextError::Internal("Tracing is not active".to_string()));
257 }
258
259 info!("Stopping trace and saving");
260
261 for session_id in self.get_session_ids().await {
263 let _ = self
264 .connection
265 .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
266 .await;
267 }
268
269 state.is_recording = false;
270
271 writer::write_trace_zip(path, &state)?;
273
274 Ok(())
275 }
276
277 #[instrument(level = "info", skip(self))]
285 pub async fn stop_discard(&self) -> Result<(), ContextError> {
286 let mut state = self.state.write().await;
287
288 if !state.is_recording {
289 return Err(ContextError::Internal("Tracing is not active".to_string()));
290 }
291
292 info!("Stopping trace and discarding");
293
294 for session_id in self.get_session_ids().await {
296 let _ = self
297 .connection
298 .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
299 .await;
300 }
301
302 state.is_recording = false;
304 state.actions.clear();
305 state.events.clear();
306 state.screenshots.clear();
307 state.snapshots.clear();
308 state.pending_requests.clear();
309 state.network_entries.clear();
310 state.har_pages.clear();
311 state.source_files.clear();
312
313 Ok(())
314 }
315
316 #[instrument(level = "debug", skip(self))]
325 pub async fn start_chunk(&self) -> Result<(), ContextError> {
326 let state = self.state.read().await;
327
328 if !state.is_recording {
329 return Err(ContextError::Internal("Tracing is not active".to_string()));
330 }
331
332 debug!("Starting new trace chunk");
333
334 Ok(())
338 }
339
340 #[instrument(level = "debug", skip(self), fields(path = %path.as_ref().display()))]
346 pub async fn stop_chunk(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
347 let path = path.as_ref();
348 let state = self.state.read().await;
349
350 if !state.is_recording {
351 return Err(ContextError::Internal("Tracing is not active".to_string()));
352 }
353
354 debug!("Stopping trace chunk and saving");
355
356 writer::write_trace_zip(path, &state)?;
358
359 Ok(())
363 }
364
365 pub async fn is_recording(&self) -> bool {
367 self.state.read().await.is_recording
368 }
369
370 pub async fn add_source_file(&self, path: impl Into<String>, content: impl Into<String>) {
389 let mut state = self.state.write().await;
390 state.source_files.push(SourceFileEntry {
391 path: path.into(),
392 content: content.into(),
393 });
394 }
395
396 pub async fn collect_sources(
409 &self,
410 dir: impl AsRef<std::path::Path>,
411 extensions: &[&str],
412 ) -> Result<(), ContextError> {
413 let files = sources::collect_sources_from_dir(dir.as_ref(), extensions)?;
414
415 let mut state = self.state.write().await;
416 for (path, content) in files {
417 state.source_files.push(SourceFileEntry { path, content });
418 }
419
420 Ok(())
421 }
422
423 pub(crate) async fn record_action(
427 &self,
428 action_type: &str,
429 selector: Option<&str>,
430 page_id: Option<&str>,
431 ) -> ActionHandle<'_> {
432 let start_time = std::time::SystemTime::now()
433 .duration_since(std::time::UNIX_EPOCH)
434 .unwrap_or_default()
435 .as_secs_f64()
436 * 1000.0;
437
438 let action = ActionEntry {
439 action_type: action_type.to_string(),
440 selector: selector.map(ToString::to_string),
441 page_id: page_id.map(ToString::to_string),
442 start_time,
443 end_time: None,
444 result: None,
445 value: None,
446 url: None,
447 screenshot: None,
448 snapshot: None,
449 };
450
451 let mut state = self.state.write().await;
452 let index = state.actions.len();
453 state.actions.push(action);
454
455 ActionHandle {
456 tracing: self,
457 index,
458 }
459 }
460
461 pub(crate) async fn record_page(&self, page_id: &str, title: &str) {
463 let mut state = self.state.write().await;
464 let started_date_time = Utc::now().to_rfc3339();
465 let page = HarPage::new(page_id, title, &started_date_time);
466 state.har_pages.push(page);
467 state.current_page_id = Some(page_id.to_string());
468 }
469
470 pub(crate) async fn capture_screenshot(
472 &self,
473 session_id: &str,
474 name: Option<&str>,
475 ) -> Result<(), ContextError> {
476 capture::capture_screenshot(&self.connection, &self.state, session_id, name).await
477 }
478
479 pub(crate) async fn capture_dom_snapshot(&self, session_id: &str) -> Result<(), ContextError> {
481 capture::capture_dom_snapshot(&self.connection, &self.state, session_id).await
482 }
483
484 pub(crate) async fn capture_action_context(
486 &self,
487 session_id: &str,
488 action_name: Option<&str>,
489 ) -> Result<(), ContextError> {
490 capture::capture_action_context(&self.connection, &self.state, session_id, action_name)
491 .await
492 }
493}
494
495pub struct ActionHandle<'a> {
497 tracing: &'a Tracing,
498 index: usize,
499}
500
501impl ActionHandle<'_> {
502 pub async fn complete(self, result: Option<serde_json::Value>) {
504 let end_time = std::time::SystemTime::now()
505 .duration_since(std::time::UNIX_EPOCH)
506 .unwrap_or_default()
507 .as_secs_f64()
508 * 1000.0;
509
510 let mut state = self.tracing.state.write().await;
511 if let Some(action) = state.actions.get_mut(self.index) {
512 action.end_time = Some(end_time);
513 action.result = result;
514 }
515 }
516
517 pub async fn fail(self, error: &str) {
519 let end_time = std::time::SystemTime::now()
520 .duration_since(std::time::UNIX_EPOCH)
521 .unwrap_or_default()
522 .as_secs_f64()
523 * 1000.0;
524
525 let mut state = self.tracing.state.write().await;
526 if let Some(action) = state.actions.get_mut(self.index) {
527 action.end_time = Some(end_time);
528 action.result = Some(serde_json::json!({ "error": error }));
529 }
530 }
531}