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::request::{
28    AnnotatedLlmRequest, ContentPart, Message, MessageContent, ToolDefinition,
29};
30use crate::codec::response::{
31    AnnotatedLlmResponse, FinishReason, ResponseToolCall, Usage, estimate_cost_for_provider,
32};
33use crate::error::FlowError;
34use crate::json::Json;
35use chrono::{DateTime, Utc};
36use openinference_semantic_conventions::SpanKind as OpenInferenceSpanKind;
37use openinference_semantic_conventions::attributes as oi;
38use opentelemetry::trace::{
39    Span as _, SpanContext, SpanKind, TraceContextExt, Tracer, TracerProvider as _,
40};
41use opentelemetry::{Context, KeyValue};
42use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig, WithHttpConfig};
43use opentelemetry_sdk::Resource;
44use opentelemetry_sdk::trace::{SdkTracer, SdkTracerProvider, Span};
45use serde::Serialize;
46use uuid::Uuid;
47
48#[cfg(target_arch = "wasm32")]
49use async_trait::async_trait;
50#[cfg(target_arch = "wasm32")]
51use opentelemetry_http::{
52    Bytes, HttpClient, HttpError, Request as HttpRequest, Response as HttpResponse,
53};
54#[cfg(not(target_arch = "wasm32"))]
55use opentelemetry_otlp::WithTonicConfig;
56#[cfg(not(target_arch = "wasm32"))]
57use tokio::runtime::Handle;
58#[cfg(not(target_arch = "wasm32"))]
59use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
60#[cfg(target_arch = "wasm32")]
61use wasm_bindgen::{JsCast, JsValue};
62#[cfg(target_arch = "wasm32")]
63use wasm_bindgen_futures::{JsFuture, spawn_local};
64#[cfg(target_arch = "wasm32")]
65use web_sys::{Request as WebRequest, RequestInit};
66
67/// Result type for the OpenInference subscriber crate.
68pub type Result<T> = std::result::Result<T, OpenInferenceError>;
69
70/// Errors produced while configuring or operating the OpenInference subscriber.
71#[derive(Debug, thiserror::Error)]
72pub enum OpenInferenceError {
73    /// The tonic gRPC exporter requires an active Tokio runtime.
74    #[error("the OTLP gRPC exporter requires an active Tokio runtime")]
75    MissingTokioRuntime,
76    /// The requested transport is not available on this target.
77    #[error("the OTLP {transport} transport is not supported on this target")]
78    UnsupportedTransport {
79        /// Human-readable transport label used in the error message.
80        transport: &'static str,
81    },
82    /// Failed to parse a configured gRPC metadata header.
83    #[error("invalid OTLP gRPC header {key:?}: {message}")]
84    InvalidGrpcHeader {
85        /// Header name that failed to parse.
86        key: String,
87        /// Parser failure message.
88        message: String,
89    },
90    /// Failed to build the OTLP exporter.
91    #[error("failed to build the OTLP exporter: {0}")]
92    ExporterBuild(String),
93    /// The underlying tracer provider returned an error.
94    #[error("OpenInference tracer provider error: {0}")]
95    Provider(String),
96    /// Registration errors from the core runtime.
97    #[error(transparent)]
98    Core(#[from] FlowError),
99}
100
101/// Supported OTLP trace transports.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum OtlpTransport {
104    /// OTLP/HTTP protobuf, typically `http://host:4318/v1/traces`.
105    #[default]
106    HttpBinary,
107    /// OTLP/gRPC, typically `http://host:4317`.
108    Grpc,
109}
110
111/// Configuration for the OpenInference subscriber.
112#[derive(Debug, Clone)]
113pub struct OpenInferenceConfig {
114    endpoint: Option<String>,
115    headers: HashMap<String, String>,
116    resource_attributes: HashMap<String, String>,
117    service_name: String,
118    service_namespace: Option<String>,
119    service_version: Option<String>,
120    instrumentation_scope: String,
121    timeout: Duration,
122    transport: OtlpTransport,
123}
124
125impl Default for OpenInferenceConfig {
126    fn default() -> Self {
127        Self {
128            endpoint: None,
129            headers: HashMap::new(),
130            resource_attributes: HashMap::new(),
131            service_name: "nemo-relay".to_string(),
132            service_namespace: None,
133            service_version: None,
134            instrumentation_scope: "nemo-relay-openinference".to_string(),
135            timeout: Duration::from_secs(3),
136            transport: OtlpTransport::HttpBinary,
137        }
138    }
139}
140
141impl OpenInferenceConfig {
142    /// Creates a config with sensible defaults.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Selects the OTLP transport.
148    pub fn with_transport(mut self, transport: OtlpTransport) -> Self {
149        self.transport = transport;
150        self
151    }
152
153    /// Sets the `service.name` resource attribute.
154    pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
155        self.service_name = service_name.into();
156        self
157    }
158
159    /// Overrides the OTLP endpoint. If unset, exporter defaults and OTEL_* env vars apply.
160    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
161        self.endpoint = Some(endpoint.into());
162        self
163    }
164
165    /// Adds a header/metadata entry for the exporter.
166    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
167        self.headers.insert(key.into(), value.into());
168        self
169    }
170
171    /// Adds a resource attribute as a string key/value pair.
172    pub fn with_resource_attribute(
173        mut self,
174        key: impl Into<String>,
175        value: impl Into<String>,
176    ) -> Self {
177        self.resource_attributes.insert(key.into(), value.into());
178        self
179    }
180
181    /// Sets the OTLP request timeout.
182    pub fn with_timeout(mut self, timeout: Duration) -> Self {
183        self.timeout = timeout;
184        self
185    }
186
187    /// Sets the service namespace resource attribute.
188    pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
189        self.service_namespace = Some(namespace.into());
190        self
191    }
192
193    /// Sets the service version resource attribute.
194    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
195        self.service_version = Some(version.into());
196        self
197    }
198
199    /// Sets the instrumentation scope name used for emitted spans.
200    pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
201        self.instrumentation_scope = scope.into();
202        self
203    }
204}
205
206/// OpenInference-backed NeMo Relay subscriber.
207#[derive(Clone)]
208pub struct OpenInferenceSubscriber {
209    inner: Arc<Inner>,
210}
211
212struct Inner {
213    processor: Arc<Mutex<OpenInferenceEventProcessor>>,
214    subscriber: EventSubscriberFn,
215}
216
217impl OpenInferenceSubscriber {
218    /// Builds a subscriber backed by a new OTLP tracer provider.
219    pub fn new(config: OpenInferenceConfig) -> Result<Self> {
220        #[cfg(not(target_arch = "wasm32"))]
221        if config.transport == OtlpTransport::Grpc && tokio::runtime::Handle::try_current().is_err()
222        {
223            return Err(OpenInferenceError::MissingTokioRuntime);
224        }
225        #[cfg(target_arch = "wasm32")]
226        if config.transport == OtlpTransport::Grpc {
227            return Err(OpenInferenceError::UnsupportedTransport { transport: "gRPC" });
228        }
229
230        let provider = build_tracer_provider(&config)?;
231        Ok(Self::from_tracer_provider_with_scope(
232            provider,
233            config.instrumentation_scope,
234        ))
235    }
236
237    /// Builds a subscriber from an already-configured tracer provider.
238    pub fn from_tracer_provider(
239        provider: SdkTracerProvider,
240        instrumentation_scope: impl Into<String>,
241    ) -> Self {
242        Self::from_tracer_provider_with_scope(provider, instrumentation_scope.into())
243    }
244
245    fn from_tracer_provider_with_scope(
246        provider: SdkTracerProvider,
247        instrumentation_scope: String,
248    ) -> Self {
249        let processor = Arc::new(Mutex::new(OpenInferenceEventProcessor::new(
250            provider,
251            instrumentation_scope,
252        )));
253        let processor_for_callback = Arc::clone(&processor);
254        let subscriber: EventSubscriberFn = Arc::new(move |event: &Event| {
255            let Ok(mut guard) = processor_for_callback.lock() else {
256                // Observability should not take down the host process if the
257                // subscriber state was previously poisoned.
258                return;
259            };
260            guard.process(event);
261        });
262
263        Self {
264            inner: Arc::new(Inner {
265                processor,
266                subscriber,
267            }),
268        }
269    }
270
271    /// Returns the raw NeMo Relay subscriber callback for custom registration flows.
272    pub fn subscriber(&self) -> EventSubscriberFn {
273        Arc::clone(&self.inner.subscriber)
274    }
275
276    /// Registers this subscriber globally with the NeMo Relay runtime.
277    pub fn register(&self, name: &str) -> Result<()> {
278        register_subscriber(name, self.subscriber()).map_err(Into::into)
279    }
280
281    /// Deregisters a previously-registered global subscriber by name.
282    pub fn deregister(&self, name: &str) -> Result<bool> {
283        deregister_subscriber(name).map_err(Into::into)
284    }
285
286    /// Flushes finished spans through the underlying tracer provider.
287    pub fn force_flush(&self) -> Result<()> {
288        flush_subscribers()?;
289        let guard = self.inner.processor.lock().map_err(|_| {
290            OpenInferenceError::Provider("the subscriber state lock was poisoned".to_string())
291        })?;
292        guard.force_flush()
293    }
294
295    /// Shuts down the underlying tracer provider.
296    ///
297    /// Call `deregister(...)` first if the subscriber is still registered with NeMo Relay.
298    pub fn shutdown(&self) -> Result<()> {
299        flush_subscribers()?;
300        let guard = self.inner.processor.lock().map_err(|_| {
301            OpenInferenceError::Provider("the subscriber state lock was poisoned".to_string())
302        })?;
303        guard.shutdown()
304    }
305}
306
307#[cfg(target_arch = "wasm32")]
308#[derive(Debug, Clone, Copy, Default)]
309struct WasmHttpClient;
310
311#[cfg(target_arch = "wasm32")]
312#[async_trait]
313impl HttpClient for WasmHttpClient {
314    async fn send_bytes(
315        &self,
316        request: HttpRequest<Bytes>,
317    ) -> std::result::Result<HttpResponse<Bytes>, HttpError> {
318        let (parts, body) = request.into_parts();
319
320        let request = {
321            let request_url = parts.uri.to_string();
322            let init = RequestInit::new();
323            init.set_method(parts.method.as_str());
324            if !body.is_empty() {
325                let body_bytes = js_sys::Uint8Array::from(body.as_ref());
326                init.set_body_opt_u8_array(Some(&body_bytes));
327            }
328
329            let request =
330                WebRequest::new_with_str_and_init(&request_url, &init).map_err(js_error)?;
331            let request_headers = request.headers();
332            for (name, value) in &parts.headers {
333                let value = value
334                    .to_str()
335                    .map_err(|e| http_error(format!("invalid OTLP HTTP header {name}: {e}")))?;
336                request_headers
337                    .set(name.as_str(), value)
338                    .map_err(js_error)?;
339            }
340            request
341        };
342
343        let fetch_promise = if let Some(window) = web_sys::window() {
344            window.fetch_with_request(&request)
345        } else {
346            let global = js_sys::global();
347            let fetch = js_sys::Reflect::get(&global, &JsValue::from_str("fetch"))
348                .map_err(js_error)?
349                .dyn_into::<js_sys::Function>()
350                .map_err(js_error)?;
351            fetch.call1(&global, &request).map_err(js_error)?.into()
352        };
353        // Waiting on the fetch promise from a synchronous wasm call stack can deadlock
354        // Node/browser event processing, so dispatch the request asynchronously.
355        spawn_local(async move {
356            if let Err(error) = JsFuture::from(fetch_promise).await {
357                web_sys::console::warn_1(&JsValue::from_str(&format!(
358                    "OpenInference OTLP/HTTP export failed: {error:?}"
359                )));
360            }
361        });
362
363        HttpResponse::builder()
364            .status(202)
365            .body(Bytes::new())
366            .map_err(|e| http_error(e.to_string()))
367    }
368}
369
370#[cfg(target_arch = "wasm32")]
371fn js_error(value: JsValue) -> HttpError {
372    http_error(
373        value
374            .as_string()
375            .unwrap_or_else(|| format!("JavaScript error: {value:?}")),
376    )
377}
378
379#[cfg(target_arch = "wasm32")]
380fn http_error(message: impl Into<String>) -> HttpError {
381    Box::new(std::io::Error::other(message.into()))
382}
383
384fn build_tracer_provider(config: &OpenInferenceConfig) -> Result<SdkTracerProvider> {
385    let exporter = match config.transport {
386        OtlpTransport::HttpBinary => {
387            #[cfg(not(target_arch = "wasm32"))]
388            install_rustls_crypto_provider();
389            let mut builder = SpanExporter::builder()
390                .with_http()
391                .with_protocol(Protocol::HttpBinary)
392                .with_timeout(config.timeout);
393            if let Some(endpoint) = &config.endpoint {
394                builder = builder.with_endpoint(endpoint.clone());
395            }
396            if !config.headers.is_empty() {
397                builder = builder.with_headers(config.headers.clone());
398            }
399            #[cfg(target_arch = "wasm32")]
400            {
401                builder = builder.with_http_client(WasmHttpClient);
402            }
403            builder
404                .build()
405                .map_err(|e| OpenInferenceError::ExporterBuild(e.to_string()))?
406        }
407        #[cfg(not(target_arch = "wasm32"))]
408        OtlpTransport::Grpc => {
409            let mut builder = SpanExporter::builder()
410                .with_tonic()
411                .with_protocol(Protocol::Grpc)
412                .with_timeout(config.timeout);
413            if let Some(endpoint) = &config.endpoint {
414                builder = builder.with_endpoint(endpoint.clone());
415            }
416            if !config.headers.is_empty() {
417                builder = builder.with_metadata(build_grpc_metadata(&config.headers)?);
418            }
419            builder
420                .build()
421                .map_err(|e| OpenInferenceError::ExporterBuild(e.to_string()))?
422        }
423        #[cfg(target_arch = "wasm32")]
424        OtlpTransport::Grpc => {
425            return Err(OpenInferenceError::UnsupportedTransport { transport: "gRPC" });
426        }
427    };
428
429    let mut resource_attributes = vec![KeyValue::new("service.name", config.service_name.clone())];
430    if let Some(service_namespace) = &config.service_namespace {
431        resource_attributes.push(KeyValue::new(
432            "service.namespace",
433            service_namespace.clone(),
434        ));
435    }
436    if let Some(service_version) = &config.service_version {
437        resource_attributes.push(KeyValue::new("service.version", service_version.clone()));
438    }
439    for (key, value) in &config.resource_attributes {
440        resource_attributes.push(KeyValue::new(key.clone(), value.clone()));
441    }
442
443    // Disable per-span attribute caps. OpenInference emits many flat
444    // `llm.input_messages.*` attributes on long conversations; the OTel SDK
445    // default (128) silently drops attributes added last in the span's
446    // lifecycle, notably `llm.token_count.*` emitted at span end.
447    let builder = SdkTracerProvider::builder()
448        .with_resource(
449            Resource::builder_empty()
450                .with_attributes(resource_attributes)
451                .build(),
452        )
453        .with_max_attributes_per_span(u32::MAX)
454        .with_max_attributes_per_event(u32::MAX);
455
456    #[cfg(not(target_arch = "wasm32"))]
457    {
458        if Handle::try_current().is_ok() {
459            Ok(builder.with_batch_exporter(exporter).build())
460        } else {
461            Ok(builder.with_simple_exporter(exporter).build())
462        }
463    }
464    #[cfg(target_arch = "wasm32")]
465    {
466        Ok(builder.with_simple_exporter(exporter).build())
467    }
468}
469
470#[cfg(not(target_arch = "wasm32"))]
471fn install_rustls_crypto_provider() {
472    let _ = rustls::crypto::ring::default_provider().install_default();
473}
474
475#[cfg(not(target_arch = "wasm32"))]
476fn build_grpc_metadata(headers: &HashMap<String, String>) -> Result<MetadataMap> {
477    let mut metadata = MetadataMap::new();
478    for (key, value) in headers {
479        let metadata_key = MetadataKey::from_bytes(key.as_bytes()).map_err(|e| {
480            OpenInferenceError::InvalidGrpcHeader {
481                key: key.clone(),
482                message: e.to_string(),
483            }
484        })?;
485        let metadata_value = MetadataValue::try_from(value.as_str()).map_err(|e| {
486            OpenInferenceError::InvalidGrpcHeader {
487                key: key.clone(),
488                message: e.to_string(),
489            }
490        })?;
491        metadata.insert(metadata_key, metadata_value);
492    }
493    Ok(metadata)
494}
495
496struct ActiveSpan {
497    span: Span,
498    span_context: SpanContext,
499}
500
501struct OpenInferenceEventProcessor {
502    active_spans: HashMap<Uuid, ActiveSpan>,
503    provider: SdkTracerProvider,
504    tracer: SdkTracer,
505}
506
507impl OpenInferenceEventProcessor {
508    fn new(provider: SdkTracerProvider, instrumentation_scope: String) -> Self {
509        let tracer = provider.tracer(instrumentation_scope);
510        Self {
511            active_spans: HashMap::new(),
512            provider,
513            tracer,
514        }
515    }
516
517    fn process(&mut self, event: &Event) {
518        match event.scope_category() {
519            Some(ScopeCategory::Start) => self.process_start(event),
520            Some(ScopeCategory::End) => self.process_end(event),
521            None => self.process_mark(event),
522        }
523    }
524
525    fn force_flush(&self) -> Result<()> {
526        self.provider
527            .force_flush()
528            .map_err(|e| OpenInferenceError::Provider(e.to_string()))
529    }
530
531    fn shutdown(&self) -> Result<()> {
532        self.provider
533            .shutdown()
534            .map_err(|e| OpenInferenceError::Provider(e.to_string()))
535    }
536
537    fn process_start(&mut self, event: &Event) {
538        let mut span = self
539            .tracer
540            .span_builder(span_name(event))
541            .with_kind(span_kind(event))
542            .with_start_time(to_system_time(*event.timestamp()))
543            .start_with_context(&self.tracer, &self.parent_context(event));
544        span.set_attributes(start_attributes(event));
545        let span_context = local_parent_span_context(span.span_context());
546        self.active_spans
547            .insert(event.uuid(), ActiveSpan { span, span_context });
548    }
549
550    fn process_end(&mut self, event: &Event) {
551        let Some(mut active_span) = self.active_spans.remove(&event.uuid()) else {
552            return;
553        };
554        super::set_span_status_from_event_metadata(&mut active_span.span, event);
555        active_span.span.set_attributes(end_attributes(event));
556        active_span
557            .span
558            .end_with_timestamp(to_system_time(*event.timestamp()));
559    }
560
561    fn process_mark(&mut self, event: &Event) {
562        let mark_name = event.name().to_string();
563        let timestamp = to_system_time(*event.timestamp());
564        let attributes = mark_attributes(event);
565
566        if let Some(parent_span) = self.find_parent_span_mut(event) {
567            parent_span
568                .span
569                .add_event_with_timestamp(mark_name, timestamp, attributes);
570            return;
571        }
572
573        let mut span = self
574            .tracer
575            .span_builder(format!("mark:{mark_name}"))
576            .with_kind(SpanKind::Internal)
577            .with_start_time(timestamp)
578            .start_with_context(&self.tracer, &self.parent_context(event));
579        let mut span_attributes = attributes;
580        span_attributes.push(KeyValue::new(
581            oi::OPENINFERENCE_SPAN_KIND,
582            OpenInferenceSpanKind::Chain,
583        ));
584        span_attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
585        span.set_attributes(span_attributes);
586        span.end_with_timestamp(timestamp);
587    }
588
589    fn parent_context(&self, event: &Event) -> Context {
590        self.find_parent_span(event)
591            .map(|active_span| {
592                Context::new().with_remote_span_context(active_span.span_context.clone())
593            })
594            .unwrap_or_default()
595    }
596
597    fn parent_span_uuid(&self, event: &Event) -> Option<Uuid> {
598        event
599            .parent_uuid()
600            .filter(|uuid| self.active_spans.contains_key(uuid))
601    }
602
603    fn find_parent_span(&self, event: &Event) -> Option<&ActiveSpan> {
604        self.parent_span_uuid(event)
605            .and_then(|uuid| self.active_spans.get(&uuid))
606    }
607
608    fn find_parent_span_mut(&mut self, event: &Event) -> Option<&mut ActiveSpan> {
609        self.parent_span_uuid(event)
610            .and_then(|uuid| self.active_spans.get_mut(&uuid))
611    }
612}
613
614fn span_kind(event: &Event) -> SpanKind {
615    match semantic_scope_type(event) {
616        Some(ScopeType::Llm) => SpanKind::Client,
617        Some(
618            ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
619        ) => SpanKind::Client,
620        _ => SpanKind::Internal,
621    }
622}
623
624fn span_name(event: &Event) -> String {
625    event.name().to_string()
626}
627
628fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
629    event.scope_type()
630}
631
632fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
633    match scope_type {
634        Some(ScopeType::Agent) => "agent",
635        Some(ScopeType::Function) => "function",
636        Some(ScopeType::Tool) => "tool",
637        Some(ScopeType::Llm) => "llm",
638        Some(ScopeType::Retriever) => "retriever",
639        Some(ScopeType::Embedder) => "embedder",
640        Some(ScopeType::Reranker) => "reranker",
641        Some(ScopeType::Guardrail) => "guardrail",
642        Some(ScopeType::Evaluator) => "evaluator",
643        Some(ScopeType::Custom) => "custom",
644        Some(ScopeType::Unknown) | None => "unknown",
645    }
646}
647
648fn start_attributes(event: &Event) -> Vec<KeyValue> {
649    let mut attributes = common_attributes(event);
650    let is_llm = event
651        .category()
652        .is_some_and(|category| category.as_str() == "llm");
653    if is_llm {
654        // Final span metadata should reflect the completed event, especially for mixed-fidelity
655        // Hermes flows where the request can be exact but the terminal error is lossy.
656        attributes.retain(|attribute| attribute.key.as_str() != oi::METADATA.as_str());
657    }
658    let handle_attributes = event.attributes();
659    if handle_attributes.is_some_and(|attributes| !attributes.is_empty()) {
660        push_serialized(
661            &mut attributes,
662            "nemo_relay.handle_attributes_json",
663            handle_attributes,
664        );
665    }
666    if event
667        .category()
668        .is_none_or(|category| category.as_str() != "llm")
669    {
670        push_serialized(
671            &mut attributes,
672            "nemo_relay.start.input_json",
673            event.input(),
674        );
675    }
676    if event
677        .category()
678        .is_some_and(|category| category.as_str() == "tool")
679    {
680        attributes.push(KeyValue::new(oi::tool::NAME, event.name().to_string()));
681        attributes.push(KeyValue::new(
682            oi::tool_call::function::NAME,
683            event.name().to_string(),
684        ));
685    }
686
687    if let Some((input, mime_type)) = openinference_input_value(event) {
688        attributes.push(KeyValue::new(oi::input::VALUE, input.clone()));
689        attributes.push(KeyValue::new(oi::input::MIME_TYPE, mime_type));
690
691        if event
692            .category()
693            .is_some_and(|category| category.as_str() == "tool")
694        {
695            attributes.push(KeyValue::new(oi::tool::PARAMETERS, input.clone()));
696            attributes.push(KeyValue::new(oi::tool_call::function::ARGUMENTS, input));
697        }
698    }
699    if is_llm {
700        push_llm_request_attributes(&mut attributes, event);
701    }
702    attributes
703}
704
705fn end_attributes(event: &Event) -> Vec<KeyValue> {
706    let mut attributes = Vec::new();
707    let is_llm = event
708        .category()
709        .is_some_and(|category| category.as_str() == "llm");
710
711    if let Some(metadata) = event.metadata().and_then(to_json_string) {
712        attributes.push(KeyValue::new(oi::METADATA, metadata));
713    }
714
715    push_serialized(
716        &mut attributes,
717        "nemo_relay.end.output_json",
718        event.output(),
719    );
720    if let Some((output, mime_type)) = openinference_output_value(event) {
721        attributes.push(KeyValue::new(oi::output::VALUE, output));
722        attributes.push(KeyValue::new(oi::output::MIME_TYPE, mime_type));
723    }
724    let fallback_usage = if is_llm {
725        usage_from_manual_llm_output(event.output())
726    } else {
727        None
728    };
729    let usage = event
730        .annotated_response()
731        .and_then(|response| response.usage.as_ref())
732        .or(fallback_usage.as_ref());
733    if is_llm {
734        push_llm_usage_attributes(&mut attributes, usage);
735    }
736    if is_llm && let Some(cost_total) = cost_total_from_llm_event(event, fallback_usage.as_ref()) {
737        attributes.push(KeyValue::new(oi::llm::cost::TOTAL, cost_total));
738    }
739    if is_llm {
740        push_llm_response_attributes(&mut attributes, event);
741    }
742    attributes
743}
744
745fn push_llm_usage_attributes(attributes: &mut Vec<KeyValue>, usage: Option<&Usage>) {
746    let Some(usage) = usage else {
747        return;
748    };
749    if let Some(v) = usage.prompt_tokens {
750        attributes.push(KeyValue::new(oi::llm::token_count::PROMPT, v as i64));
751    }
752    if let Some(v) = usage.completion_tokens {
753        attributes.push(KeyValue::new(oi::llm::token_count::COMPLETION, v as i64));
754    }
755    if let Some(v) = usage.total_tokens {
756        attributes.push(KeyValue::new(oi::llm::token_count::TOTAL, v as i64));
757    }
758    if let Some(v) = usage.cache_read_tokens {
759        attributes.push(KeyValue::new(
760            oi::llm::token_count::prompt_details::CACHE_READ,
761            v as i64,
762        ));
763    }
764    if let Some(v) = usage.cache_write_tokens {
765        attributes.push(KeyValue::new(
766            oi::llm::token_count::prompt_details::CACHE_WRITE,
767            v as i64,
768        ));
769    }
770}
771
772fn push_llm_request_attributes(attributes: &mut Vec<KeyValue>, event: &Event) {
773    if let Some(request) = event.annotated_request() {
774        push_annotated_request_attributes(attributes, request);
775        return;
776    }
777
778    let Some(input) = event.input().and_then(replay_llm_payload) else {
779        return;
780    };
781    if let Some(provider) = input.get("provider").and_then(Json::as_str) {
782        attributes.push(KeyValue::new(oi::llm::PROVIDER, provider.to_string()));
783    }
784    if let Some(system) = input.get("systemPrompt").and_then(display_text_from_json) {
785        attributes.push(KeyValue::new(oi::llm::SYSTEM, system));
786    }
787    push_replay_input_messages(attributes, input);
788}
789
790fn push_llm_response_attributes(attributes: &mut Vec<KeyValue>, event: &Event) {
791    if let Some(response) = event.annotated_response() {
792        push_annotated_response_attributes(attributes, response);
793        return;
794    }
795
796    let Some(output) = event.output().and_then(replay_llm_response) else {
797        return;
798    };
799    push_replay_response_attributes(attributes, output);
800}
801
802fn push_annotated_request_attributes(
803    attributes: &mut Vec<KeyValue>,
804    request: &AnnotatedLlmRequest,
805) {
806    if let Some(system) = request.system_prompt() {
807        attributes.push(KeyValue::new(oi::llm::SYSTEM, system.to_string()));
808    }
809    if let Some(params) = request.params.as_ref().and_then(to_json_string) {
810        attributes.push(KeyValue::new(oi::llm::INVOCATION_PARAMETERS, params));
811    }
812    push_annotated_input_messages(attributes, &request.messages);
813    if let Some(tools) = request.tools.as_deref() {
814        push_annotated_tools(attributes, tools);
815    }
816}
817
818fn push_annotated_response_attributes(
819    attributes: &mut Vec<KeyValue>,
820    response: &AnnotatedLlmResponse,
821) {
822    if let Some(reason) = response.finish_reason.as_ref() {
823        attributes.push(KeyValue::new(
824            "llm.finish_reason",
825            finish_reason_value(reason),
826        ));
827    }
828
829    let has_message = response.message.is_some()
830        || response
831            .tool_calls
832            .as_ref()
833            .is_some_and(|tool_calls| !tool_calls.is_empty());
834    if has_message {
835        attributes.push(KeyValue::new(
836            "llm.output_messages.0.message.role",
837            "assistant",
838        ));
839    }
840    if let Some(content) = response.message.as_ref().and_then(message_content_text) {
841        attributes.push(KeyValue::new(
842            "llm.output_messages.0.message.content",
843            content,
844        ));
845    }
846    if let Some(tool_calls) = response.tool_calls.as_deref() {
847        push_response_tool_calls(attributes, 0, tool_calls);
848    }
849}
850
851fn push_annotated_input_messages(attributes: &mut Vec<KeyValue>, messages: &[Message]) {
852    for (index, message) in messages.iter().enumerate() {
853        let (role, content) = match message {
854            Message::System { content, .. } => ("system", Some(content)),
855            Message::User { content, .. } => ("user", Some(content)),
856            Message::Assistant { content, .. } => ("assistant", content.as_ref()),
857            Message::Tool { content, .. } => ("tool", Some(content)),
858        };
859        push_message_role(attributes, "llm.input_messages", index, role);
860        if let Some(content) = content {
861            push_message_text_content(attributes, "llm.input_messages", index, content);
862        }
863    }
864}
865
866fn push_annotated_tools(attributes: &mut Vec<KeyValue>, tools: &[ToolDefinition]) {
867    for (index, tool) in tools.iter().enumerate() {
868        if let Some(json) = to_json_string(tool) {
869            attributes.push(KeyValue::new(
870                format!("llm.tools.{index}.tool.json_schema"),
871                json,
872            ));
873        }
874    }
875}
876
877fn push_response_tool_calls(
878    attributes: &mut Vec<KeyValue>,
879    message_index: usize,
880    tool_calls: &[ResponseToolCall],
881) {
882    for (call_index, tool_call) in tool_calls.iter().enumerate() {
883        push_output_tool_call(
884            attributes,
885            message_index,
886            call_index,
887            Some(tool_call.id.as_str()),
888            Some(tool_call.name.as_str()),
889            to_json_string(&tool_call.arguments),
890        );
891    }
892}
893
894fn push_message_role(
895    attributes: &mut Vec<KeyValue>,
896    prefix: &'static str,
897    index: usize,
898    role: &str,
899) {
900    attributes.push(KeyValue::new(
901        format!("{prefix}.{index}.message.role"),
902        role.to_string(),
903    ));
904}
905
906fn push_message_text_content(
907    attributes: &mut Vec<KeyValue>,
908    prefix: &'static str,
909    index: usize,
910    content: &MessageContent,
911) {
912    if let Some(text) = message_content_text(content) {
913        attributes.push(KeyValue::new(
914            format!("{prefix}.{index}.message.content"),
915            text,
916        ));
917    }
918}
919
920fn message_content_text(content: &MessageContent) -> Option<String> {
921    match content {
922        MessageContent::Text(text) => display_text_from_string(text),
923        MessageContent::Parts(parts) => {
924            let text = parts
925                .iter()
926                .filter_map(|part| match part {
927                    ContentPart::Text { text } => Some(text.as_str()),
928                    ContentPart::ImageUrl { .. } => None,
929                })
930                .collect::<Vec<_>>()
931                .join("\n")
932                .trim()
933                .to_string();
934            if text.is_empty() { None } else { Some(text) }
935        }
936    }
937}
938
939fn replay_llm_payload(input: &Json) -> Option<&Json> {
940    let content = input.as_object().and_then(|object| object.get("content"))?;
941    let content_object = content.as_object()?;
942    is_openclaw_replay_payload(content_object).then_some(content)
943}
944
945fn replay_llm_response(output: &Json) -> Option<&Json> {
946    output
947        .as_object()
948        .and_then(|object| object.get("openclaw"))
949        .and_then(Json::as_object)
950        .map(|_| output)
951}
952
953fn is_openclaw_replay_payload(content: &serde_json::Map<String, Json>) -> bool {
954    content
955        .get("source")
956        .and_then(Json::as_str)
957        .is_some_and(|source| source.starts_with("openclaw."))
958        || content.contains_key("placeholderRequest")
959}
960
961fn push_replay_input_messages(attributes: &mut Vec<KeyValue>, input: &Json) {
962    if let Some(messages) = input.get("messages").and_then(Json::as_array) {
963        for (index, message) in messages.iter().enumerate() {
964            push_replay_input_message(attributes, index, message);
965        }
966        return;
967    }
968    if let Some(prompt) = input.get("prompt").and_then(display_text_from_json) {
969        push_message_role(attributes, "llm.input_messages", 0, "user");
970        attributes.push(KeyValue::new(
971            "llm.input_messages.0.message.content",
972            prompt,
973        ));
974    }
975}
976
977fn push_replay_input_message(attributes: &mut Vec<KeyValue>, index: usize, message: &Json) {
978    let Some(object) = message.as_object() else {
979        return;
980    };
981    if !object.contains_key("role") && !object.contains_key("content") {
982        return;
983    }
984    let role = object.get("role").and_then(Json::as_str).unwrap_or("user");
985    push_message_role(attributes, "llm.input_messages", index, role);
986    if let Some(text) = object.get("content").and_then(display_text_from_json) {
987        attributes.push(KeyValue::new(
988            format!("llm.input_messages.{index}.message.content"),
989            text,
990        ));
991    }
992}
993
994fn push_replay_response_attributes(attributes: &mut Vec<KeyValue>, output: &Json) {
995    if output.get("role").is_none()
996        && output.get("content").is_none()
997        && output.get("tool_calls").is_none()
998    {
999        return;
1000    }
1001    let role = output
1002        .get("role")
1003        .and_then(Json::as_str)
1004        .unwrap_or("assistant");
1005    push_message_role(attributes, "llm.output_messages", 0, role);
1006    if let Some(content) = output.get("content").and_then(display_text_from_json) {
1007        attributes.push(KeyValue::new(
1008            "llm.output_messages.0.message.content",
1009            content,
1010        ));
1011    }
1012    if let Some(tool_calls) = output.get("tool_calls").and_then(Json::as_array) {
1013        push_raw_output_tool_calls(attributes, 0, tool_calls);
1014    }
1015}
1016
1017fn push_raw_output_tool_calls(
1018    attributes: &mut Vec<KeyValue>,
1019    message_index: usize,
1020    tool_calls: &[Json],
1021) {
1022    for (call_index, tool_call) in tool_calls.iter().enumerate() {
1023        push_output_tool_call(
1024            attributes,
1025            message_index,
1026            call_index,
1027            tool_call.get("id").and_then(Json::as_str),
1028            raw_tool_call_name(tool_call),
1029            raw_tool_call_arguments(tool_call).and_then(|value| {
1030                value
1031                    .as_str()
1032                    .map(str::to_string)
1033                    .or_else(|| to_json_string(value))
1034            }),
1035        );
1036    }
1037}
1038
1039fn raw_tool_call_name(tool_call: &Json) -> Option<&str> {
1040    tool_call
1041        .get("function")
1042        .and_then(|function| function.get("name"))
1043        .and_then(Json::as_str)
1044        .or_else(|| tool_call.get("name").and_then(Json::as_str))
1045        .or_else(|| tool_call.get("toolName").and_then(Json::as_str))
1046}
1047
1048fn raw_tool_call_arguments(tool_call: &Json) -> Option<&Json> {
1049    tool_call
1050        .get("function")
1051        .and_then(|function| function.get("arguments"))
1052        .or_else(|| tool_call.get("arguments"))
1053        .or_else(|| tool_call.get("input"))
1054}
1055
1056fn push_output_tool_call(
1057    attributes: &mut Vec<KeyValue>,
1058    message_index: usize,
1059    call_index: usize,
1060    id: Option<&str>,
1061    name: Option<&str>,
1062    arguments: Option<String>,
1063) {
1064    if let Some(id) = id {
1065        attributes.push(KeyValue::new(
1066            format!(
1067                "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.id"
1068            ),
1069            id.to_string(),
1070        ));
1071    }
1072    if let Some(name) = name {
1073        attributes.push(KeyValue::new(
1074            format!(
1075                "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.name"
1076            ),
1077            name.to_string(),
1078        ));
1079    }
1080    if let Some(arguments) = arguments {
1081        attributes.push(KeyValue::new(
1082            format!(
1083                "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.arguments"
1084            ),
1085            arguments,
1086        ));
1087    }
1088}
1089
1090fn finish_reason_value(reason: &FinishReason) -> String {
1091    match reason {
1092        FinishReason::Complete => "complete".to_string(),
1093        FinishReason::Length => "length".to_string(),
1094        FinishReason::ToolUse => "tool_use".to_string(),
1095        FinishReason::ContentFilter => "content_filter".to_string(),
1096        FinishReason::Unknown(reason) => reason.clone(),
1097    }
1098}
1099
1100fn cost_total_from_manual_llm_output(output: Option<&Json>) -> Option<f64> {
1101    let object = output?.as_object()?;
1102    let usage = object.get("usage").and_then(Json::as_object);
1103    let token_usage = object.get("token_usage").and_then(Json::as_object);
1104    usage
1105        .and_then(cost_total_from_usage)
1106        .or_else(|| token_usage.and_then(cost_total_from_usage))
1107}
1108
1109fn cost_total_from_llm_event(event: &Event, fallback_usage: Option<&Usage>) -> Option<f64> {
1110    if let Some(cost) = cost_total_from_manual_llm_output(event.output()) {
1111        return Some(cost);
1112    }
1113
1114    if let Some(response) = event.annotated_response()
1115        && let Some(usage) = response.usage.as_ref()
1116    {
1117        if let Some(cost) = usage.cost.as_ref() {
1118            return cost.total_or_component_sum_for_currency("USD");
1119        }
1120        if let Some(model_name) = response.model.as_deref().or_else(|| event.model_name()) {
1121            return estimate_cost_for_provider(Some(event.name()), model_name, usage)
1122                .and_then(|cost| cost.total_for_currency("USD"));
1123        }
1124    }
1125
1126    let usage = fallback_usage?;
1127    let model_name = event
1128        .model_name()
1129        .or_else(|| model_name_from_manual_llm_output(event.output()))?;
1130    estimate_cost_for_provider(Some(event.name()), model_name, usage)
1131        .and_then(|cost| cost.total_for_currency("USD"))
1132}
1133
1134fn model_name_from_manual_llm_output(output: Option<&Json>) -> Option<&str> {
1135    output?.as_object()?.get("model").and_then(Json::as_str)
1136}
1137
1138fn cost_total_from_usage(usage: &serde_json::Map<String, Json>) -> Option<f64> {
1139    usage.get("cost_usd").and_then(Json::as_f64).or_else(|| {
1140        let cost = usage.get("cost")?.as_object()?;
1141        let currency = cost.get("currency").and_then(Json::as_str);
1142        let is_usd_cost = currency.is_none_or(|currency| currency.eq_ignore_ascii_case("USD"));
1143        if !is_usd_cost {
1144            return None;
1145        }
1146        cost.get("total").and_then(Json::as_f64).or_else(|| {
1147            let (has_component, component_total) = ["input", "output", "cache_read", "cache_write"]
1148                .iter()
1149                .filter_map(|field| cost.get(*field).and_then(Json::as_f64))
1150                .fold((false, 0.0), |(_, total), value| (true, total + value));
1151            has_component.then_some(component_total)
1152        })
1153    })
1154}
1155
1156fn usage_from_manual_llm_output(output: Option<&Json>) -> Option<Usage> {
1157    let object = output?.as_object()?;
1158    let usage = object.get("usage").and_then(Json::as_object);
1159    let token_usage = object.get("token_usage").and_then(Json::as_object);
1160    if usage.is_none() && token_usage.is_none() {
1161        return None;
1162    }
1163
1164    let prompt_tokens = first_u64_from_manual_usage(
1165        usage,
1166        token_usage,
1167        &["prompt_tokens", "input_tokens", "inputTokens", "input"],
1168    );
1169    let completion_tokens = first_u64_from_manual_usage(
1170        usage,
1171        token_usage,
1172        &[
1173            "completion_tokens",
1174            "output_tokens",
1175            "completionTokens",
1176            "outputTokens",
1177            "output",
1178        ],
1179    );
1180    let reported_total_tokens = first_u64_from_manual_usage(
1181        usage,
1182        token_usage,
1183        &["total_tokens", "totalTokens", "total"],
1184    );
1185    let cache_read_tokens = first_u64_from_manual_usage(
1186        usage,
1187        token_usage,
1188        &[
1189            "cache_read_tokens",
1190            "cached_tokens",
1191            "cache_read_input_tokens",
1192            "cacheReadTokens",
1193            "cachedTokens",
1194            "cacheReadInputTokens",
1195            "cacheRead",
1196        ],
1197    )
1198    .or_else(|| {
1199        first_nested_u64_from_manual_usage(
1200            usage,
1201            token_usage,
1202            "input_tokens_details",
1203            "cached_tokens",
1204        )
1205    })
1206    .or_else(|| {
1207        first_nested_u64_from_manual_usage(
1208            usage,
1209            token_usage,
1210            "prompt_tokens_details",
1211            "cached_tokens",
1212        )
1213    });
1214    let cache_write_tokens = first_u64_from_manual_usage(
1215        usage,
1216        token_usage,
1217        &[
1218            "cache_write_tokens",
1219            "cache_creation_input_tokens",
1220            "cacheWriteTokens",
1221            "cacheCreationInputTokens",
1222            "cacheWrite",
1223        ],
1224    );
1225
1226    if prompt_tokens.is_none()
1227        && completion_tokens.is_none()
1228        && reported_total_tokens.is_none()
1229        && cache_read_tokens.is_none()
1230        && cache_write_tokens.is_none()
1231    {
1232        return None;
1233    }
1234    let total_tokens =
1235        normalize_total_tokens(reported_total_tokens, prompt_tokens, completion_tokens);
1236
1237    Some(Usage {
1238        prompt_tokens,
1239        completion_tokens,
1240        total_tokens,
1241        cache_read_tokens,
1242        cache_write_tokens,
1243        cost: None,
1244    })
1245}
1246
1247fn normalize_total_tokens(
1248    total_tokens: Option<u64>,
1249    prompt_tokens: Option<u64>,
1250    completion_tokens: Option<u64>,
1251) -> Option<u64> {
1252    let total_tokens = total_tokens?;
1253    let minimum_total = prompt_tokens
1254        .unwrap_or(0)
1255        .saturating_add(completion_tokens.unwrap_or(0));
1256    if minimum_total == 0 || total_tokens >= minimum_total {
1257        Some(total_tokens)
1258    } else {
1259        None
1260    }
1261}
1262
1263fn first_u64_from_manual_usage(
1264    usage: Option<&serde_json::Map<String, Json>>,
1265    token_usage: Option<&serde_json::Map<String, Json>>,
1266    keys: &[&str],
1267) -> Option<u64> {
1268    usage
1269        .and_then(|value| first_u64(value, keys))
1270        .or_else(|| token_usage.and_then(|value| first_u64(value, keys)))
1271}
1272
1273fn first_nested_u64_from_manual_usage(
1274    usage: Option<&serde_json::Map<String, Json>>,
1275    token_usage: Option<&serde_json::Map<String, Json>>,
1276    parent_key: &str,
1277    child_key: &str,
1278) -> Option<u64> {
1279    usage
1280        .and_then(|value| nested_u64(value, parent_key, child_key))
1281        .or_else(|| token_usage.and_then(|value| nested_u64(value, parent_key, child_key)))
1282}
1283
1284fn nested_u64(
1285    usage: &serde_json::Map<String, Json>,
1286    parent_key: &str,
1287    child_key: &str,
1288) -> Option<u64> {
1289    usage
1290        .get(parent_key)
1291        .and_then(Json::as_object)
1292        .and_then(|details| details.get(child_key))
1293        .and_then(Json::as_u64)
1294}
1295
1296fn first_u64(usage: &serde_json::Map<String, Json>, keys: &[&str]) -> Option<u64> {
1297    keys.iter()
1298        .find_map(|key| usage.get(*key).and_then(Json::as_u64))
1299}
1300
1301fn mark_attributes(event: &Event) -> Vec<KeyValue> {
1302    let handle_attributes = event.attributes();
1303    let mut attributes = vec![
1304        KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
1305        KeyValue::new(
1306            "nemo_relay.mark.parent_uuid",
1307            event
1308                .parent_uuid()
1309                .map(|uuid| uuid.to_string())
1310                .unwrap_or_default(),
1311        ),
1312    ];
1313    push_serialized(
1314        &mut attributes,
1315        "nemo_relay.mark.attributes_json",
1316        handle_attributes,
1317    );
1318    push_serialized(&mut attributes, "nemo_relay.mark.data_json", event.data());
1319    push_serialized(
1320        &mut attributes,
1321        "nemo_relay.mark.metadata_json",
1322        event.metadata(),
1323    );
1324    attributes
1325}
1326
1327fn common_attributes(event: &Event) -> Vec<KeyValue> {
1328    let mut attributes = vec![
1329        KeyValue::new(
1330            oi::OPENINFERENCE_SPAN_KIND,
1331            openinference_span_kind(semantic_scope_type(event)),
1332        ),
1333        KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
1334        KeyValue::new(
1335            "nemo_relay.parent_uuid",
1336            event
1337                .parent_uuid()
1338                .map(|uuid| uuid.to_string())
1339                .unwrap_or_default(),
1340        ),
1341        KeyValue::new(
1342            "nemo_relay.scope_type",
1343            scope_type_name(semantic_scope_type(event)),
1344        ),
1345    ];
1346
1347    if let Some(model_name) = event.model_name() {
1348        attributes.push(KeyValue::new(oi::llm::MODEL_NAME, model_name.to_string()));
1349    }
1350    if let Some(tool_call_id) = event.tool_call_id() {
1351        attributes.push(KeyValue::new(oi::tool_call::ID, tool_call_id.to_string()));
1352    }
1353    if let Some(metadata) = event.metadata().and_then(to_json_string) {
1354        attributes.push(KeyValue::new(oi::METADATA, metadata));
1355    }
1356
1357    attributes
1358}
1359
1360fn openinference_span_kind(scope_type: Option<ScopeType>) -> OpenInferenceSpanKind {
1361    match scope_type {
1362        Some(ScopeType::Agent) => OpenInferenceSpanKind::Agent,
1363        Some(ScopeType::Tool) => OpenInferenceSpanKind::Tool,
1364        Some(ScopeType::Llm) => OpenInferenceSpanKind::Llm,
1365        Some(ScopeType::Retriever) => OpenInferenceSpanKind::Retriever,
1366        Some(ScopeType::Embedder) => OpenInferenceSpanKind::Embedding,
1367        Some(ScopeType::Reranker) => OpenInferenceSpanKind::Reranker,
1368        Some(ScopeType::Guardrail) => OpenInferenceSpanKind::Guardrail,
1369        Some(ScopeType::Evaluator) => OpenInferenceSpanKind::Evaluator,
1370        Some(ScopeType::Function | ScopeType::Custom | ScopeType::Unknown) | None => {
1371            OpenInferenceSpanKind::Chain
1372        }
1373    }
1374}
1375
1376fn push_serialized<T: Serialize + ?Sized>(
1377    attributes: &mut Vec<KeyValue>,
1378    key: &'static str,
1379    value: Option<&T>,
1380) {
1381    if let Some(value) = value
1382        && let Ok(json) = serde_json::to_string(value)
1383    {
1384        attributes.push(KeyValue::new(key, json));
1385    }
1386}
1387
1388fn openinference_input_value(event: &Event) -> Option<(String, &'static str)> {
1389    let input = event.input()?;
1390
1391    if event
1392        .category()
1393        .is_some_and(|category| category.as_str() == "llm")
1394    {
1395        return llm_input_display_value(input)
1396            .map(|display| (display, "text/plain"))
1397            .or_else(|| sanitized_llm_input_json(input).map(|json| (json, "application/json")));
1398    }
1399
1400    to_json_string(input).map(|json| (json, "application/json"))
1401}
1402
1403fn openinference_output_value(event: &Event) -> Option<(String, &'static str)> {
1404    let output = event.output()?;
1405    display_text_from_json(output)
1406        .map(|display| (display, "text/plain"))
1407        .or_else(|| to_json_string(output).map(|json| (json, "application/json")))
1408}
1409
1410fn llm_input_display_value(input: &Json) -> Option<String> {
1411    let content = match input {
1412        Json::Object(object) => object.get("content").unwrap_or(input),
1413        _ => input,
1414    };
1415
1416    content
1417        .get("messages")
1418        .and_then(display_text_from_messages)
1419        .or_else(|| display_text_from_json(content))
1420}
1421
1422fn sanitized_llm_input_json(input: &Json) -> Option<String> {
1423    match input {
1424        Json::Object(object) => {
1425            let mut sanitized = object.clone();
1426            sanitized.remove("headers");
1427            to_json_string(&Json::Object(sanitized))
1428        }
1429        _ => to_json_string(input),
1430    }
1431}
1432
1433fn display_text_from_json(value: &Json) -> Option<String> {
1434    match value {
1435        Json::String(text) => display_text_from_string(text),
1436        Json::Object(object) => {
1437            for key in ["content", "summary", "message", "text", "prompt"] {
1438                if let Some(display) = object.get(key).and_then(display_text_from_json) {
1439                    return Some(display);
1440                }
1441            }
1442            object
1443                .get("output")
1444                .and_then(display_text_from_openai_responses_output)
1445                .or_else(|| {
1446                    object
1447                        .get("choices")
1448                        .and_then(display_text_from_chat_choices)
1449                })
1450                .or_else(|| {
1451                    object
1452                        .get("tool_calls")
1453                        .and_then(display_text_from_tool_calls)
1454                })
1455        }
1456        Json::Array(items) => display_text_from_content_blocks(items),
1457        _ => None,
1458    }
1459}
1460
1461fn display_text_from_openai_responses_output(value: &Json) -> Option<String> {
1462    let items = value.as_array()?;
1463    let mut entries = Vec::new();
1464    let mut tool_names = Vec::new();
1465    for item in items {
1466        let Some(object) = item.as_object() else {
1467            continue;
1468        };
1469        match object.get("type").and_then(Json::as_str) {
1470            Some("message") => {
1471                if let Some(content) = object
1472                    .get("content")
1473                    .and_then(display_text_from_openai_responses_content)
1474                {
1475                    entries.push(content);
1476                }
1477            }
1478            Some("function_call") => {
1479                if let Some(name) = object.get("name").and_then(Json::as_str) {
1480                    tool_names.push(name.to_string());
1481                }
1482            }
1483            _ => {}
1484        }
1485    }
1486    if !tool_names.is_empty() {
1487        entries.push(format!("Requested tools: {}", tool_names.join(", ")));
1488    }
1489    let text = entries.join("\n").trim().to_string();
1490    if text.is_empty() { None } else { Some(text) }
1491}
1492
1493fn display_text_from_openai_responses_content(value: &Json) -> Option<String> {
1494    let content = value.as_array()?;
1495    let text = content
1496        .iter()
1497        .filter_map(|part| {
1498            let object = part.as_object()?;
1499            match object.get("type").and_then(Json::as_str) {
1500                Some("output_text" | "text") => object.get("text").and_then(Json::as_str),
1501                _ => None,
1502            }
1503        })
1504        .collect::<Vec<_>>()
1505        .join("\n\n")
1506        .trim()
1507        .to_string();
1508    if text.is_empty() { None } else { Some(text) }
1509}
1510
1511fn display_text_from_messages(value: &Json) -> Option<String> {
1512    let messages = value.as_array()?;
1513    let text = messages
1514        .iter()
1515        .filter_map(display_text_from_message)
1516        .collect::<Vec<_>>()
1517        .join("\n\n")
1518        .trim()
1519        .to_string();
1520    if text.is_empty() { None } else { Some(text) }
1521}
1522
1523fn display_text_from_message(value: &Json) -> Option<String> {
1524    let role = value
1525        .get("role")
1526        .and_then(Json::as_str)
1527        .unwrap_or("message");
1528    if role == "tool" {
1529        return Some("tool: Tool result omitted".to_string());
1530    }
1531    let display = value
1532        .get("content")
1533        .and_then(display_text_from_json)
1534        .or_else(|| {
1535            value
1536                .get("tool_calls")
1537                .and_then(display_text_from_tool_calls)
1538        })?;
1539    Some(format!("{role}: {display}"))
1540}
1541
1542fn display_text_from_string(text: &str) -> Option<String> {
1543    let trimmed = text.trim();
1544    if trimmed.is_empty() {
1545        return None;
1546    }
1547    if let Ok(parsed) = serde_json::from_str::<Json>(trimmed)
1548        && let Some(display) = display_text_from_json(&parsed)
1549    {
1550        return Some(display);
1551    }
1552    Some(trimmed.to_string())
1553}
1554
1555fn display_text_from_chat_choices(value: &Json) -> Option<String> {
1556    let choices = value.as_array()?;
1557    for choice in choices {
1558        let Some(message) = choice.get("message") else {
1559            continue;
1560        };
1561        let content = message.get("content").and_then(display_text_from_json);
1562        let tool_calls = message
1563            .get("tool_calls")
1564            .and_then(display_text_from_tool_calls);
1565        match (content, tool_calls) {
1566            (Some(content), Some(tool_calls)) => return Some(format!("{content}\n{tool_calls}")),
1567            (Some(content), None) => return Some(content),
1568            (None, Some(tool_calls)) => return Some(tool_calls),
1569            (None, None) => {}
1570        }
1571    }
1572    None
1573}
1574
1575fn display_text_from_content_blocks(items: &[Json]) -> Option<String> {
1576    let mut entries = items
1577        .iter()
1578        .filter_map(content_block_display_text)
1579        .collect::<Vec<_>>();
1580    let tool_calls = items.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1581    if !tool_calls.is_empty() {
1582        entries.push(format!("Requested tools: {}", tool_calls.join(", ")));
1583    }
1584    let text = entries
1585        .into_iter()
1586        .filter(|item| !item.trim().is_empty())
1587        .collect::<Vec<_>>()
1588        .join("\n")
1589        .trim()
1590        .to_string();
1591    if text.is_empty() { None } else { Some(text) }
1592}
1593
1594fn content_block_display_text(item: &Json) -> Option<String> {
1595    if let Some(text) = item.as_str() {
1596        return Some(text.to_string());
1597    }
1598    if item.get("stripped").and_then(Json::as_bool) == Some(true) {
1599        return None;
1600    }
1601    if let Some("thinking" | "reasoning" | "toolResult" | "tool_result") =
1602        item.get("type").and_then(Json::as_str)
1603    {
1604        return None;
1605    }
1606    item.get("text").and_then(Json::as_str).map(str::to_string)
1607}
1608
1609fn display_text_from_tool_calls(value: &Json) -> Option<String> {
1610    let calls = value.as_array()?;
1611    let names = calls.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1612    if names.is_empty() {
1613        None
1614    } else {
1615        Some(format!("Requested tools: {}", names.join(", ")))
1616    }
1617}
1618
1619fn tool_call_name(value: &Json) -> Option<String> {
1620    value
1621        .get("name")
1622        .and_then(Json::as_str)
1623        .or_else(|| value.get("toolName").and_then(Json::as_str))
1624        .or_else(|| {
1625            value
1626                .get("function")
1627                .and_then(|function| function.get("name"))
1628                .and_then(Json::as_str)
1629        })
1630        .map(str::to_string)
1631}
1632
1633fn to_json_string<T: Serialize>(value: &T) -> Option<String> {
1634    serde_json::to_string(value).ok()
1635}
1636
1637fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
1638    SpanContext::new(
1639        span_context.trace_id(),
1640        span_context.span_id(),
1641        span_context.trace_flags(),
1642        false,
1643        span_context.trace_state().clone(),
1644    )
1645}
1646
1647fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
1648    let seconds = timestamp.timestamp();
1649    let nanos = timestamp.timestamp_subsec_nanos();
1650    if seconds >= 0 {
1651        UNIX_EPOCH + Duration::new(seconds as u64, nanos)
1652    } else if nanos == 0 {
1653        UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
1654    } else {
1655        UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
1656    }
1657}
1658
1659#[cfg(test)]
1660#[path = "../../tests/unit/observability/openinference_tests.rs"]
1661mod tests;