Skip to main content

confium_coordinator/coordinator/
otlp.rs

1//! OTLP (OpenTelemetry Protocol) span exporter.
2//!
3//! Converts tracing spans to OTLP-compatible JSON for export to
4//! Jaeger, Zipkin, or any OTLP-compatible backend.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// An OTLP span — OpenTelemetry-compatible trace span.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct OtlpSpan {
12    pub trace_id: String,
13    pub span_id: String,
14    pub parent_span_id: Option<String>,
15    pub name: String,
16    pub start_time: DateTime<Utc>,
17    pub end_time: DateTime<Utc>,
18    pub attributes: Vec<OtlpAttribute>,
19    pub status: OtlpStatus,
20}
21
22/// A key-value attribute on a span.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct OtlpAttribute {
25    pub key: String,
26    pub value: String,
27}
28
29/// Span status.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub struct OtlpStatus {
33    pub code: String,
34    pub message: Option<String>,
35}
36
37impl OtlpStatus {
38    pub fn ok() -> Self {
39        Self {
40            code: "ok".into(),
41            message: None,
42        }
43    }
44    pub fn error(msg: &str) -> Self {
45        Self {
46            code: "error".into(),
47            message: Some(msg.into()),
48        }
49    }
50}
51
52/// Batch of spans for export.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SpanBatch {
55    pub resource_spans: Vec<ResourceSpans>,
56}
57
58/// Resource-level spans (one per service).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ResourceSpans {
61    pub resource: ResourceAttributes,
62    pub scope_spans: Vec<ScopeSpans>,
63}
64
65/// Resource attributes (service name, version, etc.).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ResourceAttributes {
68    pub attributes: Vec<OtlpAttribute>,
69}
70
71/// Scope-level spans.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ScopeSpans {
74    pub scope: ScopeInfo,
75    pub spans: Vec<OtlpSpan>,
76}
77
78/// Scope metadata.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct ScopeInfo {
81    pub name: String,
82    pub version: String,
83}
84
85/// Build an OTLP span from a tracing event.
86pub fn build_span(
87    trace_id: &str,
88    span_id: &str,
89    name: &str,
90    start: DateTime<Utc>,
91    end: DateTime<Utc>,
92    attributes: Vec<(&str, &str)>,
93) -> OtlpSpan {
94    OtlpSpan {
95        trace_id: trace_id.into(),
96        span_id: span_id.into(),
97        parent_span_id: None,
98        name: name.into(),
99        start_time: start,
100        end_time: end,
101        attributes: attributes
102            .into_iter()
103            .map(|(k, v)| OtlpAttribute {
104                key: k.into(),
105                value: v.into(),
106            })
107            .collect(),
108        status: OtlpStatus::ok(),
109    }
110}
111
112/// Build a complete OTLP export batch.
113pub fn build_export_batch(
114    service_name: &str,
115    service_version: &str,
116    spans: Vec<OtlpSpan>,
117) -> SpanBatch {
118    SpanBatch {
119        resource_spans: vec![ResourceSpans {
120            resource: ResourceAttributes {
121                attributes: vec![
122                    OtlpAttribute {
123                        key: "service.name".into(),
124                        value: service_name.into(),
125                    },
126                    OtlpAttribute {
127                        key: "service.version".into(),
128                        value: service_version.into(),
129                    },
130                ],
131            },
132            scope_spans: vec![ScopeSpans {
133                scope: ScopeInfo {
134                    name: "confium-tc".into(),
135                    version: service_version.into(),
136                },
137                spans,
138            }],
139        }],
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn span_has_trace_id() {
149        let span = build_span("trace-1", "span-1", "test", Utc::now(), Utc::now(), vec![]);
150        assert_eq!(span.trace_id, "trace-1");
151        assert_eq!(span.span_id, "span-1");
152    }
153
154    #[test]
155    fn span_attributes_preserved() {
156        let span = build_span(
157            "t",
158            "s",
159            "n",
160            Utc::now(),
161            Utc::now(),
162            vec![("key1", "val1"), ("key2", "val2")],
163        );
164        assert_eq!(span.attributes.len(), 2);
165        assert_eq!(span.attributes[0].key, "key1");
166    }
167
168    #[test]
169    fn status_ok_has_no_message() {
170        let status = OtlpStatus::ok();
171        assert_eq!(status.code, "ok");
172        assert!(status.message.is_none());
173    }
174
175    #[test]
176    fn status_error_has_message() {
177        let status = OtlpStatus::error("failed");
178        assert_eq!(status.code, "error");
179        assert_eq!(status.message.as_deref(), Some("failed"));
180    }
181
182    #[test]
183    fn export_batch_serializes() {
184        let span = build_span("t", "s", "n", Utc::now(), Utc::now(), vec![]);
185        let batch = build_export_batch("confium-coord", "0.3.0", vec![span]);
186        let json = serde_json::to_string(&batch).unwrap();
187        assert!(json.contains("confium-coord"));
188        assert!(json.contains("resource_spans"));
189    }
190
191    #[test]
192    fn export_batch_has_resource_attributes() {
193        let batch = build_export_batch("svc", "1.0", vec![]);
194        assert_eq!(batch.resource_spans.len(), 1);
195        let attrs = &batch.resource_spans[0].resource.attributes;
196        assert!(
197            attrs
198                .iter()
199                .any(|a| a.key == "service.name" && a.value == "svc")
200        );
201    }
202
203    #[test]
204    fn span_round_trips_json() {
205        let span = build_span(
206            "t1",
207            "s1",
208            "operation",
209            Utc::now(),
210            Utc::now(),
211            vec![("a", "b")],
212        );
213        let json = serde_json::to_string(&span).unwrap();
214        let recovered: OtlpSpan = serde_json::from_str(&json).unwrap();
215        assert_eq!(recovered.trace_id, "t1");
216        assert_eq!(recovered.attributes.len(), 1);
217    }
218}