Skip to main content

jugar_probar/
renacer_integration.rs

1//! Renacer Integration for Probar (Issue #9)
2//!
3//! Deep WASM tracing integration with renacer for full E2E observability.
4//! Enables trace context propagation across JS→WASM boundaries.
5//!
6//! ## Features
7//!
8//! - W3C Trace Context propagation to browser
9//! - Unified trace output (browser + WASM spans)
10//! - Chrome trace format export (chrome://tracing compatible)
11//! - Correlation of console messages with trace spans
12//!
13//! ## Usage
14//!
15//! ```ignore
16//! use probar::{Browser, BrowserConfig, TracingConfig};
17//!
18//! let config = BrowserConfig::default()
19//!     .with_tracing(TracingConfig::default());
20//!
21//! let browser = Browser::launch(config).await?;
22//! let mut page = browser.new_page().await?;
23//!
24//! // Trace context is automatically injected
25//! page.goto("http://localhost:8080").await?;
26//!
27//! // Export trace in Chrome format
28//! let trace = page.export_chrome_trace().await?;
29//! std::fs::write("trace.json", trace)?;
30//! ```
31
32use std::time::{SystemTime, UNIX_EPOCH};
33
34/// Tracing configuration for renacer integration
35#[derive(Debug, Clone)]
36pub struct TracingConfig {
37    /// Enable tracing (default: true)
38    pub enabled: bool,
39    /// Service name for traces
40    pub service_name: String,
41    /// Generate trace ID (default: random)
42    pub trace_id: Option<String>,
43    /// Sample rate (0.0 - 1.0, default: 1.0)
44    pub sample_rate: f64,
45    /// Include console messages in trace
46    pub capture_console: bool,
47    /// Include network requests in trace
48    pub capture_network: bool,
49    /// Include user interactions in trace
50    pub capture_interactions: bool,
51}
52
53impl Default for TracingConfig {
54    fn default() -> Self {
55        Self {
56            enabled: true,
57            service_name: "probar-browser-test".to_string(),
58            trace_id: None,
59            sample_rate: 1.0,
60            capture_console: true,
61            capture_network: true,
62            capture_interactions: true,
63        }
64    }
65}
66
67impl TracingConfig {
68    /// Create a new tracing configuration
69    #[must_use]
70    pub fn new(service_name: impl Into<String>) -> Self {
71        Self {
72            service_name: service_name.into(),
73            ..Default::default()
74        }
75    }
76
77    /// Disable tracing
78    #[must_use]
79    pub const fn disabled() -> Self {
80        Self {
81            enabled: false,
82            service_name: String::new(),
83            trace_id: None,
84            sample_rate: 0.0,
85            capture_console: false,
86            capture_network: false,
87            capture_interactions: false,
88        }
89    }
90
91    /// Set service name
92    #[must_use]
93    pub fn with_service_name(mut self, name: impl Into<String>) -> Self {
94        self.service_name = name.into();
95        self
96    }
97
98    /// Set trace ID
99    #[must_use]
100    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
101        self.trace_id = Some(id.into());
102        self
103    }
104
105    /// Set sample rate
106    #[must_use]
107    pub fn with_sample_rate(mut self, rate: f64) -> Self {
108        self.sample_rate = rate.clamp(0.0, 1.0);
109        self
110    }
111
112    /// Enable/disable console capture
113    #[must_use]
114    pub const fn with_console_capture(mut self, enabled: bool) -> Self {
115        self.capture_console = enabled;
116        self
117    }
118
119    /// Enable/disable network capture
120    #[must_use]
121    pub const fn with_network_capture(mut self, enabled: bool) -> Self {
122        self.capture_network = enabled;
123        self
124    }
125}
126
127/// W3C Trace Context for distributed tracing
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct TraceContext {
130    /// 128-bit trace ID (32 hex chars)
131    pub trace_id: String,
132    /// 64-bit parent span ID (16 hex chars)
133    pub parent_id: String,
134    /// Trace flags (sampled = 01)
135    pub flags: u8,
136}
137
138impl TraceContext {
139    /// Generate a new trace context with random IDs
140    #[must_use]
141    pub fn new() -> Self {
142        let trace_id = generate_trace_id();
143        let parent_id = generate_span_id();
144        Self {
145            trace_id,
146            parent_id,
147            flags: 0x01, // sampled
148        }
149    }
150
151    /// Create from existing trace ID
152    #[must_use]
153    pub fn with_trace_id(trace_id: impl Into<String>) -> Self {
154        Self {
155            trace_id: trace_id.into(),
156            parent_id: generate_span_id(),
157            flags: 0x01,
158        }
159    }
160
161    /// Parse from W3C traceparent header
162    ///
163    /// Format: "00-{trace_id}-{parent_id}-{flags}"
164    pub fn parse(traceparent: &str) -> Option<Self> {
165        let parts: Vec<&str> = traceparent.split('-').collect();
166        if parts.len() != 4 {
167            return None;
168        }
169
170        // Validate version
171        if parts[0] != "00" {
172            return None;
173        }
174
175        // Validate trace_id length
176        if parts[1].len() != 32 {
177            return None;
178        }
179
180        // Validate parent_id length
181        if parts[2].len() != 16 {
182            return None;
183        }
184
185        // Parse flags
186        let flags = u8::from_str_radix(parts[3], 16).ok()?;
187
188        Some(Self {
189            trace_id: parts[1].to_string(),
190            parent_id: parts[2].to_string(),
191            flags,
192        })
193    }
194
195    /// Format as W3C traceparent header
196    #[must_use]
197    pub fn to_traceparent(&self) -> String {
198        format!("00-{}-{}-{:02x}", self.trace_id, self.parent_id, self.flags)
199    }
200
201    /// Generate a new child span ID
202    #[must_use]
203    pub fn child(&self) -> Self {
204        Self {
205            trace_id: self.trace_id.clone(),
206            parent_id: generate_span_id(),
207            flags: self.flags,
208        }
209    }
210
211    /// Check if sampled
212    #[must_use]
213    pub const fn is_sampled(&self) -> bool {
214        self.flags & 0x01 != 0
215    }
216}
217
218impl Default for TraceContext {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl std::fmt::Display for TraceContext {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "{}", self.to_traceparent())
227    }
228}
229
230/// A trace span representing a unit of work
231#[derive(Debug, Clone)]
232pub struct TraceSpan {
233    /// Span name
234    pub name: String,
235    /// Trace context
236    pub context: TraceContext,
237    /// Start timestamp (microseconds since epoch)
238    pub start_us: u64,
239    /// End timestamp (microseconds since epoch)
240    pub end_us: Option<u64>,
241    /// Span category (e.g., "browser", "wasm", "network")
242    pub category: String,
243    /// Span attributes
244    pub attributes: Vec<(String, String)>,
245    /// Child spans
246    pub children: Vec<TraceSpan>,
247}
248
249impl TraceSpan {
250    /// Create a new trace span
251    #[must_use]
252    pub fn new(name: impl Into<String>, category: impl Into<String>) -> Self {
253        Self {
254            name: name.into(),
255            context: TraceContext::new(),
256            start_us: now_micros(),
257            end_us: None,
258            category: category.into(),
259            attributes: Vec::new(),
260            children: Vec::new(),
261        }
262    }
263
264    /// Create with existing context
265    #[must_use]
266    pub fn with_context(
267        name: impl Into<String>,
268        category: impl Into<String>,
269        context: TraceContext,
270    ) -> Self {
271        Self {
272            name: name.into(),
273            context,
274            start_us: now_micros(),
275            end_us: None,
276            category: category.into(),
277            attributes: Vec::new(),
278            children: Vec::new(),
279        }
280    }
281
282    /// Add an attribute
283    pub fn add_attribute(&mut self, key: impl Into<String>, value: impl Into<String>) {
284        self.attributes.push((key.into(), value.into()));
285    }
286
287    /// End the span
288    pub fn end(&mut self) {
289        if self.end_us.is_none() {
290            self.end_us = Some(now_micros());
291        }
292    }
293
294    /// Get duration in microseconds
295    #[must_use]
296    pub fn duration_us(&self) -> Option<u64> {
297        self.end_us.map(|end| end.saturating_sub(self.start_us))
298    }
299
300    /// Add a child span
301    pub fn add_child(&mut self, child: TraceSpan) {
302        self.children.push(child);
303    }
304}
305
306/// Chrome trace event format (for chrome://tracing)
307#[derive(Debug, Clone, serde::Serialize)]
308pub struct ChromeTraceEvent {
309    /// Event name
310    pub name: String,
311    /// Category
312    pub cat: String,
313    /// Phase: B (begin), E (end), X (complete), I (instant)
314    pub ph: String,
315    /// Timestamp in microseconds
316    pub ts: u64,
317    /// Duration in microseconds (for X phase)
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub dur: Option<u64>,
320    /// Process ID
321    pub pid: u32,
322    /// Thread ID
323    pub tid: u32,
324    /// Arguments
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub args: Option<serde_json::Value>,
327}
328
329impl ChromeTraceEvent {
330    /// Create a complete (X) event from a span
331    pub fn from_span(span: &TraceSpan, pid: u32, tid: u32) -> Self {
332        let args = if span.attributes.is_empty() {
333            None
334        } else {
335            let map: serde_json::Map<String, serde_json::Value> = span
336                .attributes
337                .iter()
338                .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
339                .collect();
340            Some(serde_json::Value::Object(map))
341        };
342
343        Self {
344            name: span.name.clone(),
345            cat: span.category.clone(),
346            ph: "X".to_string(),
347            ts: span.start_us,
348            dur: span.duration_us(),
349            pid,
350            tid,
351            args,
352        }
353    }
354
355    /// Create an instant (I) event
356    pub fn instant(
357        name: impl Into<String>,
358        category: impl Into<String>,
359        ts: u64,
360        pid: u32,
361        tid: u32,
362    ) -> Self {
363        Self {
364            name: name.into(),
365            cat: category.into(),
366            ph: "I".to_string(),
367            ts,
368            dur: None,
369            pid,
370            tid,
371            args: None,
372        }
373    }
374}
375
376/// Chrome trace format output
377#[derive(Debug, serde::Serialize)]
378pub struct ChromeTrace {
379    /// Trace events
380    #[serde(rename = "traceEvents")]
381    pub trace_events: Vec<ChromeTraceEvent>,
382    /// Metadata
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub metadata: Option<serde_json::Value>,
385}
386
387impl ChromeTrace {
388    /// Create an empty trace
389    #[must_use]
390    pub fn new() -> Self {
391        Self {
392            trace_events: Vec::new(),
393            metadata: None,
394        }
395    }
396
397    /// Add a span and its children recursively
398    pub fn add_span(&mut self, span: &TraceSpan, pid: u32, tid: u32) {
399        self.trace_events
400            .push(ChromeTraceEvent::from_span(span, pid, tid));
401
402        for child in &span.children {
403            self.add_span(child, pid, tid + 1);
404        }
405    }
406
407    /// Add an instant event
408    pub fn add_instant(
409        &mut self,
410        name: impl Into<String>,
411        category: impl Into<String>,
412        ts: u64,
413        pid: u32,
414        tid: u32,
415    ) {
416        self.trace_events
417            .push(ChromeTraceEvent::instant(name, category, ts, pid, tid));
418    }
419
420    /// Set metadata
421    pub fn set_metadata(&mut self, metadata: serde_json::Value) {
422        self.metadata = Some(metadata);
423    }
424
425    /// Export as JSON string
426    ///
427    /// # Errors
428    ///
429    /// Returns error if serialization fails
430    pub fn to_json(&self) -> Result<String, serde_json::Error> {
431        serde_json::to_string_pretty(self)
432    }
433
434    /// Export as JSON bytes
435    ///
436    /// # Errors
437    ///
438    /// Returns error if serialization fails
439    pub fn to_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
440        serde_json::to_vec_pretty(self)
441    }
442}
443
444impl Default for ChromeTrace {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450/// Unified trace collector for browser tests
451#[derive(Debug, Default)]
452pub struct TraceCollector {
453    /// Root trace context
454    pub root_context: Option<TraceContext>,
455    /// Collected spans
456    pub spans: Vec<TraceSpan>,
457    /// Console message timestamps (for correlation)
458    pub console_timestamps: Vec<(u64, String)>,
459    /// Service name
460    pub service_name: String,
461}
462
463impl TraceCollector {
464    /// Create a new trace collector
465    #[must_use]
466    pub fn new(service_name: impl Into<String>) -> Self {
467        let root_context = TraceContext::new();
468        Self {
469            root_context: Some(root_context),
470            spans: Vec::new(),
471            console_timestamps: Vec::new(),
472            service_name: service_name.into(),
473        }
474    }
475
476    /// Start a new span
477    pub fn start_span(
478        &mut self,
479        name: impl Into<String>,
480        category: impl Into<String>,
481    ) -> TraceSpan {
482        let context = self
483            .root_context
484            .as_ref()
485            .map(TraceContext::child)
486            .unwrap_or_default();
487
488        TraceSpan::with_context(name, category, context)
489    }
490
491    /// Record a completed span
492    pub fn record_span(&mut self, span: TraceSpan) {
493        self.spans.push(span);
494    }
495
496    /// Record a console message
497    pub fn record_console(&mut self, message: impl Into<String>) {
498        self.console_timestamps.push((now_micros(), message.into()));
499    }
500
501    /// Get the traceparent header value
502    #[must_use]
503    pub fn traceparent(&self) -> Option<String> {
504        self.root_context.as_ref().map(TraceContext::to_traceparent)
505    }
506
507    /// Export as Chrome trace format
508    #[must_use]
509    pub fn to_chrome_trace(&self) -> ChromeTrace {
510        let mut trace = ChromeTrace::new();
511        let pid = 1;
512
513        // Add all spans
514        for (tid, span) in self.spans.iter().enumerate() {
515            trace.add_span(span, pid, tid as u32);
516        }
517
518        // Add console messages as instant events
519        for (ts, msg) in &self.console_timestamps {
520            trace.add_instant(msg.clone(), "console", *ts, pid, 0);
521        }
522
523        // Add metadata
524        let metadata = serde_json::json!({
525            "service_name": self.service_name,
526            "trace_id": self.root_context.as_ref().map(|c| &c.trace_id),
527        });
528        trace.set_metadata(metadata);
529
530        trace
531    }
532}
533
534// ============================================================================
535// Helper Functions
536// ============================================================================
537
538/// Generate a random 128-bit trace ID (32 hex chars)
539fn generate_trace_id() -> String {
540    use std::collections::hash_map::RandomState;
541    use std::hash::{BuildHasher, Hasher};
542
543    let hasher = RandomState::new();
544    let mut h1 = hasher.build_hasher();
545    let mut h2 = hasher.build_hasher();
546
547    h1.write_u64(now_micros());
548    h2.write_u64(std::process::id() as u64);
549
550    format!("{:016x}{:016x}", h1.finish(), h2.finish())
551}
552
553/// Generate a random 64-bit span ID (16 hex chars)
554fn generate_span_id() -> String {
555    use std::collections::hash_map::RandomState;
556    use std::hash::{BuildHasher, Hasher};
557
558    let hasher = RandomState::new();
559    let mut h = hasher.build_hasher();
560    h.write_u64(now_micros());
561    h.write_u32(std::process::id());
562
563    format!("{:016x}", h.finish())
564}
565
566/// Get current time in microseconds since epoch
567fn now_micros() -> u64 {
568    SystemTime::now()
569        .duration_since(UNIX_EPOCH)
570        .map(|d| d.as_micros() as u64)
571        .unwrap_or(0)
572}
573
574// ============================================================================
575// Tests
576// ============================================================================
577
578#[cfg(test)]
579#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
580mod tests {
581    use super::*;
582
583    mod trace_context_tests {
584        use super::*;
585
586        #[test]
587        fn test_new() {
588            let ctx = TraceContext::new();
589            assert_eq!(ctx.trace_id.len(), 32);
590            assert_eq!(ctx.parent_id.len(), 16);
591            assert!(ctx.is_sampled());
592        }
593
594        #[test]
595        fn test_parse_valid() {
596            let traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
597            let ctx = TraceContext::parse(traceparent).unwrap();
598            assert_eq!(ctx.trace_id, "0af7651916cd43dd8448eb211c80319c");
599            assert_eq!(ctx.parent_id, "b7ad6b7169203331");
600            assert_eq!(ctx.flags, 1);
601        }
602
603        #[test]
604        fn test_parse_invalid_parts() {
605            assert!(TraceContext::parse("00-abc").is_none());
606        }
607
608        #[test]
609        fn test_parse_invalid_version() {
610            let traceparent = "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
611            assert!(TraceContext::parse(traceparent).is_none());
612        }
613
614        #[test]
615        fn test_parse_invalid_trace_id_length() {
616            let traceparent = "00-abc-b7ad6b7169203331-01";
617            assert!(TraceContext::parse(traceparent).is_none());
618        }
619
620        #[test]
621        fn test_to_traceparent() {
622            let ctx = TraceContext {
623                trace_id: "0af7651916cd43dd8448eb211c80319c".to_string(),
624                parent_id: "b7ad6b7169203331".to_string(),
625                flags: 1,
626            };
627            assert_eq!(
628                ctx.to_traceparent(),
629                "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
630            );
631        }
632
633        #[test]
634        fn test_child() {
635            let ctx = TraceContext::new();
636            let child = ctx.child();
637            assert_eq!(child.trace_id, ctx.trace_id);
638            assert_ne!(child.parent_id, ctx.parent_id);
639        }
640
641        #[test]
642        fn test_display() {
643            let ctx = TraceContext::new();
644            let s = format!("{ctx}");
645            assert!(s.starts_with("00-"));
646        }
647    }
648
649    mod tracing_config_tests {
650        use super::*;
651
652        #[test]
653        fn test_default() {
654            let config = TracingConfig::default();
655            assert!(config.enabled);
656            assert_eq!(config.sample_rate, 1.0);
657            assert!(config.capture_console);
658        }
659
660        #[test]
661        fn test_disabled() {
662            let config = TracingConfig::disabled();
663            assert!(!config.enabled);
664            assert_eq!(config.sample_rate, 0.0);
665        }
666
667        #[test]
668        fn test_builder() {
669            let config = TracingConfig::new("my-service")
670                .with_sample_rate(0.5)
671                .with_console_capture(false);
672            assert_eq!(config.service_name, "my-service");
673            assert!((config.sample_rate - 0.5).abs() < f64::EPSILON);
674            assert!(!config.capture_console);
675        }
676
677        #[test]
678        fn test_sample_rate_clamped() {
679            let config = TracingConfig::default().with_sample_rate(2.0);
680            assert!((config.sample_rate - 1.0).abs() < f64::EPSILON);
681
682            let config = TracingConfig::default().with_sample_rate(-1.0);
683            assert!((config.sample_rate - 0.0).abs() < f64::EPSILON);
684        }
685    }
686
687    mod trace_span_tests {
688        use super::*;
689
690        #[test]
691        fn test_new() {
692            let span = TraceSpan::new("test-span", "test");
693            assert_eq!(span.name, "test-span");
694            assert_eq!(span.category, "test");
695            assert!(span.end_us.is_none());
696        }
697
698        #[test]
699        fn test_add_attribute() {
700            let mut span = TraceSpan::new("test", "test");
701            span.add_attribute("key", "value");
702            assert_eq!(span.attributes.len(), 1);
703            assert_eq!(span.attributes[0], ("key".to_string(), "value".to_string()));
704        }
705
706        #[test]
707        fn test_end() {
708            let mut span = TraceSpan::new("test", "test");
709            std::thread::sleep(std::time::Duration::from_millis(1));
710            span.end();
711            assert!(span.end_us.is_some());
712            assert!(span.duration_us().unwrap() > 0);
713        }
714
715        #[test]
716        fn test_duration_before_end() {
717            let span = TraceSpan::new("test", "test");
718            assert!(span.duration_us().is_none());
719        }
720    }
721
722    mod chrome_trace_tests {
723        use super::*;
724
725        #[test]
726        fn test_new() {
727            let trace = ChromeTrace::new();
728            assert!(trace.trace_events.is_empty());
729            assert!(trace.metadata.is_none());
730        }
731
732        #[test]
733        fn test_add_span() {
734            let mut trace = ChromeTrace::new();
735            let mut span = TraceSpan::new("test-span", "test");
736            span.end();
737            trace.add_span(&span, 1, 1);
738            assert_eq!(trace.trace_events.len(), 1);
739            assert_eq!(trace.trace_events[0].name, "test-span");
740        }
741
742        #[test]
743        fn test_add_instant() {
744            let mut trace = ChromeTrace::new();
745            trace.add_instant("instant-event", "test", 1000, 1, 1);
746            assert_eq!(trace.trace_events.len(), 1);
747            assert_eq!(trace.trace_events[0].ph, "I");
748        }
749
750        #[test]
751        fn test_to_json() {
752            let mut trace = ChromeTrace::new();
753            trace.add_instant("test", "test", 0, 1, 1);
754            let json = trace.to_json().unwrap();
755            assert!(json.contains("traceEvents"));
756            assert!(json.contains("test"));
757        }
758
759        #[test]
760        fn test_nested_spans() {
761            let mut trace = ChromeTrace::new();
762            let mut parent = TraceSpan::new("parent", "test");
763            let mut child = TraceSpan::new("child", "test");
764            child.end();
765            parent.add_child(child);
766            parent.end();
767            trace.add_span(&parent, 1, 1);
768            assert_eq!(trace.trace_events.len(), 2);
769        }
770    }
771
772    mod trace_collector_tests {
773        use super::*;
774
775        #[test]
776        fn test_new() {
777            let collector = TraceCollector::new("test-service");
778            assert!(collector.root_context.is_some());
779            assert_eq!(collector.service_name, "test-service");
780        }
781
782        #[test]
783        fn test_start_span() {
784            let mut collector = TraceCollector::new("test");
785            let span = collector.start_span("test-span", "browser");
786            assert_eq!(span.name, "test-span");
787            assert_eq!(span.category, "browser");
788        }
789
790        #[test]
791        fn test_record_span() {
792            let mut collector = TraceCollector::new("test");
793            let mut span = collector.start_span("test", "test");
794            span.end();
795            collector.record_span(span);
796            assert_eq!(collector.spans.len(), 1);
797        }
798
799        #[test]
800        fn test_record_console() {
801            let mut collector = TraceCollector::new("test");
802            collector.record_console("test message");
803            assert_eq!(collector.console_timestamps.len(), 1);
804        }
805
806        #[test]
807        fn test_traceparent() {
808            let collector = TraceCollector::new("test");
809            let traceparent = collector.traceparent().unwrap();
810            assert!(traceparent.starts_with("00-"));
811        }
812
813        #[test]
814        fn test_to_chrome_trace() {
815            let mut collector = TraceCollector::new("test-service");
816            let mut span = collector.start_span("test-span", "browser");
817            span.end();
818            collector.record_span(span);
819            collector.record_console("console message");
820
821            let chrome_trace = collector.to_chrome_trace();
822            assert_eq!(chrome_trace.trace_events.len(), 2); // 1 span + 1 console
823        }
824    }
825}