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}