Skip to main content

lc_callbacks/tracing/
backend.rs

1use std::sync::Mutex;
2
3#[cfg(feature = "opentelemetry")]
4use std::collections::HashMap;
5
6#[cfg(feature = "opentelemetry")]
7use crate::semconv;
8
9use super::span::{build_tree, TraceSpan};
10use super::TracingBackend;
11
12// ---------------------------------------------------------------------------
13// In-memory backend
14// ---------------------------------------------------------------------------
15
16/// In-memory tracing backend for development and testing.
17pub struct InMemoryTracingBackend {
18    spans: Mutex<Vec<TraceSpan>>,
19}
20
21impl InMemoryTracingBackend {
22    /// Create a new empty in-memory backend.
23    pub fn new() -> Self {
24        Self {
25            spans: Mutex::new(Vec::new()),
26        }
27    }
28
29    /// Return a snapshot of all recorded spans.
30    pub fn spans(&self) -> Vec<TraceSpan> {
31        self.spans.lock().unwrap_or_else(|e| e.into_inner()).clone()
32    }
33
34    /// Remove all recorded spans.
35    pub fn clear(&self) {
36        self.spans.lock().unwrap_or_else(|e| e.into_inner()).clear();
37    }
38
39    /// Get the full trace tree rooted at `root_id`.
40    ///
41    /// Returns `None` if no span with that ID exists.
42    pub fn trace_tree(&self, root_id: &str) -> Option<super::TraceNode> {
43        let spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
44        let root = spans.iter().find(|s| s.id == root_id)?;
45        Some(build_tree(root, &spans))
46    }
47}
48
49impl Default for InMemoryTracingBackend {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl TracingBackend for InMemoryTracingBackend {
56    fn start_span(&self, span: &TraceSpan) {
57        self.spans
58            .lock()
59            .unwrap_or_else(|e| e.into_inner())
60            .push(span.clone());
61    }
62
63    fn end_span(&self, span: &TraceSpan) {
64        let mut spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
65        if let Some(existing) = spans.iter_mut().find(|s| s.id == span.id) {
66            *existing = span.clone();
67        }
68    }
69
70    fn flush(&self) {
71        // In-memory backend has nothing to flush.
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Console / logging backend
77// ---------------------------------------------------------------------------
78
79/// Console logging backend that prints span lifecycle events.
80pub struct ConsoleTracingBackend;
81
82impl TracingBackend for ConsoleTracingBackend {
83    fn start_span(&self, span: &TraceSpan) {
84        println!("[TRACE START] {} ({})", span.name, span.kind);
85    }
86
87    fn end_span(&self, span: &TraceSpan) {
88        let latency = span.latency_ms.unwrap_or(0);
89        let status_str = match &span.status {
90            super::SpanStatus::Ok => "OK".to_string(),
91            super::SpanStatus::Error(e) => format!("ERROR: {}", e),
92        };
93        println!(
94            "[TRACE END]   {} latency={}ms status={}",
95            span.name, latency, status_str
96        );
97    }
98
99    fn flush(&self) {}
100}
101
102// ---------------------------------------------------------------------------
103// OpenTelemetry backend (feature-gated)
104// ---------------------------------------------------------------------------
105
106/// OpenTelemetry tracing backend (requires `opentelemetry` feature).
107///
108/// Converts framework trace spans into OTel spans via the global tracer,
109/// emitting the stabilized GenAI semantic-convention attributes carried on
110/// [`TraceSpan`] (same vocabulary as `OtelHandler`). Parent spans are linked
111/// through the framework `parent_id`, so the exported trace topology matches
112/// the in-process trace tree.
113#[cfg(feature = "opentelemetry")]
114pub struct OtelTracingBackend {
115    tracer: opentelemetry::global::BoxedTracer,
116    /// Active OTel spans keyed by framework span ID.
117    spans: Mutex<HashMap<String, opentelemetry::global::BoxedSpan>>,
118}
119
120#[cfg(feature = "opentelemetry")]
121impl OtelTracingBackend {
122    /// Create a new backend with the given tracer.
123    pub fn new(tracer: opentelemetry::global::BoxedTracer) -> Self {
124        Self {
125            tracer,
126            spans: Mutex::new(HashMap::new()),
127        }
128    }
129
130    /// Create a backend using the global tracer provider.
131    pub fn from_global(name: &str) -> Self {
132        Self::new(opentelemetry::global::tracer(name.to_string()))
133    }
134}
135
136#[cfg(feature = "opentelemetry")]
137impl TracingBackend for OtelTracingBackend {
138    fn start_span(&self, span: &TraceSpan) {
139        use opentelemetry::trace::{Span as _, TraceContextExt, Tracer as _};
140        use opentelemetry::{Context, KeyValue};
141
142        // Establish the OTel parent from the framework parent_id, cloning the
143        // SpanContext while the lock is held (matching OtelHandler).
144        let parent_cx = {
145            let spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
146            span.parent_id
147                .as_ref()
148                .and_then(|pid| spans.get(pid))
149                .map(|parent| {
150                    Context::new().with_remote_span_context(parent.span_context().clone())
151                })
152        };
153
154        let name = semconv_span_name(span);
155        let mut otel_span = match parent_cx {
156            Some(cx) => self.tracer.start_with_context(name, &cx),
157            None => self.tracer.start(name),
158        };
159
160        if let Some(op) = &span.gen_ai_operation_name {
161            otel_span.set_attribute(KeyValue::new(semconv::GEN_AI_OPERATION_NAME, op.clone()));
162        }
163        if let Some(system) = &span.gen_ai_system {
164            otel_span.set_attribute(KeyValue::new(semconv::GEN_AI_PROVIDER_NAME, system.clone()));
165        }
166        if let Some(model) = &span.gen_ai_request_model {
167            otel_span.set_attribute(KeyValue::new(semconv::GEN_AI_REQUEST_MODEL, model.clone()));
168        }
169        if let Some(max) = span.gen_ai_request_max_tokens {
170            otel_span.set_attribute(KeyValue::new(
171                semconv::GEN_AI_REQUEST_MAX_TOKENS,
172                max as i64,
173            ));
174        }
175        if let Some(temp) = span.gen_ai_request_temperature {
176            otel_span.set_attribute(KeyValue::new(semconv::GEN_AI_REQUEST_TEMPERATURE, temp));
177        }
178        if let Some(tool) = &span.gen_ai_tool_name {
179            otel_span.set_attribute(KeyValue::new(semconv::GEN_AI_TOOL_NAME, tool.clone()));
180        }
181        // Eval/trace join keys when the caller stamped them on metadata.
182        if let Some(obj) = span.metadata.as_object() {
183            for (key, attr) in [
184                ("run_id", semconv::RUN_ID_ATTR),
185                ("trace_id", semconv::TRACE_ID_ATTR),
186            ] {
187                if let Some(v) = obj.get(key).and_then(|v| v.as_str()) {
188                    otel_span.set_attribute(KeyValue::new(attr, v.to_string()));
189                }
190            }
191        }
192
193        self.spans
194            .lock()
195            .unwrap_or_else(|e| e.into_inner())
196            .insert(span.id.clone(), otel_span);
197    }
198
199    fn end_span(&self, span: &TraceSpan) {
200        use opentelemetry::trace::{Span as _, Status};
201        use opentelemetry::{Array, KeyValue, StringValue, Value};
202
203        let mut spans = self.spans.lock().unwrap_or_else(|e| e.into_inner());
204        if let Some(mut s) = spans.remove(&span.id) {
205            if let Some(model) = &span.gen_ai_response_model {
206                s.set_attribute(KeyValue::new(semconv::GEN_AI_RESPONSE_MODEL, model.clone()));
207            }
208            if let Some(tokens) = &span.tokens {
209                s.set_attribute(KeyValue::new(
210                    semconv::GEN_AI_USAGE_INPUT_TOKENS,
211                    tokens.prompt_tokens as i64,
212                ));
213                s.set_attribute(KeyValue::new(
214                    semconv::GEN_AI_USAGE_OUTPUT_TOKENS,
215                    tokens.completion_tokens as i64,
216                ));
217            }
218            if let Some(reason) = &span.gen_ai_finish_reason {
219                s.set_attribute(KeyValue::new(
220                    semconv::GEN_AI_RESPONSE_FINISH_REASONS,
221                    Value::Array(Array::String(vec![StringValue::from(reason.clone())])),
222                ));
223            }
224            if let crate::SpanStatus::Error(error) = &span.status {
225                s.set_status(Status::error(error.clone()));
226                s.set_attribute(KeyValue::new(
227                    semconv::ERROR_TYPE,
228                    crate::semconv::error_type(error),
229                ));
230                s.add_event(
231                    "exception".to_string(),
232                    vec![KeyValue::new(
233                        "exception.message",
234                        crate::semconv::truncate(error, 1024),
235                    )],
236                );
237            }
238            s.end();
239        }
240    }
241
242    fn flush(&self) {}
243}
244
245/// Picks the semconv span name for a trace span: `chat {model}` for LLM
246/// spans, `execute_tool {tool}` for tool spans; falls back to the recorded
247/// name.
248#[cfg(feature = "opentelemetry")]
249fn semconv_span_name(span: &TraceSpan) -> String {
250    use super::span::SpanKind;
251    match &span.kind {
252        SpanKind::Llm => match &span.gen_ai_request_model {
253            Some(model) => format!("chat {model}"),
254            None => "chat".to_string(),
255        },
256        crate::SpanKind::Tool => match &span.gen_ai_tool_name {
257            Some(tool) => format!("execute_tool {tool}"),
258            None => span.name.clone(),
259        },
260        _ => span.name.clone(),
261    }
262}
263
264#[cfg(all(test, feature = "opentelemetry"))]
265mod otel_backend_tests {
266    use super::*;
267    use crate::semconv;
268    use crate::tracing::span::make_span;
269    use opentelemetry::trace::TracerProvider as _;
270    use opentelemetry::Value;
271    use opentelemetry_sdk::testing::trace::InMemorySpanExporterBuilder;
272    use opentelemetry_sdk::trace::{SimpleSpanProcessor, TracerProvider};
273
274    fn backend_with_exporter() -> (
275        OtelTracingBackend,
276        opentelemetry_sdk::testing::trace::InMemorySpanExporter,
277    ) {
278        let exporter = InMemorySpanExporterBuilder::new().build();
279        let provider = TracerProvider::builder()
280            .with_span_processor(SimpleSpanProcessor::new(Box::new(exporter.clone())))
281            .build();
282        let tracer: opentelemetry::global::BoxedTracer =
283            opentelemetry::global::BoxedTracer::new(Box::new(provider.tracer("test")));
284        (OtelTracingBackend::new(tracer), exporter)
285    }
286
287    fn attr_str(span: &opentelemetry_sdk::export::trace::SpanData, key: &str) -> Option<String> {
288        span.attributes
289            .iter()
290            .find(|kv| kv.key.as_str() == key)
291            .map(|kv| kv.value.as_str().into_owned())
292    }
293
294    #[test]
295    fn llm_span_emits_semconv_name_and_request_attributes() {
296        let (backend, exporter) = backend_with_exporter();
297        let mut span = make_span(
298            "root".into(),
299            None,
300            "gpt-4o-mini call",
301            crate::SpanKind::Llm,
302        );
303        span.gen_ai_system = Some("openai".into());
304        span.gen_ai_request_model = Some("gpt-4o-mini".into());
305        span.gen_ai_operation_name = Some("chat".into());
306        span.gen_ai_request_temperature = Some(0.1);
307        span.gen_ai_request_max_tokens = Some(256);
308        span.metadata = serde_json::json!({"run_id": "run-42", "trace_id": "trace-7"});
309
310        backend.start_span(&span);
311        backend.end_span(&span);
312
313        let spans = exporter.get_finished_spans().unwrap();
314        assert_eq!(spans.len(), 1);
315        let exported = &spans[0];
316        assert_eq!(exported.name, "chat gpt-4o-mini");
317        assert_eq!(
318            attr_str(exported, semconv::GEN_AI_PROVIDER_NAME).as_deref(),
319            Some("openai")
320        );
321        assert_eq!(
322            attr_str(exported, semconv::RUN_ID_ATTR).as_deref(),
323            Some("run-42")
324        );
325        assert_eq!(
326            attr_str(exported, semconv::TRACE_ID_ATTR).as_deref(),
327            Some("trace-7")
328        );
329        assert!(exported
330            .attributes
331            .iter()
332            .any(|kv| kv.key.as_str() == semconv::GEN_AI_REQUEST_TEMPERATURE
333                && matches!(kv.value, Value::F64(_))));
334    }
335
336    #[test]
337    fn end_span_emits_usage_finish_reasons_and_error_status() {
338        let (backend, exporter) = backend_with_exporter();
339        let mut span = make_span("t".into(), None, "tool call", crate::SpanKind::Tool);
340        span.gen_ai_tool_name = Some("calculator".into());
341        span.gen_ai_response_model = Some("gpt-4o-mini-2024-07-18".into());
342        span.gen_ai_finish_reason = Some("tool_calls".into());
343        span.tokens = Some(crate::SpanTokenUsage {
344            prompt_tokens: 10,
345            completion_tokens: 4,
346            total_tokens: 14,
347        });
348        span.status = crate::SpanStatus::Error("TimeoutError: 30s".into());
349
350        backend.start_span(&span);
351        backend.end_span(&span);
352
353        let spans = exporter.get_finished_spans().unwrap();
354        let exported = &spans[0];
355        assert_eq!(exported.name, "execute_tool calculator");
356        assert_eq!(
357            attr_str(exported, semconv::GEN_AI_RESPONSE_MODEL).as_deref(),
358            Some("gpt-4o-mini-2024-07-18")
359        );
360        let reasons = exported
361            .attributes
362            .iter()
363            .find(|kv| kv.key.as_str() == semconv::GEN_AI_RESPONSE_FINISH_REASONS)
364            .map(|kv| match &kv.value {
365                opentelemetry::Value::Array(opentelemetry::Array::String(values)) => values
366                    .iter()
367                    .map(|v| v.as_str().to_string())
368                    .collect::<Vec<_>>(),
369                _ => vec![],
370            })
371            .unwrap();
372        assert_eq!(reasons, vec!["tool_calls".to_string()]);
373        assert!(matches!(
374            exported.status,
375            opentelemetry::trace::Status::Error { .. }
376        ));
377        assert_eq!(
378            attr_str(exported, semconv::ERROR_TYPE).as_deref(),
379            Some("TimeoutError")
380        );
381        assert!(exported.events.iter().any(|e| e.name == "exception"));
382    }
383
384    #[test]
385    fn child_span_links_to_parent_context() {
386        let (backend, exporter) = backend_with_exporter();
387        let root = make_span("root".into(), None, "root", crate::SpanKind::Chain);
388        let mut child = make_span(
389            "child".into(),
390            Some("root".into()),
391            "child",
392            crate::SpanKind::Llm,
393        );
394        // LLM spans are renamed `chat {model}`.
395        child.gen_ai_request_model = Some("mini".into());
396
397        backend.start_span(&root);
398        backend.start_span(&child);
399        backend.end_span(&child);
400        backend.end_span(&root);
401
402        let spans = exporter.get_finished_spans().unwrap();
403        assert_eq!(spans.len(), 2);
404        let root_ctx = spans
405            .iter()
406            .find(|s| s.name == "root")
407            .unwrap()
408            .span_context
409            .clone();
410        let child_data = spans.iter().find(|s| s.name == "chat mini").unwrap();
411        assert_eq!(child_data.parent_span_id, root_ctx.span_id());
412        assert_eq!(child_data.span_context.trace_id(), root_ctx.trace_id());
413    }
414}