Skip to main content

nemo_relay/observability/
openinference.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenInference subscriber support for NeMo Relay.
5//!
6//! This crate adapts NeMo Relay lifecycle events into OpenInference 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//! - [`OpenInferenceConfig`] configures the OTLP exporter and OpenInference metadata
16//! - [`OpenInferenceSubscriber`] 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, ScopeCategory};
24use crate::api::runtime::EventSubscriberFn;
25use crate::api::scope::ScopeType;
26use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
27use crate::codec::response::Usage;
28use crate::error::FlowError;
29use crate::json::Json;
30use chrono::{DateTime, Utc};
31use openinference_semantic_conventions::SpanKind as OpenInferenceSpanKind;
32use openinference_semantic_conventions::attributes as oi;
33use opentelemetry::trace::{
34    Span as _, SpanContext, SpanKind, TraceContextExt, Tracer, TracerProvider as _,
35};
36use opentelemetry::{Context, KeyValue};
37use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig, WithHttpConfig};
38use opentelemetry_sdk::Resource;
39use opentelemetry_sdk::trace::{SdkTracer, SdkTracerProvider, Span};
40use serde::Serialize;
41use uuid::Uuid;
42
43#[cfg(target_arch = "wasm32")]
44use async_trait::async_trait;
45#[cfg(target_arch = "wasm32")]
46use opentelemetry_http::{
47    Bytes, HttpClient, HttpError, Request as HttpRequest, Response as HttpResponse,
48};
49#[cfg(not(target_arch = "wasm32"))]
50use opentelemetry_otlp::WithTonicConfig;
51#[cfg(not(target_arch = "wasm32"))]
52use tokio::runtime::Handle;
53#[cfg(not(target_arch = "wasm32"))]
54use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
55#[cfg(target_arch = "wasm32")]
56use wasm_bindgen::{JsCast, JsValue};
57#[cfg(target_arch = "wasm32")]
58use wasm_bindgen_futures::{JsFuture, spawn_local};
59#[cfg(target_arch = "wasm32")]
60use web_sys::{Request as WebRequest, RequestInit};
61
62/// Result type for the OpenInference subscriber crate.
63pub type Result<T> = std::result::Result<T, OpenInferenceError>;
64
65/// Errors produced while configuring or operating the OpenInference subscriber.
66#[derive(Debug, thiserror::Error)]
67pub enum OpenInferenceError {
68    /// The tonic gRPC exporter requires an active Tokio runtime.
69    #[error("the OTLP gRPC exporter requires an active Tokio runtime")]
70    MissingTokioRuntime,
71    /// The requested transport is not available on this target.
72    #[error("the OTLP {transport} transport is not supported on this target")]
73    UnsupportedTransport {
74        /// Human-readable transport label used in the error message.
75        transport: &'static str,
76    },
77    /// Failed to parse a configured gRPC metadata header.
78    #[error("invalid OTLP gRPC header {key:?}: {message}")]
79    InvalidGrpcHeader {
80        /// Header name that failed to parse.
81        key: String,
82        /// Parser failure message.
83        message: String,
84    },
85    /// Failed to build the OTLP exporter.
86    #[error("failed to build the OTLP exporter: {0}")]
87    ExporterBuild(String),
88    /// The underlying tracer provider returned an error.
89    #[error("OpenInference tracer provider error: {0}")]
90    Provider(String),
91    /// Registration errors from the core runtime.
92    #[error(transparent)]
93    Core(#[from] FlowError),
94}
95
96/// Supported OTLP trace transports.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum OtlpTransport {
99    /// OTLP/HTTP protobuf, typically `http://host:4318/v1/traces`.
100    #[default]
101    HttpBinary,
102    /// OTLP/gRPC, typically `http://host:4317`.
103    Grpc,
104}
105
106/// Configuration for the OpenInference subscriber.
107#[derive(Debug, Clone)]
108pub struct OpenInferenceConfig {
109    endpoint: Option<String>,
110    headers: HashMap<String, String>,
111    resource_attributes: HashMap<String, String>,
112    service_name: String,
113    service_namespace: Option<String>,
114    service_version: Option<String>,
115    instrumentation_scope: String,
116    timeout: Duration,
117    transport: OtlpTransport,
118}
119
120impl Default for OpenInferenceConfig {
121    fn default() -> Self {
122        Self {
123            endpoint: None,
124            headers: HashMap::new(),
125            resource_attributes: HashMap::new(),
126            service_name: "nemo-relay".to_string(),
127            service_namespace: None,
128            service_version: None,
129            instrumentation_scope: "nemo-relay-openinference".to_string(),
130            timeout: Duration::from_secs(3),
131            transport: OtlpTransport::HttpBinary,
132        }
133    }
134}
135
136impl OpenInferenceConfig {
137    /// Creates a config with sensible defaults.
138    pub fn new() -> Self {
139        Self::default()
140    }
141
142    /// Selects the OTLP transport.
143    pub fn with_transport(mut self, transport: OtlpTransport) -> Self {
144        self.transport = transport;
145        self
146    }
147
148    /// Sets the `service.name` resource attribute.
149    pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
150        self.service_name = service_name.into();
151        self
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/// OpenInference-backed NeMo Relay subscriber.
202#[derive(Clone)]
203pub struct OpenInferenceSubscriber {
204    inner: Arc<Inner>,
205}
206
207struct Inner {
208    processor: Arc<Mutex<OpenInferenceEventProcessor>>,
209    subscriber: EventSubscriberFn,
210}
211
212impl OpenInferenceSubscriber {
213    /// Builds a subscriber backed by a new OTLP tracer provider.
214    pub fn new(config: OpenInferenceConfig) -> 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(OpenInferenceError::MissingTokioRuntime);
219        }
220        #[cfg(target_arch = "wasm32")]
221        if config.transport == OtlpTransport::Grpc {
222            return Err(OpenInferenceError::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(OpenInferenceEventProcessor::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            OpenInferenceError::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            OpenInferenceError::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                    "OpenInference 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: &OpenInferenceConfig) -> 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| OpenInferenceError::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| OpenInferenceError::ExporterBuild(e.to_string()))?
417        }
418        #[cfg(target_arch = "wasm32")]
419        OtlpTransport::Grpc => {
420            return Err(OpenInferenceError::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. OpenInference emits many flat
439    // `llm.input_messages.*` attributes on long conversations; the OTel SDK
440    // default (128) silently drops attributes added last in the span's
441    // lifecycle, notably `llm.token_count.*` emitted at span end.
442    let builder = SdkTracerProvider::builder()
443        .with_resource(
444            Resource::builder_empty()
445                .with_attributes(resource_attributes)
446                .build(),
447        )
448        .with_max_attributes_per_span(u32::MAX)
449        .with_max_attributes_per_event(u32::MAX);
450
451    #[cfg(not(target_arch = "wasm32"))]
452    {
453        if Handle::try_current().is_ok() {
454            Ok(builder.with_batch_exporter(exporter).build())
455        } else {
456            Ok(builder.with_simple_exporter(exporter).build())
457        }
458    }
459    #[cfg(target_arch = "wasm32")]
460    {
461        Ok(builder.with_simple_exporter(exporter).build())
462    }
463}
464
465#[cfg(not(target_arch = "wasm32"))]
466fn install_rustls_crypto_provider() {
467    let _ = rustls::crypto::ring::default_provider().install_default();
468}
469
470#[cfg(not(target_arch = "wasm32"))]
471fn build_grpc_metadata(headers: &HashMap<String, String>) -> Result<MetadataMap> {
472    let mut metadata = MetadataMap::new();
473    for (key, value) in headers {
474        let metadata_key = MetadataKey::from_bytes(key.as_bytes()).map_err(|e| {
475            OpenInferenceError::InvalidGrpcHeader {
476                key: key.clone(),
477                message: e.to_string(),
478            }
479        })?;
480        let metadata_value = MetadataValue::try_from(value.as_str()).map_err(|e| {
481            OpenInferenceError::InvalidGrpcHeader {
482                key: key.clone(),
483                message: e.to_string(),
484            }
485        })?;
486        metadata.insert(metadata_key, metadata_value);
487    }
488    Ok(metadata)
489}
490
491struct ActiveSpan {
492    span: Span,
493    span_context: SpanContext,
494}
495
496struct OpenInferenceEventProcessor {
497    active_spans: HashMap<Uuid, ActiveSpan>,
498    provider: SdkTracerProvider,
499    tracer: SdkTracer,
500}
501
502impl OpenInferenceEventProcessor {
503    fn new(provider: SdkTracerProvider, instrumentation_scope: String) -> Self {
504        let tracer = provider.tracer(instrumentation_scope);
505        Self {
506            active_spans: HashMap::new(),
507            provider,
508            tracer,
509        }
510    }
511
512    fn process(&mut self, event: &Event) {
513        match event.scope_category() {
514            Some(ScopeCategory::Start) => self.process_start(event),
515            Some(ScopeCategory::End) => self.process_end(event),
516            None => self.process_mark(event),
517        }
518    }
519
520    fn force_flush(&self) -> Result<()> {
521        self.provider
522            .force_flush()
523            .map_err(|e| OpenInferenceError::Provider(e.to_string()))
524    }
525
526    fn shutdown(&self) -> Result<()> {
527        self.provider
528            .shutdown()
529            .map_err(|e| OpenInferenceError::Provider(e.to_string()))
530    }
531
532    fn process_start(&mut self, event: &Event) {
533        let mut span = self
534            .tracer
535            .span_builder(span_name(event))
536            .with_kind(span_kind(event))
537            .with_start_time(to_system_time(*event.timestamp()))
538            .start_with_context(&self.tracer, &self.parent_context(event));
539        span.set_attributes(start_attributes(event));
540        let span_context = local_parent_span_context(span.span_context());
541        self.active_spans
542            .insert(event.uuid(), ActiveSpan { span, span_context });
543    }
544
545    fn process_end(&mut self, event: &Event) {
546        let Some(mut active_span) = self.active_spans.remove(&event.uuid()) else {
547            return;
548        };
549        active_span.span.set_attributes(end_attributes(event));
550        active_span
551            .span
552            .end_with_timestamp(to_system_time(*event.timestamp()));
553    }
554
555    fn process_mark(&mut self, event: &Event) {
556        let mark_name = event.name().to_string();
557        let timestamp = to_system_time(*event.timestamp());
558        let attributes = mark_attributes(event);
559
560        if let Some(parent_span) = self.find_parent_span_mut(event) {
561            parent_span
562                .span
563                .add_event_with_timestamp(mark_name, timestamp, attributes);
564            return;
565        }
566
567        let mut span = self
568            .tracer
569            .span_builder(format!("mark:{mark_name}"))
570            .with_kind(SpanKind::Internal)
571            .with_start_time(timestamp)
572            .start_with_context(&self.tracer, &self.parent_context(event));
573        let mut span_attributes = attributes;
574        span_attributes.push(KeyValue::new(
575            oi::OPENINFERENCE_SPAN_KIND,
576            OpenInferenceSpanKind::Chain,
577        ));
578        span_attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
579        span.set_attributes(span_attributes);
580        span.end_with_timestamp(timestamp);
581    }
582
583    fn parent_context(&self, event: &Event) -> Context {
584        self.find_parent_span(event)
585            .map(|active_span| {
586                Context::new().with_remote_span_context(active_span.span_context.clone())
587            })
588            .unwrap_or_default()
589    }
590
591    fn parent_span_uuid(&self, event: &Event) -> Option<Uuid> {
592        event
593            .parent_uuid()
594            .filter(|uuid| self.active_spans.contains_key(uuid))
595    }
596
597    fn find_parent_span(&self, event: &Event) -> Option<&ActiveSpan> {
598        self.parent_span_uuid(event)
599            .and_then(|uuid| self.active_spans.get(&uuid))
600    }
601
602    fn find_parent_span_mut(&mut self, event: &Event) -> Option<&mut ActiveSpan> {
603        self.parent_span_uuid(event)
604            .and_then(|uuid| self.active_spans.get_mut(&uuid))
605    }
606}
607
608fn span_kind(event: &Event) -> SpanKind {
609    match semantic_scope_type(event) {
610        Some(ScopeType::Llm) => SpanKind::Client,
611        Some(
612            ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
613        ) => SpanKind::Client,
614        _ => SpanKind::Internal,
615    }
616}
617
618fn span_name(event: &Event) -> String {
619    event.name().to_string()
620}
621
622fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
623    event.scope_type()
624}
625
626fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
627    match scope_type {
628        Some(ScopeType::Agent) => "agent",
629        Some(ScopeType::Function) => "function",
630        Some(ScopeType::Tool) => "tool",
631        Some(ScopeType::Llm) => "llm",
632        Some(ScopeType::Retriever) => "retriever",
633        Some(ScopeType::Embedder) => "embedder",
634        Some(ScopeType::Reranker) => "reranker",
635        Some(ScopeType::Guardrail) => "guardrail",
636        Some(ScopeType::Evaluator) => "evaluator",
637        Some(ScopeType::Custom) => "custom",
638        Some(ScopeType::Unknown) | None => "unknown",
639    }
640}
641
642fn start_attributes(event: &Event) -> Vec<KeyValue> {
643    let mut attributes = common_attributes(event);
644    let handle_attributes = event.attributes();
645    if handle_attributes.is_some_and(|attributes| !attributes.is_empty()) {
646        push_serialized(
647            &mut attributes,
648            "nemo_relay.handle_attributes_json",
649            handle_attributes,
650        );
651    }
652    if event
653        .category()
654        .is_none_or(|category| category.as_str() != "llm")
655    {
656        push_serialized(
657            &mut attributes,
658            "nemo_relay.start.input_json",
659            event.input(),
660        );
661    }
662    if event
663        .category()
664        .is_some_and(|category| category.as_str() == "tool")
665    {
666        attributes.push(KeyValue::new(oi::tool::NAME, event.name().to_string()));
667        attributes.push(KeyValue::new(
668            oi::tool_call::function::NAME,
669            event.name().to_string(),
670        ));
671    }
672
673    if let Some((input, mime_type)) = openinference_input_value(event) {
674        attributes.push(KeyValue::new(oi::input::VALUE, input.clone()));
675        attributes.push(KeyValue::new(oi::input::MIME_TYPE, mime_type));
676
677        if event
678            .category()
679            .is_some_and(|category| category.as_str() == "tool")
680        {
681            attributes.push(KeyValue::new(oi::tool::PARAMETERS, input.clone()));
682            attributes.push(KeyValue::new(oi::tool_call::function::ARGUMENTS, input));
683        }
684    }
685    attributes
686}
687
688fn end_attributes(event: &Event) -> Vec<KeyValue> {
689    let mut attributes = Vec::new();
690    push_serialized(
691        &mut attributes,
692        "nemo_relay.end.output_json",
693        event.output(),
694    );
695    if let Some((output, mime_type)) = openinference_output_value(event) {
696        attributes.push(KeyValue::new(oi::output::VALUE, output));
697        attributes.push(KeyValue::new(oi::output::MIME_TYPE, mime_type));
698    }
699    let fallback_usage = if event
700        .category()
701        .is_some_and(|category| category.as_str() == "llm")
702    {
703        usage_from_manual_llm_output(event.output())
704    } else {
705        None
706    };
707    let usage = event
708        .annotated_response()
709        .and_then(|response| response.usage.as_ref())
710        .or(fallback_usage.as_ref());
711    if event
712        .category()
713        .is_some_and(|category| category.as_str() == "llm")
714        && let Some(usage) = usage
715    {
716        if let Some(v) = usage.prompt_tokens {
717            attributes.push(KeyValue::new(oi::llm::token_count::PROMPT, v as i64));
718        }
719        if let Some(v) = usage.completion_tokens {
720            attributes.push(KeyValue::new(oi::llm::token_count::COMPLETION, v as i64));
721        }
722        if let Some(v) = usage.total_tokens {
723            attributes.push(KeyValue::new(oi::llm::token_count::TOTAL, v as i64));
724        }
725        if let Some(v) = usage.cache_read_tokens {
726            attributes.push(KeyValue::new(
727                oi::llm::token_count::prompt_details::CACHE_READ,
728                v as i64,
729            ));
730        }
731        if let Some(v) = usage.cache_write_tokens {
732            attributes.push(KeyValue::new(
733                oi::llm::token_count::prompt_details::CACHE_WRITE,
734                v as i64,
735            ));
736        }
737    }
738    attributes
739}
740
741fn usage_from_manual_llm_output(output: Option<&Json>) -> Option<Usage> {
742    let object = output?.as_object()?;
743    let usage = object.get("usage").and_then(Json::as_object);
744    let token_usage = object.get("token_usage").and_then(Json::as_object);
745    if usage.is_none() && token_usage.is_none() {
746        return None;
747    }
748
749    let prompt_tokens = first_u64_from_manual_usage(
750        usage,
751        token_usage,
752        &["prompt_tokens", "input_tokens", "inputTokens", "input"],
753    );
754    let completion_tokens = first_u64_from_manual_usage(
755        usage,
756        token_usage,
757        &[
758            "completion_tokens",
759            "output_tokens",
760            "completionTokens",
761            "outputTokens",
762            "output",
763        ],
764    );
765    let reported_total_tokens = first_u64_from_manual_usage(
766        usage,
767        token_usage,
768        &["total_tokens", "totalTokens", "total"],
769    );
770    let cache_read_tokens = first_u64_from_manual_usage(
771        usage,
772        token_usage,
773        &[
774            "cache_read_tokens",
775            "cached_tokens",
776            "cache_read_input_tokens",
777            "cacheReadTokens",
778            "cachedTokens",
779            "cacheReadInputTokens",
780            "cacheRead",
781        ],
782    );
783    let cache_write_tokens = first_u64_from_manual_usage(
784        usage,
785        token_usage,
786        &[
787            "cache_write_tokens",
788            "cache_creation_input_tokens",
789            "cacheWriteTokens",
790            "cacheCreationInputTokens",
791            "cacheWrite",
792        ],
793    );
794
795    if prompt_tokens.is_none()
796        && completion_tokens.is_none()
797        && reported_total_tokens.is_none()
798        && cache_read_tokens.is_none()
799        && cache_write_tokens.is_none()
800    {
801        return None;
802    }
803    let total_tokens =
804        normalize_total_tokens(reported_total_tokens, prompt_tokens, completion_tokens);
805
806    Some(Usage {
807        prompt_tokens,
808        completion_tokens,
809        total_tokens,
810        cache_read_tokens,
811        cache_write_tokens,
812    })
813}
814
815fn normalize_total_tokens(
816    total_tokens: Option<u64>,
817    prompt_tokens: Option<u64>,
818    completion_tokens: Option<u64>,
819) -> Option<u64> {
820    let total_tokens = total_tokens?;
821    let minimum_total = prompt_tokens
822        .unwrap_or(0)
823        .saturating_add(completion_tokens.unwrap_or(0));
824    if minimum_total == 0 || total_tokens >= minimum_total {
825        Some(total_tokens)
826    } else {
827        None
828    }
829}
830
831fn first_u64_from_manual_usage(
832    usage: Option<&serde_json::Map<String, Json>>,
833    token_usage: Option<&serde_json::Map<String, Json>>,
834    keys: &[&str],
835) -> Option<u64> {
836    usage
837        .and_then(|value| first_u64(value, keys))
838        .or_else(|| token_usage.and_then(|value| first_u64(value, keys)))
839}
840
841fn first_u64(usage: &serde_json::Map<String, Json>, keys: &[&str]) -> Option<u64> {
842    keys.iter()
843        .find_map(|key| usage.get(*key).and_then(Json::as_u64))
844}
845
846fn mark_attributes(event: &Event) -> Vec<KeyValue> {
847    let handle_attributes = event.attributes();
848    let mut attributes = vec![
849        KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
850        KeyValue::new(
851            "nemo_relay.mark.parent_uuid",
852            event
853                .parent_uuid()
854                .map(|uuid| uuid.to_string())
855                .unwrap_or_default(),
856        ),
857    ];
858    push_serialized(
859        &mut attributes,
860        "nemo_relay.mark.attributes_json",
861        handle_attributes,
862    );
863    push_serialized(&mut attributes, "nemo_relay.mark.data_json", event.data());
864    push_serialized(
865        &mut attributes,
866        "nemo_relay.mark.metadata_json",
867        event.metadata(),
868    );
869    attributes
870}
871
872fn common_attributes(event: &Event) -> Vec<KeyValue> {
873    let mut attributes = vec![
874        KeyValue::new(
875            oi::OPENINFERENCE_SPAN_KIND,
876            openinference_span_kind(semantic_scope_type(event)),
877        ),
878        KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
879        KeyValue::new(
880            "nemo_relay.parent_uuid",
881            event
882                .parent_uuid()
883                .map(|uuid| uuid.to_string())
884                .unwrap_or_default(),
885        ),
886        KeyValue::new(
887            "nemo_relay.scope_type",
888            scope_type_name(semantic_scope_type(event)),
889        ),
890    ];
891
892    if let Some(model_name) = event.model_name() {
893        attributes.push(KeyValue::new(oi::llm::MODEL_NAME, model_name.to_string()));
894    }
895    if let Some(tool_call_id) = event.tool_call_id() {
896        attributes.push(KeyValue::new(oi::tool_call::ID, tool_call_id.to_string()));
897    }
898    if let Some(metadata) = event.metadata().and_then(to_json_string) {
899        attributes.push(KeyValue::new(oi::METADATA, metadata));
900    }
901
902    attributes
903}
904
905fn openinference_span_kind(scope_type: Option<ScopeType>) -> OpenInferenceSpanKind {
906    match scope_type {
907        Some(ScopeType::Agent) => OpenInferenceSpanKind::Agent,
908        Some(ScopeType::Tool) => OpenInferenceSpanKind::Tool,
909        Some(ScopeType::Llm) => OpenInferenceSpanKind::Llm,
910        Some(ScopeType::Retriever) => OpenInferenceSpanKind::Retriever,
911        Some(ScopeType::Embedder) => OpenInferenceSpanKind::Embedding,
912        Some(ScopeType::Reranker) => OpenInferenceSpanKind::Reranker,
913        Some(ScopeType::Guardrail) => OpenInferenceSpanKind::Guardrail,
914        Some(ScopeType::Evaluator) => OpenInferenceSpanKind::Evaluator,
915        Some(ScopeType::Function | ScopeType::Custom | ScopeType::Unknown) | None => {
916            OpenInferenceSpanKind::Chain
917        }
918    }
919}
920
921fn push_serialized<T: Serialize + ?Sized>(
922    attributes: &mut Vec<KeyValue>,
923    key: &'static str,
924    value: Option<&T>,
925) {
926    if let Some(value) = value
927        && let Ok(json) = serde_json::to_string(value)
928    {
929        attributes.push(KeyValue::new(key, json));
930    }
931}
932
933fn openinference_input_value(event: &Event) -> Option<(String, &'static str)> {
934    let input = event.input()?;
935
936    if event
937        .category()
938        .is_some_and(|category| category.as_str() == "llm")
939    {
940        return llm_input_display_value(input)
941            .map(|display| (display, "text/plain"))
942            .or_else(|| sanitized_llm_input_json(input).map(|json| (json, "application/json")));
943    }
944
945    to_json_string(input).map(|json| (json, "application/json"))
946}
947
948fn openinference_output_value(event: &Event) -> Option<(String, &'static str)> {
949    let output = event.output()?;
950    display_text_from_json(output)
951        .map(|display| (display, "text/plain"))
952        .or_else(|| to_json_string(output).map(|json| (json, "application/json")))
953}
954
955fn llm_input_display_value(input: &Json) -> Option<String> {
956    let content = match input {
957        Json::Object(object) => object.get("content").unwrap_or(input),
958        _ => input,
959    };
960
961    content
962        .get("messages")
963        .and_then(display_text_from_messages)
964        .or_else(|| display_text_from_json(content))
965}
966
967fn sanitized_llm_input_json(input: &Json) -> Option<String> {
968    match input {
969        Json::Object(object) => {
970            let mut sanitized = object.clone();
971            sanitized.remove("headers");
972            to_json_string(&Json::Object(sanitized))
973        }
974        _ => to_json_string(input),
975    }
976}
977
978fn display_text_from_json(value: &Json) -> Option<String> {
979    match value {
980        Json::String(text) => display_text_from_string(text),
981        Json::Object(object) => {
982            for key in ["content", "summary", "message", "text", "prompt"] {
983                if let Some(display) = object.get(key).and_then(display_text_from_json) {
984                    return Some(display);
985                }
986            }
987            object
988                .get("choices")
989                .and_then(display_text_from_chat_choices)
990                .or_else(|| {
991                    object
992                        .get("tool_calls")
993                        .and_then(display_text_from_tool_calls)
994                })
995        }
996        Json::Array(items) => display_text_from_content_blocks(items),
997        _ => None,
998    }
999}
1000
1001fn display_text_from_messages(value: &Json) -> Option<String> {
1002    let messages = value.as_array()?;
1003    let text = messages
1004        .iter()
1005        .filter_map(display_text_from_message)
1006        .collect::<Vec<_>>()
1007        .join("\n\n")
1008        .trim()
1009        .to_string();
1010    if text.is_empty() { None } else { Some(text) }
1011}
1012
1013fn display_text_from_message(value: &Json) -> Option<String> {
1014    let role = value
1015        .get("role")
1016        .and_then(Json::as_str)
1017        .unwrap_or("message");
1018    if role == "tool" {
1019        return Some("tool: Tool result omitted".to_string());
1020    }
1021    let display = value
1022        .get("content")
1023        .and_then(display_text_from_json)
1024        .or_else(|| {
1025            value
1026                .get("tool_calls")
1027                .and_then(display_text_from_tool_calls)
1028        })?;
1029    Some(format!("{role}: {display}"))
1030}
1031
1032fn display_text_from_string(text: &str) -> Option<String> {
1033    let trimmed = text.trim();
1034    if trimmed.is_empty() {
1035        return None;
1036    }
1037    if let Ok(parsed) = serde_json::from_str::<Json>(trimmed)
1038        && let Some(display) = display_text_from_json(&parsed)
1039    {
1040        return Some(display);
1041    }
1042    Some(trimmed.to_string())
1043}
1044
1045fn display_text_from_chat_choices(value: &Json) -> Option<String> {
1046    let choices = value.as_array()?;
1047    for choice in choices {
1048        let Some(message) = choice.get("message") else {
1049            continue;
1050        };
1051        let content = message.get("content").and_then(display_text_from_json);
1052        let tool_calls = message
1053            .get("tool_calls")
1054            .and_then(display_text_from_tool_calls);
1055        match (content, tool_calls) {
1056            (Some(content), Some(tool_calls)) => return Some(format!("{content}\n{tool_calls}")),
1057            (Some(content), None) => return Some(content),
1058            (None, Some(tool_calls)) => return Some(tool_calls),
1059            (None, None) => {}
1060        }
1061    }
1062    None
1063}
1064
1065fn display_text_from_content_blocks(items: &[Json]) -> Option<String> {
1066    let mut entries = items
1067        .iter()
1068        .filter_map(content_block_display_text)
1069        .collect::<Vec<_>>();
1070    let tool_calls = items.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1071    if !tool_calls.is_empty() {
1072        entries.push(format!("Requested tools: {}", tool_calls.join(", ")));
1073    }
1074    let text = entries
1075        .into_iter()
1076        .filter(|item| !item.trim().is_empty())
1077        .collect::<Vec<_>>()
1078        .join("\n")
1079        .trim()
1080        .to_string();
1081    if text.is_empty() { None } else { Some(text) }
1082}
1083
1084fn content_block_display_text(item: &Json) -> Option<String> {
1085    if let Some(text) = item.as_str() {
1086        return Some(text.to_string());
1087    }
1088    if item.get("stripped").and_then(Json::as_bool) == Some(true) {
1089        return None;
1090    }
1091    if let Some("thinking" | "reasoning" | "toolResult" | "tool_result") =
1092        item.get("type").and_then(Json::as_str)
1093    {
1094        return None;
1095    }
1096    item.get("text").and_then(Json::as_str).map(str::to_string)
1097}
1098
1099fn display_text_from_tool_calls(value: &Json) -> Option<String> {
1100    let calls = value.as_array()?;
1101    let names = calls.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1102    if names.is_empty() {
1103        None
1104    } else {
1105        Some(format!("Requested tools: {}", names.join(", ")))
1106    }
1107}
1108
1109fn tool_call_name(value: &Json) -> Option<String> {
1110    value
1111        .get("name")
1112        .and_then(Json::as_str)
1113        .or_else(|| value.get("toolName").and_then(Json::as_str))
1114        .or_else(|| {
1115            value
1116                .get("function")
1117                .and_then(|function| function.get("name"))
1118                .and_then(Json::as_str)
1119        })
1120        .map(str::to_string)
1121}
1122
1123fn to_json_string<T: Serialize>(value: &T) -> Option<String> {
1124    serde_json::to_string(value).ok()
1125}
1126
1127fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
1128    SpanContext::new(
1129        span_context.trace_id(),
1130        span_context.span_id(),
1131        span_context.trace_flags(),
1132        false,
1133        span_context.trace_state().clone(),
1134    )
1135}
1136
1137fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
1138    let seconds = timestamp.timestamp();
1139    let nanos = timestamp.timestamp_subsec_nanos();
1140    if seconds >= 0 {
1141        UNIX_EPOCH + Duration::new(seconds as u64, nanos)
1142    } else if nanos == 0 {
1143        UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
1144    } else {
1145        UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
1146    }
1147}
1148
1149#[cfg(test)]
1150#[path = "../../tests/unit/observability/openinference_tests.rs"]
1151mod tests;