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
12pub struct InMemoryTracingBackend {
18 spans: Mutex<Vec<TraceSpan>>,
19}
20
21impl InMemoryTracingBackend {
22 pub fn new() -> Self {
24 Self {
25 spans: Mutex::new(Vec::new()),
26 }
27 }
28
29 pub fn spans(&self) -> Vec<TraceSpan> {
31 self.spans.lock().unwrap_or_else(|e| e.into_inner()).clone()
32 }
33
34 pub fn clear(&self) {
36 self.spans.lock().unwrap_or_else(|e| e.into_inner()).clear();
37 }
38
39 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 }
73}
74
75pub 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#[cfg(feature = "opentelemetry")]
114pub struct OtelTracingBackend {
115 tracer: opentelemetry::global::BoxedTracer,
116 spans: Mutex<HashMap<String, opentelemetry::global::BoxedSpan>>,
118}
119
120#[cfg(feature = "opentelemetry")]
121impl OtelTracingBackend {
122 pub fn new(tracer: opentelemetry::global::BoxedTracer) -> Self {
124 Self {
125 tracer,
126 spans: Mutex::new(HashMap::new()),
127 }
128 }
129
130 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 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 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#[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 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}