Skip to main content

nemo_relay/observability/
otel.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenTelemetry subscriber support for NeMo Relay.
5//!
6//! This crate adapts NeMo Relay lifecycle events into OpenTelemetry trace spans:
7//!
8//! - scope/tool/LLM `Start` events open spans
9//! - matching `End` events close spans
10//! - `Mark` events become span events on the active parent span when possible
11//! - orphan marks fall back to zero-duration spans so they still reach OTLP
12//!
13//! The public API is intentionally small:
14//!
15//! - [`OpenTelemetryConfig`] configures the OTLP exporter and resource metadata
16//! - [`OpenTelemetrySubscriber`] exposes a NeMo Relay [`EventSubscriberFn`] and
17//!   convenience `register` / `deregister` / `force_flush` / `shutdown` methods
18
19use 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
61/// Result type for the OpenTelemetry subscriber crate.
62pub type Result<T> = std::result::Result<T, OpenTelemetryError>;
63
64/// Errors produced while configuring or operating the OpenTelemetry subscriber.
65#[derive(Debug, thiserror::Error)]
66pub enum OpenTelemetryError {
67    /// The tonic gRPC exporter requires an active Tokio runtime.
68    #[error("the OTLP gRPC exporter requires an active Tokio runtime")]
69    MissingTokioRuntime,
70    /// The requested transport is not available on this target.
71    #[error("the OTLP {transport} transport is not supported on this target")]
72    UnsupportedTransport {
73        /// Human-readable transport label used in the error message.
74        transport: &'static str,
75    },
76    /// Failed to parse a configured gRPC metadata header.
77    #[error("invalid OTLP gRPC header {key:?}: {message}")]
78    InvalidGrpcHeader {
79        /// Header name that failed to parse.
80        key: String,
81        /// Parser failure message.
82        message: String,
83    },
84    /// Failed to build the OTLP exporter.
85    #[error("failed to build the OTLP exporter: {0}")]
86    ExporterBuild(String),
87    /// The underlying tracer provider returned an error.
88    #[error("OpenTelemetry tracer provider error: {0}")]
89    Provider(String),
90    /// Registration errors from the core runtime.
91    #[error(transparent)]
92    Core(#[from] FlowError),
93}
94
95/// Supported OTLP trace transports.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum OtlpTransport {
98    /// OTLP/HTTP protobuf, typically `http://host:4318/v1/traces`.
99    #[default]
100    HttpBinary,
101    /// OTLP/gRPC, typically `http://host:4317`.
102    Grpc,
103}
104
105/// Configuration for the OpenTelemetry subscriber.
106#[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    /// Creates an HTTP OTLP config for the given service name.
137    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    /// Creates a gRPC OTLP config for the given service name.
146    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    /// Overrides the OTLP endpoint. If unset, exporter defaults and OTEL_* env vars apply.
155    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
156        self.endpoint = Some(endpoint.into());
157        self
158    }
159
160    /// Adds a header/metadata entry for the exporter.
161    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    /// Adds a resource attribute as a string key/value pair.
167    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    /// Sets the OTLP request timeout.
177    pub fn with_timeout(mut self, timeout: Duration) -> Self {
178        self.timeout = timeout;
179        self
180    }
181
182    /// Sets the service namespace resource attribute.
183    pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
184        self.service_namespace = Some(namespace.into());
185        self
186    }
187
188    /// Sets the service version resource attribute.
189    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
190        self.service_version = Some(version.into());
191        self
192    }
193
194    /// Sets the instrumentation scope name used for emitted spans.
195    pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
196        self.instrumentation_scope = scope.into();
197        self
198    }
199}
200
201/// OpenTelemetry-backed NeMo Relay subscriber.
202#[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    /// Builds a subscriber backed by a new OTLP tracer provider.
214    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    /// Builds a subscriber from an already-configured tracer provider.
233    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                // Observability should not take down the host process if the
252                // subscriber state was previously poisoned.
253                return;
254            };
255            guard.process(event);
256        });
257
258        Self {
259            inner: Arc::new(Inner {
260                processor,
261                subscriber,
262            }),
263        }
264    }
265
266    /// Returns the raw NeMo Relay subscriber callback for custom registration flows.
267    pub fn subscriber(&self) -> EventSubscriberFn {
268        Arc::clone(&self.inner.subscriber)
269    }
270
271    /// Registers this subscriber globally with the NeMo Relay runtime.
272    pub fn register(&self, name: &str) -> Result<()> {
273        register_subscriber(name, self.subscriber()).map_err(Into::into)
274    }
275
276    /// Deregisters a previously-registered global subscriber by name.
277    pub fn deregister(&self, name: &str) -> Result<bool> {
278        deregister_subscriber(name).map_err(Into::into)
279    }
280
281    /// Flushes finished spans through the underlying tracer provider.
282    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    /// Shuts down the underlying tracer provider.
291    ///
292    /// Call `deregister(...)` first if the subscriber is still registered with NeMo Relay.
293    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        // Waiting on the fetch promise from a synchronous wasm call stack can deadlock
349        // Node/browser event processing, so dispatch the request asynchronously.
350        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    // Disable per-span attribute caps. Consumers may emit large attribute
439    // sets on long-running spans; the OTel SDK default (128) silently drops
440    // attributes added last in the span's lifecycle.
441    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;