clnrm_core/otel/stdout_parser.rs
1//! Stdout OTEL span parser
2//!
3//! Parses OpenTelemetry spans from container stdout mixed with other log output.
4//! Supports OTEL stdout exporter format (JSON lines).
5
6use crate::error::{CleanroomError, Result};
7use crate::validation::span_validator::SpanData;
8use serde_json::Value;
9
10/// Parser for extracting OTEL spans from container stdout
11pub struct StdoutSpanParser;
12
13impl StdoutSpanParser {
14 /// Parse OTEL spans from container stdout
15 ///
16 /// This method extracts JSON-formatted OTEL spans from mixed stdout content
17 /// (logs, debug output, etc.). Non-JSON lines are silently ignored.
18 ///
19 /// # Arguments
20 /// * `stdout` - Container stdout containing OTEL spans and other output
21 ///
22 /// # Returns
23 /// * `Result<Vec<SpanData>>` - Extracted spans or error
24 ///
25 /// # Example
26 /// ```rust
27 /// use clnrm_core::otel::stdout_parser::StdoutSpanParser;
28 ///
29 /// let stdout = r#"
30 /// Starting test...
31 /// {"name":"test.span","trace_id":"abc123","span_id":"span1","parent_span_id":null,"attributes":{}}
32 /// Some log output
33 /// {"name":"test.span2","trace_id":"abc123","span_id":"span2","parent_span_id":"span1","attributes":{}}
34 /// Done.
35 /// "#;
36 ///
37 /// let spans = StdoutSpanParser::parse(stdout).unwrap();
38 /// assert_eq!(spans.len(), 2);
39 /// ```
40 pub fn parse(stdout: &str) -> Result<Vec<SpanData>> {
41 let mut spans = Vec::new();
42
43 for (line_num, line) in stdout.lines().enumerate() {
44 let line = line.trim();
45
46 // Skip empty lines
47 if line.is_empty() {
48 continue;
49 }
50
51 // Try to parse as JSON
52 match serde_json::from_str::<Value>(line) {
53 Ok(value) => {
54 // Check if this looks like a span object
55 if Self::is_span_like(&value) {
56 match Self::parse_span(&value) {
57 Ok(span) => spans.push(span),
58 Err(e) => {
59 // Log warning but don't fail - malformed span
60 tracing::warn!(
61 line = line_num + 1,
62 error = %e,
63 "Failed to parse span-like JSON object"
64 );
65 }
66 }
67 }
68 // Otherwise, it's valid JSON but not a span - ignore silently
69 }
70 Err(_) => {
71 // Not JSON - ignore silently (likely a log line)
72 continue;
73 }
74 }
75 }
76
77 Ok(spans)
78 }
79
80 /// Check if a JSON value looks like a span
81 ///
82 /// A span-like object must have at minimum:
83 /// - "name" field (string)
84 /// - "trace_id" field (string)
85 /// - "span_id" field (string)
86 fn is_span_like(value: &Value) -> bool {
87 value.get("name").and_then(|v| v.as_str()).is_some()
88 && value.get("trace_id").and_then(|v| v.as_str()).is_some()
89 && value.get("span_id").and_then(|v| v.as_str()).is_some()
90 }
91
92 /// Parse a single span from JSON value
93 fn parse_span(value: &Value) -> Result<SpanData> {
94 // Extract required fields
95 let name = value
96 .get("name")
97 .and_then(|v| v.as_str())
98 .ok_or_else(|| CleanroomError::validation_error("Span missing required 'name' field"))?
99 .to_string();
100
101 let trace_id = value
102 .get("trace_id")
103 .and_then(|v| v.as_str())
104 .ok_or_else(|| {
105 CleanroomError::validation_error("Span missing required 'trace_id' field")
106 })?
107 .to_string();
108
109 let span_id = value
110 .get("span_id")
111 .and_then(|v| v.as_str())
112 .ok_or_else(|| {
113 CleanroomError::validation_error("Span missing required 'span_id' field")
114 })?
115 .to_string();
116
117 // Extract optional fields
118 let parent_span_id = value
119 .get("parent_span_id")
120 .and_then(|v| v.as_str())
121 .map(|s| s.to_string());
122
123 let start_time_unix_nano = value
124 .get("start_time_unix_nano")
125 .and_then(|v| v.as_str())
126 .and_then(|s| s.parse::<u64>().ok())
127 .or_else(|| value.get("start_time_unix_nano").and_then(|v| v.as_u64()));
128
129 let end_time_unix_nano = value
130 .get("end_time_unix_nano")
131 .and_then(|v| v.as_str())
132 .and_then(|s| s.parse::<u64>().ok())
133 .or_else(|| value.get("end_time_unix_nano").and_then(|v| v.as_u64()));
134
135 // Parse span kind
136 let kind = value
137 .get("kind")
138 .and_then(|v| v.as_str())
139 .and_then(|s| crate::validation::span_validator::SpanKind::parse_kind(s).ok())
140 .or_else(|| {
141 value.get("kind").and_then(|v| v.as_i64()).and_then(|i| {
142 crate::validation::span_validator::SpanKind::from_otel_int(i as i32).ok()
143 })
144 });
145
146 // Parse attributes
147 let attributes = value
148 .get("attributes")
149 .and_then(|v| v.as_object())
150 .map(|obj| obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
151 .unwrap_or_default();
152
153 // Parse events (array of event names or event objects)
154 let events = value.get("events").and_then(|v| v.as_array()).map(|arr| {
155 arr.iter()
156 .filter_map(|event| {
157 // Support both string arrays and event objects with "name" field
158 event
159 .as_str()
160 .map(String::from)
161 .or_else(|| event.get("name").and_then(|n| n.as_str()).map(String::from))
162 })
163 .collect()
164 });
165
166 Ok(SpanData {
167 name,
168 attributes,
169 trace_id,
170 span_id,
171 parent_span_id,
172 start_time_unix_nano,
173 end_time_unix_nano,
174 kind,
175 events,
176 resource_attributes: Default::default(),
177 })
178 }
179}