1use std::collections::HashMap;
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::api::event::Event;
24use crate::api::event::ScopeCategory;
25use crate::api::runtime::EventSubscriberFn;
26use crate::api::scope::ScopeType;
27use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
28use crate::codec::response::{CostEstimate, Usage, estimate_cost_for_provider};
29use crate::error::FlowError;
30use crate::json::Json;
31use chrono::{DateTime, Utc};
32use opentelemetry::trace::{
33 Span as _, SpanContext, SpanKind, TraceContextExt, Tracer, TracerProvider as _,
34};
35use opentelemetry::{Context, KeyValue};
36use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig, WithHttpConfig};
37use opentelemetry_sdk::Resource;
38use opentelemetry_sdk::trace::{SdkTracer, SdkTracerProvider, Span};
39use serde::Serialize;
40use uuid::Uuid;
41
42#[cfg(target_arch = "wasm32")]
43use async_trait::async_trait;
44#[cfg(target_arch = "wasm32")]
45use opentelemetry_http::{
46 Bytes, HttpClient, HttpError, Request as HttpRequest, Response as HttpResponse,
47};
48#[cfg(not(target_arch = "wasm32"))]
49use opentelemetry_otlp::WithTonicConfig;
50#[cfg(not(target_arch = "wasm32"))]
51use tokio::runtime::Handle;
52#[cfg(not(target_arch = "wasm32"))]
53use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
54#[cfg(target_arch = "wasm32")]
55use wasm_bindgen::{JsCast, JsValue};
56#[cfg(target_arch = "wasm32")]
57use wasm_bindgen_futures::{JsFuture, spawn_local};
58#[cfg(target_arch = "wasm32")]
59use web_sys::{Request as WebRequest, RequestInit};
60
61pub type Result<T> = std::result::Result<T, OpenTelemetryError>;
63
64#[derive(Debug, thiserror::Error)]
66pub enum OpenTelemetryError {
67 #[error("the OTLP gRPC exporter requires an active Tokio runtime")]
69 MissingTokioRuntime,
70 #[error("the OTLP {transport} transport is not supported on this target")]
72 UnsupportedTransport {
73 transport: &'static str,
75 },
76 #[error("invalid OTLP gRPC header {key:?}: {message}")]
78 InvalidGrpcHeader {
79 key: String,
81 message: String,
83 },
84 #[error("failed to build the OTLP exporter: {0}")]
86 ExporterBuild(String),
87 #[error("OpenTelemetry tracer provider error: {0}")]
89 Provider(String),
90 #[error(transparent)]
92 Core(#[from] FlowError),
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum OtlpTransport {
98 #[default]
100 HttpBinary,
101 Grpc,
103}
104
105#[derive(Debug, Clone)]
107pub struct OpenTelemetryConfig {
108 endpoint: Option<String>,
109 headers: HashMap<String, String>,
110 resource_attributes: HashMap<String, String>,
111 service_name: String,
112 service_namespace: Option<String>,
113 service_version: Option<String>,
114 instrumentation_scope: String,
115 timeout: Duration,
116 transport: OtlpTransport,
117}
118
119impl Default for OpenTelemetryConfig {
120 fn default() -> Self {
121 Self {
122 endpoint: None,
123 headers: HashMap::new(),
124 resource_attributes: HashMap::new(),
125 service_name: "nemo-relay".to_string(),
126 service_namespace: None,
127 service_version: None,
128 instrumentation_scope: "nemo-relay-otel".to_string(),
129 timeout: Duration::from_secs(3),
130 transport: OtlpTransport::HttpBinary,
131 }
132 }
133}
134
135impl OpenTelemetryConfig {
136 pub fn http_binary(service_name: impl Into<String>) -> Self {
138 Self {
139 service_name: service_name.into(),
140 transport: OtlpTransport::HttpBinary,
141 ..Self::default()
142 }
143 }
144
145 pub fn grpc(service_name: impl Into<String>) -> Self {
147 Self {
148 service_name: service_name.into(),
149 transport: OtlpTransport::Grpc,
150 ..Self::default()
151 }
152 }
153
154 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
156 self.endpoint = Some(endpoint.into());
157 self
158 }
159
160 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
162 self.headers.insert(key.into(), value.into());
163 self
164 }
165
166 pub fn with_resource_attribute(
168 mut self,
169 key: impl Into<String>,
170 value: impl Into<String>,
171 ) -> Self {
172 self.resource_attributes.insert(key.into(), value.into());
173 self
174 }
175
176 pub fn with_timeout(mut self, timeout: Duration) -> Self {
178 self.timeout = timeout;
179 self
180 }
181
182 pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
184 self.service_namespace = Some(namespace.into());
185 self
186 }
187
188 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
190 self.service_version = Some(version.into());
191 self
192 }
193
194 pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
196 self.instrumentation_scope = scope.into();
197 self
198 }
199}
200
201#[derive(Clone)]
203pub struct OpenTelemetrySubscriber {
204 inner: Arc<Inner>,
205}
206
207struct Inner {
208 processor: Arc<Mutex<OtelEventProcessor>>,
209 subscriber: EventSubscriberFn,
210}
211
212impl OpenTelemetrySubscriber {
213 pub fn new(config: OpenTelemetryConfig) -> Result<Self> {
215 #[cfg(not(target_arch = "wasm32"))]
216 if config.transport == OtlpTransport::Grpc && tokio::runtime::Handle::try_current().is_err()
217 {
218 return Err(OpenTelemetryError::MissingTokioRuntime);
219 }
220 #[cfg(target_arch = "wasm32")]
221 if config.transport == OtlpTransport::Grpc {
222 return Err(OpenTelemetryError::UnsupportedTransport { transport: "gRPC" });
223 }
224
225 let provider = build_tracer_provider(&config)?;
226 Ok(Self::from_tracer_provider_with_scope(
227 provider,
228 config.instrumentation_scope,
229 ))
230 }
231
232 pub fn from_tracer_provider(
234 provider: SdkTracerProvider,
235 instrumentation_scope: impl Into<String>,
236 ) -> Self {
237 Self::from_tracer_provider_with_scope(provider, instrumentation_scope.into())
238 }
239
240 fn from_tracer_provider_with_scope(
241 provider: SdkTracerProvider,
242 instrumentation_scope: String,
243 ) -> Self {
244 let processor = Arc::new(Mutex::new(OtelEventProcessor::new(
245 provider,
246 instrumentation_scope,
247 )));
248 let processor_for_callback = Arc::clone(&processor);
249 let subscriber: EventSubscriberFn = Arc::new(move |event: &Event| {
250 let Ok(mut guard) = processor_for_callback.lock() else {
251 return;
254 };
255 guard.process(event);
256 });
257
258 Self {
259 inner: Arc::new(Inner {
260 processor,
261 subscriber,
262 }),
263 }
264 }
265
266 pub fn subscriber(&self) -> EventSubscriberFn {
268 Arc::clone(&self.inner.subscriber)
269 }
270
271 pub fn register(&self, name: &str) -> Result<()> {
273 register_subscriber(name, self.subscriber()).map_err(Into::into)
274 }
275
276 pub fn deregister(&self, name: &str) -> Result<bool> {
278 deregister_subscriber(name).map_err(Into::into)
279 }
280
281 pub fn force_flush(&self) -> Result<()> {
283 flush_subscribers()?;
284 let guard = self.inner.processor.lock().map_err(|_| {
285 OpenTelemetryError::Provider("the subscriber state lock was poisoned".to_string())
286 })?;
287 guard.force_flush()
288 }
289
290 pub fn shutdown(&self) -> Result<()> {
294 flush_subscribers()?;
295 let guard = self.inner.processor.lock().map_err(|_| {
296 OpenTelemetryError::Provider("the subscriber state lock was poisoned".to_string())
297 })?;
298 guard.shutdown()
299 }
300}
301
302#[cfg(target_arch = "wasm32")]
303#[derive(Debug, Clone, Copy, Default)]
304struct WasmHttpClient;
305
306#[cfg(target_arch = "wasm32")]
307#[async_trait]
308impl HttpClient for WasmHttpClient {
309 async fn send_bytes(
310 &self,
311 request: HttpRequest<Bytes>,
312 ) -> std::result::Result<HttpResponse<Bytes>, HttpError> {
313 let (parts, body) = request.into_parts();
314
315 let request = {
316 let request_url = parts.uri.to_string();
317 let init = RequestInit::new();
318 init.set_method(parts.method.as_str());
319 if !body.is_empty() {
320 let body_bytes = js_sys::Uint8Array::from(body.as_ref());
321 init.set_body_opt_u8_array(Some(&body_bytes));
322 }
323
324 let request =
325 WebRequest::new_with_str_and_init(&request_url, &init).map_err(js_error)?;
326 let request_headers = request.headers();
327 for (name, value) in &parts.headers {
328 let value = value
329 .to_str()
330 .map_err(|e| http_error(format!("invalid OTLP HTTP header {name}: {e}")))?;
331 request_headers
332 .set(name.as_str(), value)
333 .map_err(js_error)?;
334 }
335 request
336 };
337
338 let fetch_promise = if let Some(window) = web_sys::window() {
339 window.fetch_with_request(&request)
340 } else {
341 let global = js_sys::global();
342 let fetch = js_sys::Reflect::get(&global, &JsValue::from_str("fetch"))
343 .map_err(js_error)?
344 .dyn_into::<js_sys::Function>()
345 .map_err(js_error)?;
346 fetch.call1(&global, &request).map_err(js_error)?.into()
347 };
348 spawn_local(async move {
351 if let Err(error) = JsFuture::from(fetch_promise).await {
352 web_sys::console::warn_1(&JsValue::from_str(&format!(
353 "OpenTelemetry OTLP/HTTP export failed: {error:?}"
354 )));
355 }
356 });
357
358 HttpResponse::builder()
359 .status(202)
360 .body(Bytes::new())
361 .map_err(|e| http_error(e.to_string()))
362 }
363}
364
365#[cfg(target_arch = "wasm32")]
366fn js_error(value: JsValue) -> HttpError {
367 http_error(
368 value
369 .as_string()
370 .unwrap_or_else(|| format!("JavaScript error: {value:?}")),
371 )
372}
373
374#[cfg(target_arch = "wasm32")]
375fn http_error(message: impl Into<String>) -> HttpError {
376 Box::new(std::io::Error::other(message.into()))
377}
378
379fn build_tracer_provider(config: &OpenTelemetryConfig) -> Result<SdkTracerProvider> {
380 let exporter = match config.transport {
381 OtlpTransport::HttpBinary => {
382 #[cfg(not(target_arch = "wasm32"))]
383 install_rustls_crypto_provider();
384 let mut builder = SpanExporter::builder()
385 .with_http()
386 .with_protocol(Protocol::HttpBinary)
387 .with_timeout(config.timeout);
388 if let Some(endpoint) = &config.endpoint {
389 builder = builder.with_endpoint(endpoint.clone());
390 }
391 if !config.headers.is_empty() {
392 builder = builder.with_headers(config.headers.clone());
393 }
394 #[cfg(target_arch = "wasm32")]
395 {
396 builder = builder.with_http_client(WasmHttpClient);
397 }
398 builder
399 .build()
400 .map_err(|e| OpenTelemetryError::ExporterBuild(e.to_string()))?
401 }
402 #[cfg(not(target_arch = "wasm32"))]
403 OtlpTransport::Grpc => {
404 let mut builder = SpanExporter::builder()
405 .with_tonic()
406 .with_protocol(Protocol::Grpc)
407 .with_timeout(config.timeout);
408 if let Some(endpoint) = &config.endpoint {
409 builder = builder.with_endpoint(endpoint.clone());
410 }
411 if !config.headers.is_empty() {
412 builder = builder.with_metadata(build_grpc_metadata(&config.headers)?);
413 }
414 builder
415 .build()
416 .map_err(|e| OpenTelemetryError::ExporterBuild(e.to_string()))?
417 }
418 #[cfg(target_arch = "wasm32")]
419 OtlpTransport::Grpc => {
420 return Err(OpenTelemetryError::UnsupportedTransport { transport: "gRPC" });
421 }
422 };
423
424 let mut resource_attributes = vec![KeyValue::new("service.name", config.service_name.clone())];
425 if let Some(service_namespace) = &config.service_namespace {
426 resource_attributes.push(KeyValue::new(
427 "service.namespace",
428 service_namespace.clone(),
429 ));
430 }
431 if let Some(service_version) = &config.service_version {
432 resource_attributes.push(KeyValue::new("service.version", service_version.clone()));
433 }
434 for (key, value) in &config.resource_attributes {
435 resource_attributes.push(KeyValue::new(key.clone(), value.clone()));
436 }
437
438 let builder = SdkTracerProvider::builder()
442 .with_resource(
443 Resource::builder_empty()
444 .with_attributes(resource_attributes)
445 .build(),
446 )
447 .with_max_attributes_per_span(u32::MAX)
448 .with_max_attributes_per_event(u32::MAX);
449
450 #[cfg(not(target_arch = "wasm32"))]
451 {
452 if Handle::try_current().is_ok() {
453 Ok(builder.with_batch_exporter(exporter).build())
454 } else {
455 Ok(builder.with_simple_exporter(exporter).build())
456 }
457 }
458 #[cfg(target_arch = "wasm32")]
459 {
460 Ok(builder.with_simple_exporter(exporter).build())
461 }
462}
463
464#[cfg(not(target_arch = "wasm32"))]
465fn install_rustls_crypto_provider() {
466 let _ = rustls::crypto::ring::default_provider().install_default();
467}
468
469#[cfg(not(target_arch = "wasm32"))]
470fn build_grpc_metadata(headers: &HashMap<String, String>) -> Result<MetadataMap> {
471 let mut metadata = MetadataMap::new();
472 for (key, value) in headers {
473 let metadata_key = MetadataKey::from_bytes(key.as_bytes()).map_err(|e| {
474 OpenTelemetryError::InvalidGrpcHeader {
475 key: key.clone(),
476 message: e.to_string(),
477 }
478 })?;
479 let metadata_value = MetadataValue::try_from(value.as_str()).map_err(|e| {
480 OpenTelemetryError::InvalidGrpcHeader {
481 key: key.clone(),
482 message: e.to_string(),
483 }
484 })?;
485 metadata.insert(metadata_key, metadata_value);
486 }
487 Ok(metadata)
488}
489
490struct ActiveSpan {
491 span: Span,
492 span_context: SpanContext,
493}
494
495struct OtelEventProcessor {
496 active_spans: HashMap<Uuid, ActiveSpan>,
497 provider: SdkTracerProvider,
498 tracer: SdkTracer,
499}
500
501impl OtelEventProcessor {
502 fn new(provider: SdkTracerProvider, instrumentation_scope: String) -> Self {
503 let tracer = provider.tracer(instrumentation_scope);
504 Self {
505 active_spans: HashMap::new(),
506 provider,
507 tracer,
508 }
509 }
510
511 fn process(&mut self, event: &Event) {
512 match event.scope_category() {
513 Some(ScopeCategory::Start) => self.process_start(event),
514 Some(ScopeCategory::End) => self.process_end(event),
515 None => self.process_mark(event),
516 }
517 }
518
519 fn force_flush(&self) -> Result<()> {
520 self.provider
521 .force_flush()
522 .map_err(|e| OpenTelemetryError::Provider(e.to_string()))
523 }
524
525 fn shutdown(&self) -> Result<()> {
526 self.provider
527 .shutdown()
528 .map_err(|e| OpenTelemetryError::Provider(e.to_string()))
529 }
530
531 fn process_start(&mut self, event: &Event) {
532 let mut span = self
533 .tracer
534 .span_builder(span_name(event))
535 .with_kind(span_kind(event))
536 .with_start_time(to_system_time(*event.timestamp()))
537 .start_with_context(&self.tracer, &self.parent_context(event));
538 span.set_attributes(start_attributes(event));
539 let span_context = local_parent_span_context(span.span_context());
540 self.active_spans
541 .insert(event.uuid(), ActiveSpan { span, span_context });
542 }
543
544 fn process_end(&mut self, event: &Event) {
545 let Some(mut active_span) = self.active_spans.remove(&event.uuid()) else {
546 return;
547 };
548
549 super::set_span_status_from_event_metadata(&mut active_span.span, event);
550 active_span.span.set_attributes(end_attributes(event));
551 active_span
552 .span
553 .end_with_timestamp(to_system_time(*event.timestamp()));
554 }
555
556 fn process_mark(&mut self, event: &Event) {
557 let mark_name = event.name().to_string();
558 let timestamp = to_system_time(*event.timestamp());
559 let attributes = mark_attributes(event);
560
561 if let Some(parent_span) = self.find_parent_span_mut(event) {
562 parent_span
563 .span
564 .add_event_with_timestamp(mark_name, timestamp, attributes);
565 return;
566 }
567
568 let mut span = self
569 .tracer
570 .span_builder(format!("mark:{mark_name}"))
571 .with_kind(SpanKind::Internal)
572 .with_start_time(timestamp)
573 .start_with_context(&self.tracer, &self.parent_context(event));
574 let mut span_attributes = attributes;
575 span_attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
576 span.set_attributes(span_attributes);
577 span.end_with_timestamp(timestamp);
578 }
579
580 fn parent_context(&self, event: &Event) -> Context {
581 self.find_parent_span(event)
582 .map(|active_span| {
583 Context::new().with_remote_span_context(active_span.span_context.clone())
584 })
585 .unwrap_or_default()
586 }
587
588 fn parent_span_uuid(&self, event: &Event) -> Option<Uuid> {
589 event
590 .parent_uuid()
591 .filter(|uuid| self.active_spans.contains_key(uuid))
592 }
593
594 fn find_parent_span(&self, event: &Event) -> Option<&ActiveSpan> {
595 self.parent_span_uuid(event)
596 .and_then(|uuid| self.active_spans.get(&uuid))
597 }
598
599 fn find_parent_span_mut(&mut self, event: &Event) -> Option<&mut ActiveSpan> {
600 self.parent_span_uuid(event)
601 .and_then(|uuid| self.active_spans.get_mut(&uuid))
602 }
603}
604
605fn span_kind(event: &Event) -> SpanKind {
606 match semantic_scope_type(event) {
607 Some(ScopeType::Llm) => SpanKind::Client,
608 Some(
609 ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
610 ) => SpanKind::Client,
611 _ => SpanKind::Internal,
612 }
613}
614
615fn span_name(event: &Event) -> String {
616 event.name().to_string()
617}
618
619fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
620 event.scope_type()
621}
622
623fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
624 match scope_type {
625 Some(ScopeType::Agent) => "agent",
626 Some(ScopeType::Function) => "function",
627 Some(ScopeType::Tool) => "tool",
628 Some(ScopeType::Llm) => "llm",
629 Some(ScopeType::Retriever) => "retriever",
630 Some(ScopeType::Embedder) => "embedder",
631 Some(ScopeType::Reranker) => "reranker",
632 Some(ScopeType::Guardrail) => "guardrail",
633 Some(ScopeType::Evaluator) => "evaluator",
634 Some(ScopeType::Custom) => "custom",
635 Some(ScopeType::Unknown) | None => "unknown",
636 }
637}
638
639fn start_attributes(event: &Event) -> Vec<KeyValue> {
640 let mut attributes = common_attributes(event);
641 let handle_attributes = event.attributes();
642 push_serialized(
643 &mut attributes,
644 "nemo_relay.handle_attributes_json",
645 handle_attributes,
646 );
647 push_serialized(&mut attributes, "nemo_relay.start.data_json", event.data());
648 push_serialized(
649 &mut attributes,
650 "nemo_relay.start.metadata_json",
651 event.metadata(),
652 );
653 push_serialized(
654 &mut attributes,
655 "nemo_relay.start.input_json",
656 event.input(),
657 );
658 attributes
659}
660
661fn end_attributes(event: &Event) -> Vec<KeyValue> {
662 let mut attributes = Vec::new();
663 push_serialized(&mut attributes, "nemo_relay.end.data_json", event.data());
664
665 let metadata = event.metadata();
666 push_serialized(&mut attributes, "nemo_relay.end.metadata_json", metadata);
667 push_serialized(
668 &mut attributes,
669 "nemo_relay.end.output_json",
670 event.output(),
671 );
672 if event
673 .category()
674 .is_some_and(|category| category.as_str() == "llm")
675 && let Some((cost, currency)) = cost_from_llm_event(event)
676 {
677 attributes.push(KeyValue::new("nemo_relay.llm.cost.total", cost));
678 attributes.push(KeyValue::new("nemo_relay.llm.cost.currency", currency));
679 }
680 attributes
681}
682
683fn cost_from_llm_event(event: &Event) -> Option<(f64, String)> {
684 if let Some(cost) = cost_from_manual_llm_output(event.output()) {
685 return Some(cost);
686 }
687 if let Some(response) = event.annotated_response()
688 && let Some(usage) = response.usage.as_ref()
689 {
690 if let Some(cost) = usage.cost.as_ref() {
691 return cost_total_and_currency(cost);
692 }
693 if let Some(model_name) = response.model.as_deref().or_else(|| event.model_name()) {
694 return estimate_cost_for_provider(Some(event.name()), model_name, usage)
695 .and_then(|cost| cost_total_and_currency(&cost));
696 }
697 }
698 let usage = usage_from_manual_llm_output(event.output())?;
699 let model_name = event
700 .model_name()
701 .or_else(|| model_name_from_manual_llm_output(event.output()))?;
702 estimate_cost_for_provider(Some(event.name()), model_name, &usage)
703 .and_then(|cost| cost_total_and_currency(&cost))
704}
705
706fn cost_total_and_currency(cost: &CostEstimate) -> Option<(f64, String)> {
707 Some((cost.total_or_component_sum()?, cost.currency.clone()))
708}
709
710fn cost_from_manual_llm_output(output: Option<&Json>) -> Option<(f64, String)> {
711 let object = output?.as_object()?;
712 let usage = object.get("usage").and_then(Json::as_object);
713 let token_usage = object.get("token_usage").and_then(Json::as_object);
714 usage
715 .and_then(cost_from_manual_usage)
716 .or_else(|| token_usage.and_then(cost_from_manual_usage))
717}
718
719fn cost_from_manual_usage(usage: &serde_json::Map<String, Json>) -> Option<(f64, String)> {
720 usage
721 .get("cost_usd")
722 .and_then(Json::as_f64)
723 .map(|total| (total, "USD".to_string()))
724 .or_else(|| {
725 let cost = usage.get("cost")?.as_object()?;
726 let total = cost.get("total").and_then(Json::as_f64).or_else(|| {
727 let (has_component, component_total) =
728 ["input", "output", "cache_read", "cache_write"]
729 .iter()
730 .filter_map(|field| cost.get(*field).and_then(Json::as_f64))
731 .fold((false, 0.0), |(_, total), value| (true, total + value));
732 has_component.then_some(component_total)
733 })?;
734 Some((
735 total,
736 cost.get("currency")
737 .and_then(Json::as_str)
738 .unwrap_or("USD")
739 .to_string(),
740 ))
741 })
742}
743
744fn usage_from_manual_llm_output(output: Option<&Json>) -> Option<Usage> {
745 let object = output?.as_object()?;
746 let usage = object.get("usage").and_then(Json::as_object);
747 let token_usage = object.get("token_usage").and_then(Json::as_object);
748 if usage.is_none() && token_usage.is_none() {
749 return None;
750 }
751
752 let prompt_tokens = first_u64_from_manual_usage(
753 usage,
754 token_usage,
755 &["prompt_tokens", "input_tokens", "inputTokens", "input"],
756 );
757 let completion_tokens = first_u64_from_manual_usage(
758 usage,
759 token_usage,
760 &[
761 "completion_tokens",
762 "output_tokens",
763 "completionTokens",
764 "outputTokens",
765 "output",
766 ],
767 );
768 let reported_total_tokens = first_u64_from_manual_usage(
769 usage,
770 token_usage,
771 &["total_tokens", "totalTokens", "total"],
772 );
773 let cache_read_tokens = first_u64_from_manual_usage(
774 usage,
775 token_usage,
776 &[
777 "cache_read_tokens",
778 "cached_tokens",
779 "cache_read_input_tokens",
780 "cacheReadTokens",
781 "cachedTokens",
782 "cacheReadInputTokens",
783 "cacheRead",
784 ],
785 )
786 .or_else(|| {
787 first_nested_u64_from_manual_usage(
788 usage,
789 token_usage,
790 "input_tokens_details",
791 "cached_tokens",
792 )
793 })
794 .or_else(|| {
795 first_nested_u64_from_manual_usage(
796 usage,
797 token_usage,
798 "prompt_tokens_details",
799 "cached_tokens",
800 )
801 });
802 let cache_write_tokens = first_u64_from_manual_usage(
803 usage,
804 token_usage,
805 &[
806 "cache_write_tokens",
807 "cache_creation_input_tokens",
808 "cacheWriteTokens",
809 "cacheCreationInputTokens",
810 "cacheWrite",
811 ],
812 );
813
814 if prompt_tokens.is_none()
815 && completion_tokens.is_none()
816 && reported_total_tokens.is_none()
817 && cache_read_tokens.is_none()
818 && cache_write_tokens.is_none()
819 {
820 return None;
821 }
822
823 Some(Usage {
824 prompt_tokens,
825 completion_tokens,
826 total_tokens: normalize_total_tokens(
827 reported_total_tokens,
828 prompt_tokens,
829 completion_tokens,
830 ),
831 cache_read_tokens,
832 cache_write_tokens,
833 cost: None,
834 })
835}
836
837fn model_name_from_manual_llm_output(output: Option<&Json>) -> Option<&str> {
838 output?.as_object()?.get("model").and_then(Json::as_str)
839}
840
841fn first_u64_from_manual_usage(
842 usage: Option<&serde_json::Map<String, Json>>,
843 token_usage: Option<&serde_json::Map<String, Json>>,
844 keys: &[&str],
845) -> Option<u64> {
846 keys.iter().find_map(|key| {
847 usage
848 .and_then(|usage| usage.get(*key).and_then(Json::as_u64))
849 .or_else(|| token_usage.and_then(|usage| usage.get(*key).and_then(Json::as_u64)))
850 })
851}
852
853fn first_nested_u64_from_manual_usage(
854 usage: Option<&serde_json::Map<String, Json>>,
855 token_usage: Option<&serde_json::Map<String, Json>>,
856 parent: &str,
857 key: &str,
858) -> Option<u64> {
859 usage
860 .and_then(|usage| usage.get(parent).and_then(Json::as_object))
861 .and_then(|details| details.get(key).and_then(Json::as_u64))
862 .or_else(|| {
863 token_usage
864 .and_then(|usage| usage.get(parent).and_then(Json::as_object))
865 .and_then(|details| details.get(key).and_then(Json::as_u64))
866 })
867}
868
869fn normalize_total_tokens(
870 reported_total_tokens: Option<u64>,
871 prompt_tokens: Option<u64>,
872 completion_tokens: Option<u64>,
873) -> Option<u64> {
874 let calculated_total = match (prompt_tokens, completion_tokens) {
875 (Some(prompt), Some(completion)) => Some(prompt + completion),
876 (Some(prompt), None) => Some(prompt),
877 (None, Some(completion)) => Some(completion),
878 (None, None) => None,
879 };
880 match (reported_total_tokens, calculated_total) {
881 (Some(reported), Some(calculated)) if reported >= calculated => Some(reported),
882 (Some(_), Some(calculated)) => Some(calculated),
883 (Some(reported), None) => Some(reported),
884 (None, calculated) => calculated,
885 }
886}
887
888fn mark_attributes(event: &Event) -> Vec<KeyValue> {
889 let handle_attributes = event.attributes();
890 let mut attributes = vec![
891 KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
892 KeyValue::new(
893 "nemo_relay.mark.parent_uuid",
894 event
895 .parent_uuid()
896 .map(|uuid| uuid.to_string())
897 .unwrap_or_default(),
898 ),
899 ];
900 push_serialized(
901 &mut attributes,
902 "nemo_relay.mark.attributes_json",
903 handle_attributes,
904 );
905 push_serialized(&mut attributes, "nemo_relay.mark.data_json", event.data());
906 push_serialized(
907 &mut attributes,
908 "nemo_relay.mark.metadata_json",
909 event.metadata(),
910 );
911 attributes
912}
913
914fn common_attributes(event: &Event) -> Vec<KeyValue> {
915 let mut attributes = vec![
916 KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
917 KeyValue::new(
918 "nemo_relay.parent_uuid",
919 event
920 .parent_uuid()
921 .map(|uuid| uuid.to_string())
922 .unwrap_or_default(),
923 ),
924 KeyValue::new(
925 "nemo_relay.scope_type",
926 scope_type_name(semantic_scope_type(event)),
927 ),
928 ];
929
930 if let Some(model_name) = event.model_name() {
931 attributes.push(KeyValue::new(
932 "nemo_relay.model_name",
933 model_name.to_string(),
934 ));
935 }
936 if let Some(tool_call_id) = event.tool_call_id() {
937 attributes.push(KeyValue::new(
938 "nemo_relay.tool_call_id",
939 tool_call_id.to_string(),
940 ));
941 }
942
943 attributes
944}
945
946fn push_serialized<T: Serialize + ?Sized>(
947 attributes: &mut Vec<KeyValue>,
948 key: &'static str,
949 value: Option<&T>,
950) {
951 if let Some(value) = value
952 && let Ok(json) = serde_json::to_string(value)
953 {
954 attributes.push(KeyValue::new(key, json));
955 }
956}
957
958fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
959 SpanContext::new(
960 span_context.trace_id(),
961 span_context.span_id(),
962 span_context.trace_flags(),
963 false,
964 span_context.trace_state().clone(),
965 )
966}
967
968fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
969 let seconds = timestamp.timestamp();
970 let nanos = timestamp.timestamp_subsec_nanos();
971 if seconds >= 0 {
972 UNIX_EPOCH + Duration::new(seconds as u64, nanos)
973 } else if nanos == 0 {
974 UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
975 } else {
976 UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
977 }
978}
979
980#[cfg(test)]
981#[path = "../../tests/unit/observability/otel_tests.rs"]
982mod tests;