clnrm_core/cli/commands/
spans.rs

1//! Span filtering and search command
2//!
3//! Implements PRD v1.0 `clnrm spans` command for searching OTEL traces.
4
5use crate::cli::types::OutputFormat;
6use crate::error::{CleanroomError, Result};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11/// Span status enum matching OTEL specification
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum SpanStatus {
15    /// The operation completed successfully
16    Ok,
17    /// The operation encountered an error
18    Error,
19    /// Status is unset (default)
20    Unset,
21}
22
23impl SpanStatus {
24    /// Parse status from string (case-insensitive)
25    pub fn from_str_case_insensitive(s: &str) -> Option<Self> {
26        match s.to_lowercase().as_str() {
27            "ok" => Some(SpanStatus::Ok),
28            "error" => Some(SpanStatus::Error),
29            "unset" => Some(SpanStatus::Unset),
30            _ => None,
31        }
32    }
33}
34
35/// OpenTelemetry span representation
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OtelSpan {
38    /// Span name
39    pub name: String,
40    /// Service name from resource attributes
41    #[serde(default)]
42    pub service_name: Option<String>,
43    /// Span duration in nanoseconds
44    #[serde(default)]
45    pub duration_ns: Option<u64>,
46    /// Span status
47    #[serde(default)]
48    pub status: Option<SpanStatus>,
49    /// Span attributes
50    #[serde(default)]
51    pub attributes: serde_json::Map<String, serde_json::Value>,
52    /// Span events
53    #[serde(default)]
54    pub events: Vec<SpanEvent>,
55    /// Trace ID
56    #[serde(default)]
57    pub trace_id: Option<String>,
58    /// Span ID
59    #[serde(default)]
60    pub span_id: Option<String>,
61    /// Parent span ID
62    #[serde(default)]
63    pub parent_span_id: Option<String>,
64}
65
66/// Span event representation
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct SpanEvent {
69    /// Event name
70    pub name: String,
71    /// Event timestamp
72    #[serde(default)]
73    pub timestamp: Option<u64>,
74    /// Event attributes
75    #[serde(default)]
76    pub attributes: serde_json::Map<String, serde_json::Value>,
77}
78
79/// Trace data structure
80#[derive(Debug, Serialize, Deserialize)]
81pub struct TraceData {
82    /// Resource spans from OTLP format
83    #[serde(rename = "resourceSpans", default)]
84    pub resource_spans: Vec<ResourceSpan>,
85    /// Alternative: flat list of spans
86    #[serde(default)]
87    pub spans: Vec<OtelSpan>,
88}
89
90/// Resource span from OTLP format
91#[derive(Debug, Serialize, Deserialize)]
92pub struct ResourceSpan {
93    /// Resource attributes
94    #[serde(default)]
95    pub resource: Option<Resource>,
96    /// Scope spans
97    #[serde(rename = "scopeSpans", default)]
98    pub scope_spans: Vec<ScopeSpan>,
99}
100
101/// Resource with attributes
102#[derive(Debug, Serialize, Deserialize)]
103pub struct Resource {
104    /// Resource attributes
105    #[serde(default)]
106    pub attributes: Vec<Attribute>,
107}
108
109/// Scope span from OTLP format
110#[derive(Debug, Serialize, Deserialize)]
111pub struct ScopeSpan {
112    /// Spans under this scope
113    #[serde(default)]
114    pub spans: Vec<OtlpSpan>,
115}
116
117/// OTLP span format
118#[derive(Debug, Serialize, Deserialize)]
119pub struct OtlpSpan {
120    /// Span name
121    pub name: String,
122    /// Trace ID (hex string)
123    #[serde(rename = "traceId", default)]
124    pub trace_id: Option<String>,
125    /// Span ID (hex string)
126    #[serde(rename = "spanId", default)]
127    pub span_id: Option<String>,
128    /// Parent span ID (hex string)
129    #[serde(rename = "parentSpanId", default)]
130    pub parent_span_id: Option<String>,
131    /// Start time (Unix nano)
132    #[serde(rename = "startTimeUnixNano", default)]
133    pub start_time_unix_nano: Option<String>,
134    /// End time (Unix nano)
135    #[serde(rename = "endTimeUnixNano", default)]
136    pub end_time_unix_nano: Option<String>,
137    /// Span attributes
138    #[serde(default)]
139    pub attributes: Vec<Attribute>,
140    /// Span events
141    #[serde(default)]
142    pub events: Vec<OtlpEvent>,
143    /// Span status
144    #[serde(default)]
145    pub status: Option<OtlpStatus>,
146}
147
148/// OTLP attribute
149#[derive(Debug, Serialize, Deserialize)]
150pub struct Attribute {
151    /// Attribute key
152    pub key: String,
153    /// Attribute value
154    pub value: AttributeValue,
155}
156
157/// OTLP attribute value
158#[derive(Debug, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct AttributeValue {
161    /// String value
162    #[serde(default)]
163    pub string_value: Option<String>,
164    /// Int value
165    #[serde(default)]
166    pub int_value: Option<i64>,
167    /// Double value
168    #[serde(default)]
169    pub double_value: Option<f64>,
170    /// Bool value
171    #[serde(default)]
172    pub bool_value: Option<bool>,
173}
174
175/// OTLP event
176#[derive(Debug, Serialize, Deserialize)]
177pub struct OtlpEvent {
178    /// Event name
179    pub name: String,
180    /// Event timestamp
181    #[serde(rename = "timeUnixNano", default)]
182    pub time_unix_nano: Option<String>,
183    /// Event attributes
184    #[serde(default)]
185    pub attributes: Vec<Attribute>,
186}
187
188/// OTLP status
189#[derive(Debug, Serialize, Deserialize)]
190pub struct OtlpStatus {
191    /// Status code (0=Unset, 1=Ok, 2=Error)
192    #[serde(default)]
193    pub code: Option<u32>,
194    /// Status message
195    #[serde(default)]
196    pub message: Option<String>,
197}
198
199/// Search and filter OpenTelemetry spans
200///
201/// Searches trace data for spans matching criteria and displays results.
202///
203/// # Arguments
204///
205/// * `trace` - Path to trace file or test run
206/// * `grep` - Optional regex pattern to filter span names
207/// * `format` - Output format
208/// * `show_attrs` - Show span attributes in output
209/// * `show_events` - Show span events in output
210///
211/// # Core Team Standards
212///
213/// - No unwrap() or expect()
214/// - Returns Result<T, CleanroomError>
215/// - Proper error handling with regex
216pub fn filter_spans(
217    trace: &Path,
218    grep: Option<&str>,
219    format: &OutputFormat,
220    show_attrs: bool,
221    show_events: bool,
222) -> Result<()> {
223    // 1. Load and parse trace
224    let trace_data = load_trace(trace)?;
225
226    // 2. Compile regex pattern if provided
227    let pattern = if let Some(grep_str) = grep {
228        Some(regex::Regex::new(grep_str).map_err(|e| {
229            CleanroomError::validation_error(format!("Invalid regex pattern '{}': {}", grep_str, e))
230        })?)
231    } else {
232        None
233    };
234
235    // 3. Apply filters
236    let filtered_spans: Vec<&OtelSpan> = trace_data
237        .spans
238        .iter()
239        .filter(|span| {
240            // Apply grep pattern filter
241            if let Some(ref regex) = pattern {
242                if !regex.is_match(&span.name) {
243                    return false;
244                }
245            }
246            true
247        })
248        .collect();
249
250    // 4. Output in requested format
251    match format {
252        OutputFormat::Json => output_json(&filtered_spans, show_attrs, show_events)?,
253        OutputFormat::Human | OutputFormat::Auto => {
254            output_table(&filtered_spans, show_attrs, show_events)?
255        }
256        _ => {
257            return Err(CleanroomError::validation_error(format!(
258                "Unsupported output format for spans: {:?}",
259                format
260            )))
261        }
262    }
263
264    Ok(())
265}
266
267/// Load trace data from file
268///
269/// Supports both OTLP format and flat span lists.
270fn load_trace(trace_path: &Path) -> Result<TraceData> {
271    let content = fs::read_to_string(trace_path).map_err(|e| {
272        CleanroomError::io_error(format!(
273            "Failed to read trace file '{}': {}",
274            trace_path.display(),
275            e
276        ))
277    })?;
278
279    let mut trace_data: TraceData = serde_json::from_str(&content).map_err(|e| {
280        CleanroomError::validation_error(format!(
281            "Failed to parse trace JSON from '{}': {}",
282            trace_path.display(),
283            e
284        ))
285    })?;
286
287    // Convert OTLP format to flat span list if needed
288    if trace_data.spans.is_empty() && !trace_data.resource_spans.is_empty() {
289        trace_data.spans = convert_otlp_to_spans(&trace_data)?;
290    }
291
292    if trace_data.spans.is_empty() {
293        return Err(CleanroomError::validation_error(format!(
294            "No spans found in trace file '{}'",
295            trace_path.display()
296        )));
297    }
298
299    Ok(trace_data)
300}
301
302/// Convert OTLP format to flat span list
303fn convert_otlp_to_spans(trace_data: &TraceData) -> Result<Vec<OtelSpan>> {
304    let mut spans = Vec::new();
305
306    for resource_span in &trace_data.resource_spans {
307        // Extract service name from resource attributes
308        let service_name = resource_span.resource.as_ref().and_then(|r| {
309            r.attributes.iter().find_map(|attr| {
310                if attr.key == "service.name" {
311                    attr.value.string_value.clone()
312                } else {
313                    None
314                }
315            })
316        });
317
318        for scope_span in &resource_span.scope_spans {
319            for otlp_span in &scope_span.spans {
320                spans.push(convert_otlp_span(otlp_span, service_name.clone())?);
321            }
322        }
323    }
324
325    Ok(spans)
326}
327
328/// Convert OTLP span to OtelSpan
329fn convert_otlp_span(otlp_span: &OtlpSpan, service_name: Option<String>) -> Result<OtelSpan> {
330    // Calculate duration
331    let duration_ns = if let (Some(start), Some(end)) = (
332        &otlp_span.start_time_unix_nano,
333        &otlp_span.end_time_unix_nano,
334    ) {
335        let start_ns = start
336            .parse::<u64>()
337            .map_err(|e| CleanroomError::validation_error(format!("Invalid start time: {}", e)))?;
338        let end_ns = end
339            .parse::<u64>()
340            .map_err(|e| CleanroomError::validation_error(format!("Invalid end time: {}", e)))?;
341        Some(end_ns.saturating_sub(start_ns))
342    } else {
343        None
344    };
345
346    // Convert status
347    let status = otlp_span.status.as_ref().and_then(|s| {
348        s.code.and_then(|code| match code {
349            0 => Some(SpanStatus::Unset),
350            1 => Some(SpanStatus::Ok),
351            2 => Some(SpanStatus::Error),
352            _ => None,
353        })
354    });
355
356    // Convert attributes to map
357    let mut attributes = serde_json::Map::new();
358    for attr in &otlp_span.attributes {
359        let value = if let Some(ref s) = attr.value.string_value {
360            serde_json::Value::String(s.clone())
361        } else if let Some(i) = attr.value.int_value {
362            serde_json::Value::Number(i.into())
363        } else if let Some(d) = attr.value.double_value {
364            serde_json::Number::from_f64(d)
365                .map(serde_json::Value::Number)
366                .unwrap_or(serde_json::Value::Null)
367        } else if let Some(b) = attr.value.bool_value {
368            serde_json::Value::Bool(b)
369        } else {
370            serde_json::Value::Null
371        };
372        attributes.insert(attr.key.clone(), value);
373    }
374
375    // Convert events
376    let events = otlp_span
377        .events
378        .iter()
379        .map(|e| {
380            let mut event_attrs = serde_json::Map::new();
381            for attr in &e.attributes {
382                let value = if let Some(ref s) = attr.value.string_value {
383                    serde_json::Value::String(s.clone())
384                } else {
385                    serde_json::Value::Null
386                };
387                event_attrs.insert(attr.key.clone(), value);
388            }
389            SpanEvent {
390                name: e.name.clone(),
391                timestamp: e.time_unix_nano.as_ref().and_then(|t| t.parse().ok()),
392                attributes: event_attrs,
393            }
394        })
395        .collect();
396
397    Ok(OtelSpan {
398        name: otlp_span.name.clone(),
399        service_name,
400        duration_ns,
401        status,
402        attributes,
403        events,
404        trace_id: otlp_span.trace_id.clone(),
405        span_id: otlp_span.span_id.clone(),
406        parent_span_id: otlp_span.parent_span_id.clone(),
407    })
408}
409
410/// Output spans as JSON
411fn output_json(spans: &[&OtelSpan], show_attrs: bool, show_events: bool) -> Result<()> {
412    let output: Vec<serde_json::Value> = spans
413        .iter()
414        .map(|span| {
415            let mut obj = serde_json::Map::new();
416            obj.insert("name".to_string(), serde_json::json!(span.name));
417            if let Some(ref service) = span.service_name {
418                obj.insert("service".to_string(), serde_json::json!(service));
419            }
420            if let Some(duration) = span.duration_ns {
421                obj.insert("duration_ns".to_string(), serde_json::json!(duration));
422            }
423            if let Some(ref status) = span.status {
424                obj.insert("status".to_string(), serde_json::json!(status));
425            }
426            if show_attrs && !span.attributes.is_empty() {
427                obj.insert(
428                    "attributes".to_string(),
429                    serde_json::Value::Object(span.attributes.clone()),
430                );
431            }
432            if show_events && !span.events.is_empty() {
433                obj.insert("events".to_string(), serde_json::json!(span.events));
434            }
435            serde_json::Value::Object(obj)
436        })
437        .collect();
438
439    let json = serde_json::to_string_pretty(&output).map_err(|e| {
440        CleanroomError::internal_error(format!("Failed to serialize JSON output: {}", e))
441    })?;
442
443    println!("{}", json);
444    Ok(())
445}
446
447/// Output spans as a table
448fn output_table(spans: &[&OtelSpan], show_attrs: bool, show_events: bool) -> Result<()> {
449    if spans.is_empty() {
450        println!("No spans found matching filter criteria.");
451        return Ok(());
452    }
453
454    // Print header
455    println!(
456        "{:<40} {:<20} {:<12} {:<10}",
457        "SPAN NAME", "SERVICE", "DURATION", "STATUS"
458    );
459    println!("{}", "-".repeat(84));
460
461    // Print rows
462    for span in spans {
463        let service = span
464            .service_name
465            .as_deref()
466            .unwrap_or("unknown")
467            .to_string();
468        let duration = format_duration(span.duration_ns);
469        let status = format_status(span.status.as_ref());
470
471        println!(
472            "{:<40} {:<20} {:<12} {:<10}",
473            truncate(&span.name, 40),
474            truncate(&service, 20),
475            duration,
476            status
477        );
478
479        // Show attributes if requested
480        if show_attrs && !span.attributes.is_empty() {
481            println!("  Attributes:");
482            for (key, value) in &span.attributes {
483                println!("    {} = {}", key, value);
484            }
485        }
486
487        // Show events if requested
488        if show_events && !span.events.is_empty() {
489            println!("  Events:");
490            for event in &span.events {
491                println!("    - {}", event.name);
492            }
493        }
494    }
495
496    println!("\nTotal spans: {}", spans.len());
497    Ok(())
498}
499
500/// Format duration from nanoseconds
501fn format_duration(duration_ns: Option<u64>) -> String {
502    match duration_ns {
503        Some(ns) => {
504            if ns < 1_000 {
505                format!("{}ns", ns)
506            } else if ns < 1_000_000 {
507                format!("{:.1}μs", ns as f64 / 1_000.0)
508            } else if ns < 1_000_000_000 {
509                format!("{:.1}ms", ns as f64 / 1_000_000.0)
510            } else {
511                format!("{:.2}s", ns as f64 / 1_000_000_000.0)
512            }
513        }
514        None => "N/A".to_string(),
515    }
516}
517
518/// Format span status
519fn format_status(status: Option<&SpanStatus>) -> String {
520    match status {
521        Some(SpanStatus::Ok) => "ok".to_string(),
522        Some(SpanStatus::Error) => "error".to_string(),
523        Some(SpanStatus::Unset) => "unset".to_string(),
524        None => "unknown".to_string(),
525    }
526}
527
528/// Truncate string to max length
529fn truncate(s: &str, max_len: usize) -> String {
530    if s.len() <= max_len {
531        s.to_string()
532    } else {
533        format!("{}...", &s[..max_len - 3])
534    }
535}