Skip to main content

obz_core/model/
trace.rs

1//! Trace data models.
2//!
3//! Represents spans and traces normalized from all supported backend
4//! providers. All IDs are lowercase hex strings. Timestamps are Unix
5//! seconds. Duration is in microseconds (i64) for sub-millisecond
6//! precision without floating point.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12/// A single span in a distributed trace.
13///
14/// All IDs are lowercase hex strings. `parent_span_id` is `None` for
15/// root spans. Attributes are flattened to string values.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct Span {
18    /// Trace ID in lowercase hex (16 or 32 chars).
19    pub trace_id: String,
20
21    /// Span ID in lowercase hex (16 chars).
22    pub span_id: String,
23
24    /// Parent span ID. `None` for root spans.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub parent_span_id: Option<String>,
27
28    /// Operation name.
29    pub name: String,
30
31    /// Service name.
32    pub service: String,
33
34    /// Span kind, normalized to `OTel` `SpanKind`.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub kind: Option<SpanKind>,
37
38    /// Span status: ok, error, or unset.
39    pub status: SpanStatus,
40
41    /// Start time as Unix seconds.
42    pub start_time: i64,
43
44    /// Duration in microseconds (integer, no floating point precision loss).
45    pub duration_us: i64,
46
47    /// Span attributes, flattened to string values.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub attributes: Option<BTreeMap<String, String>>,
50
51    /// Span events (Full View only). Not all providers populate this field.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub events: Option<Vec<SpanEvent>>,
54
55    /// Resource attributes (Full View only).
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub resource: Option<BTreeMap<String, String>>,
58
59    /// Provider-specific metadata (Full View only).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub extensions: Option<BTreeMap<String, serde_json::Value>>,
62}
63
64/// An event within a span (e.g., exception, log message).
65///
66/// Support varies by backend — some providers populate events while
67/// others omit them.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct SpanEvent {
70    /// Event name.
71    pub name: String,
72
73    /// Event timestamp as Unix seconds.
74    pub timestamp: i64,
75
76    /// Event attributes.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub attributes: Option<BTreeMap<String, String>>,
79}
80
81/// Span kind, aligned with `OTel` `SpanKind`.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum SpanKind {
85    /// Synchronous remote call receiver.
86    Server,
87    /// Synchronous remote call sender.
88    Client,
89    /// Asynchronous message sender.
90    Producer,
91    /// Asynchronous message receiver.
92    Consumer,
93    /// In-process operation.
94    Internal,
95}
96
97impl SpanKind {
98    /// Parse a span kind string (case-insensitive).
99    ///
100    /// Backends use different casing conventions — `OTel`-based stores
101    /// typically use `"Client"` / `"Server"`, while Jaeger uses lowercase
102    /// `"client"` / `"server"`.  This method normalises them all.
103    ///
104    /// Unknown or empty values map to [`SpanKind::Internal`].
105    pub fn parse(s: &str) -> Self {
106        match s.to_ascii_lowercase().as_str() {
107            "client" => Self::Client,
108            "server" => Self::Server,
109            "producer" => Self::Producer,
110            "consumer" => Self::Consumer,
111            _ => Self::Internal,
112        }
113    }
114}
115
116/// Span status, aligned with `OTel` `StatusCode`.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum SpanStatus {
120    /// Operation completed successfully.
121    Ok,
122    /// Operation encountered an error.
123    Error,
124    /// Status was not set.
125    Unset,
126}
127
128/// Trace detail with pre-computed summary, returned by `trace get`.
129///
130/// Contains all spans for a single trace plus computed summary fields
131/// so AI Agents can assess the trace without iterating over all spans.
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
133pub struct TraceDetail {
134    /// The queried trace ID.
135    pub trace_id: String,
136
137    /// Total number of spans in this trace.
138    pub span_count: usize,
139
140    /// Number of distinct services involved.
141    pub service_count: usize,
142
143    /// End-to-end duration in microseconds.
144    pub duration_us: i64,
145
146    /// List of distinct service names (sorted alphabetically).
147    pub services: Vec<String>,
148
149    /// All spans in the trace, sorted by `start_time` ascending.
150    pub spans: Vec<Span>,
151}
152
153impl TraceDetail {
154    /// Compute a trace detail summary from a list of spans.
155    ///
156    /// Spans are sorted by `start_time` ascending (ties broken by
157    /// `duration_us` descending for readability). Summary fields
158    /// are computed from the spans — the root span's duration is used
159    /// if available, otherwise the time range from earliest start to
160    /// latest end is used.
161    pub fn from_spans(trace_id: String, mut spans: Vec<Span>) -> Self {
162        // Sort by start_time ascending, then duration_us descending.
163        spans.sort_by(|a, b| {
164            a.start_time
165                .cmp(&b.start_time)
166                .then(b.duration_us.cmp(&a.duration_us))
167        });
168
169        let span_count = spans.len();
170
171        // Collect unique services (sorted).
172        let mut services: Vec<String> = spans.iter().map(|s| s.service.clone()).collect();
173        services.sort();
174        services.dedup();
175        let service_count = services.len();
176
177        // Compute end-to-end duration from root span or earliest-to-latest.
178        let duration_us = if let Some(root) = spans.iter().find(|s| s.parent_span_id.is_none()) {
179            root.duration_us
180        } else if !spans.is_empty() {
181            // Convert everything to microseconds for consistent arithmetic.
182            let earliest_us = spans
183                .iter()
184                .map(|s| s.start_time * 1_000_000)
185                .min()
186                .unwrap_or(0);
187            let latest_end_us = spans
188                .iter()
189                .map(|s| s.start_time * 1_000_000 + s.duration_us)
190                .max()
191                .unwrap_or(0);
192            latest_end_us - earliest_us
193        } else {
194            0
195        };
196
197        Self {
198            trace_id,
199            span_count,
200            service_count,
201            duration_us,
202            services,
203            spans,
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_span_kind_serialization() {
214        assert_eq!(
215            serde_json::to_string(&SpanKind::Server).unwrap(),
216            r#""server""#
217        );
218        assert_eq!(
219            serde_json::to_string(&SpanKind::Client).unwrap(),
220            r#""client""#
221        );
222        assert_eq!(
223            serde_json::to_string(&SpanKind::Internal).unwrap(),
224            r#""internal""#
225        );
226    }
227
228    #[test]
229    fn test_span_status_serialization() {
230        assert_eq!(serde_json::to_string(&SpanStatus::Ok).unwrap(), r#""ok""#);
231        assert_eq!(
232            serde_json::to_string(&SpanStatus::Error).unwrap(),
233            r#""error""#
234        );
235        assert_eq!(
236            serde_json::to_string(&SpanStatus::Unset).unwrap(),
237            r#""unset""#
238        );
239    }
240
241    #[test]
242    fn test_trace_detail_from_spans() {
243        let spans = vec![
244            Span {
245                trace_id: "abc123".to_string(),
246                span_id: "span1".to_string(),
247                parent_span_id: None,
248                name: "GET /api".to_string(),
249                service: "gateway".to_string(),
250                kind: Some(SpanKind::Server),
251                status: SpanStatus::Ok,
252                start_time: 1000,
253                duration_us: 100_000, // 100ms in microseconds
254                attributes: None,
255                events: None,
256                resource: None,
257                extensions: None,
258            },
259            Span {
260                trace_id: "abc123".to_string(),
261                span_id: "span2".to_string(),
262                parent_span_id: Some("span1".to_string()),
263                name: "SELECT".to_string(),
264                service: "db".to_string(),
265                kind: Some(SpanKind::Client),
266                status: SpanStatus::Ok,
267                start_time: 1000,
268                duration_us: 20_000, // 20ms in microseconds
269                attributes: None,
270                events: None,
271                resource: None,
272                extensions: None,
273            },
274        ];
275
276        let detail = TraceDetail::from_spans("abc123".to_string(), spans);
277        assert_eq!(detail.span_count, 2);
278        assert_eq!(detail.service_count, 2);
279        assert_eq!(detail.duration_us, 100_000); // root span duration (100ms)
280        assert_eq!(detail.services, vec!["db", "gateway"]);
281    }
282
283    #[test]
284    fn test_trace_detail_no_root_span() {
285        let spans = vec![
286            Span {
287                trace_id: "abc".to_string(),
288                span_id: "s1".to_string(),
289                parent_span_id: Some("missing".to_string()),
290                name: "op1".to_string(),
291                service: "svc".to_string(),
292                kind: None,
293                status: SpanStatus::Ok,
294                start_time: 1000,
295                duration_us: 50_000,
296                attributes: None,
297                events: None,
298                resource: None,
299                extensions: None,
300            },
301            Span {
302                trace_id: "abc".to_string(),
303                span_id: "s2".to_string(),
304                parent_span_id: Some("missing".to_string()),
305                name: "op2".to_string(),
306                service: "svc".to_string(),
307                kind: None,
308                status: SpanStatus::Ok,
309                start_time: 1000,
310                duration_us: 80_000,
311                attributes: None,
312                events: None,
313                resource: None,
314                extensions: None,
315            },
316        ];
317        let detail = TraceDetail::from_spans("abc".to_string(), spans);
318        // No root span → duration = latest_end - earliest_start
319        assert_eq!(detail.duration_us, 80_000);
320    }
321
322    #[test]
323    fn test_trace_detail_empty_spans() {
324        let detail = TraceDetail::from_spans("empty".to_string(), vec![]);
325        assert_eq!(detail.span_count, 0);
326        assert_eq!(detail.duration_us, 0);
327        assert!(detail.services.is_empty());
328    }
329
330    #[test]
331    fn test_span_kind_parse_capitalized() {
332        assert_eq!(SpanKind::parse("Client"), SpanKind::Client);
333        assert_eq!(SpanKind::parse("Server"), SpanKind::Server);
334        assert_eq!(SpanKind::parse("Producer"), SpanKind::Producer);
335        assert_eq!(SpanKind::parse("Consumer"), SpanKind::Consumer);
336    }
337
338    #[test]
339    fn test_span_kind_parse_lowercase() {
340        assert_eq!(SpanKind::parse("client"), SpanKind::Client);
341        assert_eq!(SpanKind::parse("server"), SpanKind::Server);
342        assert_eq!(SpanKind::parse("producer"), SpanKind::Producer);
343        assert_eq!(SpanKind::parse("consumer"), SpanKind::Consumer);
344        assert_eq!(SpanKind::parse("internal"), SpanKind::Internal);
345    }
346
347    #[test]
348    fn test_span_kind_parse_unknown_defaults_to_internal() {
349        assert_eq!(SpanKind::parse("unknown"), SpanKind::Internal);
350        assert_eq!(SpanKind::parse(""), SpanKind::Internal);
351    }
352}