Skip to main content

nemo_relay/observability/
otel.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenTelemetry subscriber support for NeMo Relay.
5//!
6//! This crate adapts NeMo Relay lifecycle events into the selected `full`,
7//! `gen_ai`, or `openinference` OpenTelemetry trace projection. Scope start and
8//! end events open and close supported spans. Mark behavior is fixed by the
9//! selected projection.
10//!
11//! The public API is intentionally small:
12//!
13//! - [`OpenTelemetryConfig`] configures the OTLP exporter and resource metadata
14//! - [`OpenTelemetrySubscriber`] exposes a NeMo Relay [`EventSubscriberFn`] and
15//!   convenience `register` / `deregister` / `force_flush` / `shutdown` methods
16
17use std::borrow::Cow;
18use std::cell::RefCell;
19use std::collections::{HashMap, HashSet, VecDeque};
20use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
21use std::sync::mpsc;
22use std::sync::{Arc, Mutex};
23use std::thread;
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26use super::{
27    MarkProjection, OpenTelemetryType, OtlpAttributeMapping, apply_attribute_mappings,
28    attribute_mapping_aliases, attribute_mapping_inputs, default_mark_exclude_names,
29    effective_mark_projection, estimate_cost_for_response_or_model,
30    estimate_cost_for_response_or_requested_model, manual, model_name_for_llm_event,
31    push_serialized_top_level_attributes, push_session_identity_attributes,
32    push_top_level_json_attributes, relay_span_id, relay_trace_id, validate_attribute_mappings,
33};
34use crate::api::event::{Event, EventNormalizationExt, ScopeCategory};
35use crate::api::runtime::{EventSubscriberFn, current_scope_stack};
36use crate::api::scope::ScopeType;
37use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
38use crate::codec::response::CostEstimate;
39use crate::error::FlowError;
40use chrono::{DateTime, Utc};
41use opentelemetry::trace::{
42    Span as _, SpanContext, SpanId, SpanKind, TraceContextExt, TraceFlags, TraceId, TraceState,
43    Tracer, TracerProvider as _,
44};
45use opentelemetry::{Context, KeyValue};
46use opentelemetry_otlp::{
47    Protocol, SpanExporter as OtlpSpanExporter, WithExportConfig, WithHttpConfig,
48};
49use opentelemetry_sdk::Resource;
50use opentelemetry_sdk::error::{OTelSdkError, OTelSdkResult};
51use opentelemetry_sdk::trace::{
52    BatchSpanProcessor, IdGenerator, RandomIdGenerator, SdkTracer, SdkTracerProvider, Span,
53    SpanData, SpanExporter, SpanProcessor,
54};
55use uuid::Uuid;
56
57use crate::plugin::{
58    OTEL_RUNTIME_DELIVERY_FAILURE_MARKER, RuntimeDiagnostic,
59    record_active_plugin_runtime_diagnostic,
60};
61
62pub(super) const COMPLETED_SPAN_CONTEXT_LIMIT: usize = 4096;
63
64use opentelemetry_otlp::WithTonicConfig;
65use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
66
67thread_local! {
68    static PENDING_RELAY_IDS: RefCell<Option<(TraceId, SpanId)>> = const { RefCell::new(None) };
69}
70
71#[derive(Clone, Debug, Default)]
72pub(crate) struct RelayIdGenerator;
73
74impl IdGenerator for RelayIdGenerator {
75    fn new_trace_id(&self) -> TraceId {
76        PENDING_RELAY_IDS
77            .with(|ids| ids.borrow().map(|(trace_id, _)| trace_id))
78            .unwrap_or_else(|| RandomIdGenerator::default().new_trace_id())
79    }
80
81    fn new_span_id(&self) -> SpanId {
82        PENDING_RELAY_IDS
83            .with(|ids| ids.borrow().map(|(_, span_id)| span_id))
84            .unwrap_or_else(|| RandomIdGenerator::default().new_span_id())
85    }
86}
87
88fn with_relay_ids<T>(uuid: Uuid, build: impl FnOnce() -> T) -> T {
89    struct ResetPendingIds;
90
91    impl Drop for ResetPendingIds {
92        fn drop(&mut self) {
93            PENDING_RELAY_IDS.with(|ids| {
94                ids.replace(None);
95            });
96        }
97    }
98
99    PENDING_RELAY_IDS.with(|ids| {
100        ids.replace(Some((
101            super::relay_trace_id(uuid),
102            super::relay_span_id(uuid),
103        )));
104    });
105    let _reset = ResetPendingIds;
106    build()
107}
108
109/// Result type for the OpenTelemetry subscriber crate.
110pub type Result<T> = std::result::Result<T, OpenTelemetryError>;
111
112/// Errors produced while configuring or operating the OpenTelemetry subscriber.
113#[derive(Debug, thiserror::Error)]
114pub enum OpenTelemetryError {
115    /// Failed to parse a configured gRPC metadata header.
116    #[error("invalid OTLP gRPC header {key:?}: {message}")]
117    InvalidGrpcHeader {
118        /// Header name that failed to parse.
119        key: String,
120        /// Parser failure message.
121        message: String,
122    },
123    /// A configured OTLP header name or value is invalid.
124    #[error("invalid OTLP header {key:?}: {message}")]
125    InvalidHeader {
126        /// Header name that failed validation.
127        key: String,
128        /// Validation failure message.
129        message: String,
130    },
131    /// Process-global OTLP header variables cannot be isolated per endpoint.
132    #[error(
133        "{variable} is not supported because process-global OTLP headers can leak across endpoints; use the endpoint headers or header_env configuration"
134    )]
135    GlobalHeaderEnvironmentUnsupported {
136        /// Environment variable that was set.
137        variable: &'static str,
138    },
139    /// Failed to build the OTLP exporter.
140    #[error("failed to build the OTLP exporter: {0}")]
141    ExporterBuild(String),
142    /// The underlying tracer provider returned an error.
143    #[error("OpenTelemetry tracer provider error: {0}")]
144    Provider(String),
145    /// Attribute mapping configuration was invalid.
146    #[error("invalid attribute mappings: {0}")]
147    InvalidAttributeMappings(String),
148    /// Registration errors from the core runtime.
149    #[error(transparent)]
150    Core(#[from] FlowError),
151}
152
153/// Supported OTLP trace transports.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub enum OtlpTransport {
156    /// OTLP/HTTP protobuf, typically `http://host:4318/v1/traces`.
157    #[default]
158    HttpBinary,
159    /// OTLP/gRPC, typically `http://host:4317`.
160    Grpc,
161}
162
163/// Completes a bare OTLP/HTTP base URL with the standard trace signal path.
164#[doc(hidden)]
165pub fn resolve_http_trace_endpoint(endpoint: &str) -> Cow<'_, str> {
166    let Ok(mut parsed) = reqwest::Url::parse(endpoint) else {
167        return Cow::Borrowed(endpoint);
168    };
169
170    let has_explicit_root_path = endpoint
171        .split(['?', '#'])
172        .next()
173        .is_some_and(|url| url.ends_with('/'));
174    if !matches!(parsed.scheme(), "http" | "https")
175        || parsed.path() != "/"
176        || has_explicit_root_path
177    {
178        return Cow::Borrowed(endpoint);
179    }
180    parsed.set_path("/v1/traces");
181    Cow::Owned(parsed.into())
182}
183
184/// Configuration for the OpenTelemetry subscriber.
185#[derive(Debug, Clone)]
186pub struct OpenTelemetryConfig {
187    otel_type: OpenTelemetryType,
188    endpoint: String,
189    headers: HashMap<String, String>,
190    resource_attributes: HashMap<String, String>,
191    service_name: String,
192    service_namespace: Option<String>,
193    service_version: Option<String>,
194    instrumentation_scope: String,
195    mark_projection: MarkProjection,
196    mark_exclude_names: Vec<String>,
197    attribute_mappings: Vec<OtlpAttributeMapping>,
198    timeout: Duration,
199    transport: OtlpTransport,
200}
201
202impl OpenTelemetryConfig {
203    fn default_values() -> Self {
204        Self {
205            otel_type: OpenTelemetryType::Full,
206            endpoint: String::new(),
207            headers: HashMap::new(),
208            resource_attributes: HashMap::new(),
209            service_name: "unknown_service".to_string(),
210            service_namespace: None,
211            service_version: None,
212            instrumentation_scope: "opentelemetry".to_string(),
213            mark_projection: MarkProjection::default(),
214            mark_exclude_names: default_mark_exclude_names(),
215            attribute_mappings: Vec::new(),
216            timeout: Duration::from_secs(3),
217            transport: OtlpTransport::HttpBinary,
218        }
219    }
220
221    /// Creates a typed OpenTelemetry exporter for a required OTLP endpoint.
222    pub fn new(otel_type: OpenTelemetryType, endpoint: impl Into<String>) -> Self {
223        Self {
224            otel_type,
225            endpoint: endpoint.into(),
226            ..Self::default_values()
227        }
228    }
229
230    /// Creates an HTTP OTLP config for the given service name.
231    #[cfg(test)]
232    pub(crate) fn http_binary(service_name: impl Into<String>) -> Self {
233        Self {
234            service_name: service_name.into(),
235            transport: OtlpTransport::HttpBinary,
236            ..Self::default_values()
237        }
238    }
239
240    /// Creates a gRPC OTLP config for the given service name.
241    #[cfg(test)]
242    pub(crate) fn grpc(service_name: impl Into<String>) -> Self {
243        Self {
244            service_name: service_name.into(),
245            transport: OtlpTransport::Grpc,
246            ..Self::default_values()
247        }
248    }
249
250    /// Overrides the OTLP endpoint. If unset, exporter defaults and OTEL_* env vars apply.
251    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
252        self.endpoint = endpoint.into();
253        self
254    }
255
256    /// Selects the OTLP transport.
257    pub fn with_transport(mut self, transport: OtlpTransport) -> Self {
258        self.transport = transport;
259        self
260    }
261
262    /// Sets the `service.name` resource attribute.
263    pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
264        self.service_name = service_name.into();
265        self
266    }
267
268    /// Adds a header/metadata entry for the exporter.
269    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
270        self.headers.insert(key.into(), value.into());
271        self
272    }
273
274    #[cfg(test)]
275    pub(crate) fn header(&self, key: &str) -> Option<&str> {
276        self.headers.get(key).map(String::as_str)
277    }
278
279    /// Adds a resource attribute as a string key/value pair.
280    pub fn with_resource_attribute(
281        mut self,
282        key: impl Into<String>,
283        value: impl Into<String>,
284    ) -> Self {
285        self.resource_attributes.insert(key.into(), value.into());
286        self
287    }
288
289    /// Sets the OTLP request timeout.
290    pub fn with_timeout(mut self, timeout: Duration) -> Self {
291        self.timeout = timeout;
292        self
293    }
294
295    /// Sets the service namespace resource attribute.
296    pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
297        self.service_namespace = Some(namespace.into());
298        self
299    }
300
301    /// Sets the service version resource attribute.
302    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
303        self.service_version = Some(version.into());
304        self
305    }
306
307    /// Sets the instrumentation scope name used for emitted spans.
308    pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
309        self.instrumentation_scope = scope.into();
310        self
311    }
312
313    /// Selects how point-in-time marks are represented in exported traces.
314    pub fn with_mark_projection(mut self, mark_projection: MarkProjection) -> Self {
315        self.mark_projection = mark_projection;
316        self
317    }
318
319    /// Excludes named marks from tool projection.
320    pub fn with_mark_exclude_names<I, S>(mut self, names: I) -> Self
321    where
322        I: IntoIterator<Item = S>,
323        S: Into<String>,
324    {
325        self.mark_exclude_names = names.into_iter().map(Into::into).collect();
326        self
327    }
328
329    /// Adds a projected OpenTelemetry attribute alias.
330    pub fn with_attribute_mapping(
331        mut self,
332        key: impl Into<String>,
333        alias: impl Into<String>,
334    ) -> Self {
335        self.attribute_mappings
336            .push(OtlpAttributeMapping::new(key, alias));
337        self
338    }
339
340    /// Replaces projected OpenTelemetry attribute aliases.
341    pub fn with_attribute_mappings<I>(mut self, mappings: I) -> Self
342    where
343        I: IntoIterator<Item = OtlpAttributeMapping>,
344    {
345        self.attribute_mappings = mappings.into_iter().collect();
346        self
347    }
348}
349
350#[cfg(test)]
351impl Default for OpenTelemetryConfig {
352    fn default() -> Self {
353        Self::default_values()
354    }
355}
356
357/// OpenTelemetry-backed NeMo Relay subscriber.
358#[derive(Clone)]
359pub struct OpenTelemetrySubscriber {
360    inner: Arc<Inner>,
361}
362
363/// Options for constructing an OpenTelemetry subscriber from an existing tracer provider.
364#[derive(Debug, Clone)]
365pub struct OpenTelemetrySubscriberOptions {
366    /// How mark events are projected into the trace.
367    pub mark_projection: MarkProjection,
368    /// Mark names excluded from tool projection.
369    pub mark_exclude_names: Vec<String>,
370    /// Typed OTLP attributes copied to alias keys.
371    pub attribute_mappings: Vec<OtlpAttributeMapping>,
372}
373
374impl Default for OpenTelemetrySubscriberOptions {
375    fn default() -> Self {
376        Self {
377            mark_projection: MarkProjection::default(),
378            mark_exclude_names: default_mark_exclude_names(),
379            attribute_mappings: Vec::new(),
380        }
381    }
382}
383
384struct Inner {
385    // Keep `processor` before `_runtime`: the processor owns the
386    // `SdkTracerProvider` and must be dropped before `ExporterRuntime` joins
387    // and tears down its Tokio runtime. Do not reorder these fields.
388    processor: Arc<Mutex<OtelEventProcessor>>,
389    subscriber: EventSubscriberFn,
390    _runtime: Option<ExporterRuntime>,
391}
392
393struct ExporterRuntime {
394    stop: Option<mpsc::Sender<()>>,
395    thread: Option<thread::JoinHandle<()>>,
396}
397
398impl Drop for ExporterRuntime {
399    fn drop(&mut self) {
400        self.stop.take();
401        if let Some(thread) = self.thread.take() {
402            let _ = thread.join();
403        }
404    }
405}
406
407impl OpenTelemetrySubscriber {
408    /// Builds a subscriber backed by a new OTLP tracer provider.
409    pub fn new(config: OpenTelemetryConfig) -> Result<Self> {
410        Self::new_with_runtime_diagnostics(config, None)
411    }
412
413    pub(crate) fn new_for_plugin(
414        config: OpenTelemetryConfig,
415        endpoint_index: usize,
416    ) -> Result<Self> {
417        Self::new_with_runtime_diagnostics(
418            config,
419            Some(format!(
420                "opentelemetry.endpoints[{endpoint_index}].endpoint"
421            )),
422        )
423    }
424
425    fn new_with_runtime_diagnostics(
426        config: OpenTelemetryConfig,
427        diagnostic_field: Option<String>,
428    ) -> Result<Self> {
429        if config.endpoint.trim().is_empty() {
430            return Err(OpenTelemetryError::ExporterBuild(
431                "endpoint must be a nonblank string".to_string(),
432            ));
433        }
434        validate_attribute_mappings(&config.attribute_mappings)
435            .map_err(OpenTelemetryError::InvalidAttributeMappings)?;
436        reject_global_header_environment()?;
437        validate_headers(&config.headers)?;
438        let (provider, runtime) = build_owned_tracer_provider(config.clone(), diagnostic_field)?;
439        Ok(Self::from_tracer_provider_with_scope_and_type(
440            provider,
441            config.instrumentation_scope,
442            config.otel_type,
443            config.mark_projection,
444            config.mark_exclude_names,
445            config.attribute_mappings,
446            Some(runtime),
447        ))
448    }
449
450    /// Builds a subscriber from an already-configured tracer provider.
451    pub fn from_tracer_provider(
452        provider: SdkTracerProvider,
453        instrumentation_scope: impl Into<String>,
454    ) -> Self {
455        Self::from_tracer_provider_with_type(
456            provider,
457            instrumentation_scope,
458            OpenTelemetryType::Full,
459        )
460    }
461
462    /// Builds a typed subscriber from an already-configured tracer provider.
463    pub fn from_tracer_provider_with_type(
464        provider: SdkTracerProvider,
465        instrumentation_scope: impl Into<String>,
466        otel_type: OpenTelemetryType,
467    ) -> Self {
468        let instrumentation_scope = instrumentation_scope.into();
469        Self::from_tracer_provider_with_scope_and_type(
470            provider,
471            instrumentation_scope,
472            otel_type,
473            MarkProjection::default(),
474            default_mark_exclude_names(),
475            Vec::new(),
476            None,
477        )
478    }
479
480    /// Builds a subscriber from a tracer provider with typed attribute copies.
481    pub fn from_tracer_provider_with_attribute_mappings<I>(
482        provider: SdkTracerProvider,
483        instrumentation_scope: impl Into<String>,
484        attribute_mappings: I,
485    ) -> Result<Self>
486    where
487        I: IntoIterator<Item = OtlpAttributeMapping>,
488    {
489        let attribute_mappings = attribute_mappings.into_iter().collect::<Vec<_>>();
490        Self::from_tracer_provider_with_options(
491            provider,
492            instrumentation_scope,
493            OpenTelemetrySubscriberOptions {
494                attribute_mappings,
495                ..Default::default()
496            },
497        )
498    }
499
500    /// Builds a subscriber from a tracer provider with composable projection options.
501    pub fn from_tracer_provider_with_options(
502        provider: SdkTracerProvider,
503        instrumentation_scope: impl Into<String>,
504        options: OpenTelemetrySubscriberOptions,
505    ) -> Result<Self> {
506        validate_attribute_mappings(&options.attribute_mappings)
507            .map_err(OpenTelemetryError::InvalidAttributeMappings)?;
508        Ok(Self::from_tracer_provider_with_scope_and_type(
509            provider,
510            instrumentation_scope.into(),
511            OpenTelemetryType::Full,
512            options.mark_projection,
513            options.mark_exclude_names,
514            options.attribute_mappings,
515            None,
516        ))
517    }
518
519    /// Builds a typed subscriber from a tracer provider with projection options.
520    pub fn from_tracer_provider_with_type_and_options(
521        provider: SdkTracerProvider,
522        instrumentation_scope: impl Into<String>,
523        otel_type: OpenTelemetryType,
524        options: OpenTelemetrySubscriberOptions,
525    ) -> Result<Self> {
526        validate_attribute_mappings(&options.attribute_mappings)
527            .map_err(OpenTelemetryError::InvalidAttributeMappings)?;
528        Ok(Self::from_tracer_provider_with_scope_and_type(
529            provider,
530            instrumentation_scope.into(),
531            otel_type,
532            options.mark_projection,
533            options.mark_exclude_names,
534            options.attribute_mappings,
535            None,
536        ))
537    }
538
539    fn from_tracer_provider_with_scope_and_type(
540        provider: SdkTracerProvider,
541        instrumentation_scope: String,
542        otel_type: OpenTelemetryType,
543        mark_projection: MarkProjection,
544        mark_exclude_names: Vec<String>,
545        attribute_mappings: Vec<OtlpAttributeMapping>,
546        runtime: Option<ExporterRuntime>,
547    ) -> Self {
548        let processor = Arc::new(Mutex::new(
549            OtelEventProcessor::new_with_mark_projection_and_exclusions_and_mappings(
550                provider,
551                instrumentation_scope,
552                otel_type,
553                mark_projection,
554                mark_exclude_names,
555                attribute_mappings,
556            ),
557        ));
558        let processor_for_callback = Arc::clone(&processor);
559        let subscriber: EventSubscriberFn = Arc::new(move |event: &Event| {
560            let Ok(mut guard) = processor_for_callback.lock() else {
561                // Observability should not take down the host process if the
562                // subscriber state was previously poisoned.
563                return;
564            };
565            guard.process(event);
566        });
567
568        Self {
569            inner: Arc::new(Inner {
570                processor,
571                subscriber,
572                _runtime: runtime,
573            }),
574        }
575    }
576
577    /// Returns the raw NeMo Relay subscriber callback for custom registration flows.
578    pub fn subscriber(&self) -> EventSubscriberFn {
579        Arc::clone(&self.inner.subscriber)
580    }
581
582    /// Registers this subscriber globally with the NeMo Relay runtime.
583    pub fn register(&self, name: &str) -> Result<()> {
584        register_subscriber(name, self.subscriber())?;
585        log::info!(
586            target: "nemo_relay.observability",
587            event = "exporter_registered",
588            exporter = "opentelemetry",
589            subscriber = name;
590            "OpenTelemetry exporter registered"
591        );
592        Ok(())
593    }
594
595    /// Deregisters a previously-registered global subscriber by name.
596    pub fn deregister(&self, name: &str) -> Result<bool> {
597        let removed = deregister_subscriber(name)?;
598        if removed {
599            log::info!(
600                target: "nemo_relay.observability",
601                event = "subscriber_deregistered",
602                subscriber = name;
603                "Observability subscriber deregistered"
604            );
605        }
606        Ok(removed)
607    }
608
609    /// Flushes finished spans through the underlying tracer provider.
610    pub fn force_flush(&self) -> Result<()> {
611        flush_subscribers()?;
612        let guard = self.inner.processor.lock().map_err(|_| {
613            OpenTelemetryError::Provider("the subscriber state lock was poisoned".to_string())
614        })?;
615        guard.force_flush()
616    }
617
618    /// Shuts down the underlying tracer provider.
619    ///
620    /// Call `deregister(...)` first if the subscriber is still registered with NeMo Relay.
621    pub fn shutdown(&self) -> Result<()> {
622        let barrier_error = flush_subscribers().err().map(OpenTelemetryError::Core);
623        let provider_result = self.shutdown_provider();
624        if let Some(error) = barrier_error {
625            return Err(error);
626        }
627        provider_result
628    }
629
630    pub(crate) fn shutdown_provider(&self) -> Result<()> {
631        let guard = self.inner.processor.lock().map_err(|_| {
632            OpenTelemetryError::Provider("the subscriber state lock was poisoned".to_string())
633        })?;
634        let result = guard.shutdown();
635        if result.is_ok() {
636            log::info!(
637                target: "nemo_relay.observability",
638                event = "exporter_shutdown",
639                exporter = "opentelemetry";
640                "OpenTelemetry exporter shut down"
641            );
642        }
643        result
644    }
645}
646
647fn build_owned_tracer_provider(
648    config: OpenTelemetryConfig,
649    diagnostic_field: Option<String>,
650) -> Result<(SdkTracerProvider, ExporterRuntime)> {
651    let (result_sender, result_receiver) = mpsc::sync_channel(1);
652    let (stop_sender, stop_receiver) = mpsc::channel();
653    let runtime_thread = thread::Builder::new()
654        .name("nemo-relay-otlp".to_string())
655        .spawn(move || {
656            let runtime = match tokio::runtime::Builder::new_multi_thread()
657                .worker_threads(1)
658                .enable_all()
659                .build()
660            {
661                Ok(runtime) => runtime,
662                Err(error) => {
663                    let _ = result_sender
664                        .send(Err(OpenTelemetryError::ExporterBuild(error.to_string())));
665                    return;
666                }
667            };
668            let provider = {
669                let _guard = runtime.enter();
670                build_tracer_provider(&config, diagnostic_field)
671            };
672            let keep_runtime_alive = provider.is_ok();
673            let _ = result_sender.send(provider);
674            if keep_runtime_alive {
675                let _ = stop_receiver.recv();
676            }
677        })
678        .map_err(|error| OpenTelemetryError::ExporterBuild(error.to_string()))?;
679    let provider = result_receiver.recv().map_err(|error| {
680        OpenTelemetryError::ExporterBuild(format!("exporter runtime stopped unexpectedly: {error}"))
681    })??;
682    Ok((
683        provider,
684        ExporterRuntime {
685            stop: Some(stop_sender),
686            thread: Some(runtime_thread),
687        },
688    ))
689}
690
691fn reject_global_header_environment() -> Result<()> {
692    for variable in [
693        "OTEL_EXPORTER_OTLP_HEADERS",
694        "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
695    ] {
696        if std::env::var_os(variable).is_some_and(|value| !value.is_empty()) {
697            return Err(OpenTelemetryError::GlobalHeaderEnvironmentUnsupported { variable });
698        }
699    }
700    Ok(())
701}
702
703pub(crate) fn validate_headers(headers: &HashMap<String, String>) -> Result<()> {
704    let mut normalized = HashSet::new();
705    for (key, value) in headers {
706        let normalized_key = key.to_ascii_lowercase();
707        if !normalized.insert(normalized_key) {
708            return Err(OpenTelemetryError::InvalidHeader {
709                key: key.clone(),
710                message: "header names must be unique ignoring ASCII case".to_string(),
711            });
712        }
713        reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|error| {
714            OpenTelemetryError::InvalidHeader {
715                key: key.clone(),
716                message: error.to_string(),
717            }
718        })?;
719        reqwest::header::HeaderValue::from_str(value).map_err(|error| {
720            OpenTelemetryError::InvalidHeader {
721                key: key.clone(),
722                message: error.to_string(),
723            }
724        })?;
725    }
726    Ok(())
727}
728
729fn build_tracer_provider(
730    config: &OpenTelemetryConfig,
731    diagnostic_field: Option<String>,
732) -> Result<SdkTracerProvider> {
733    let exporter = match config.transport {
734        OtlpTransport::HttpBinary => {
735            let mut builder = OtlpSpanExporter::builder()
736                .with_http()
737                .with_protocol(Protocol::HttpBinary)
738                .with_timeout(config.timeout);
739            builder =
740                builder.with_endpoint(resolve_http_trace_endpoint(&config.endpoint).into_owned());
741            if !config.headers.is_empty() {
742                builder = builder.with_headers(config.headers.clone());
743            }
744            builder
745                .build()
746                .map_err(|e| OpenTelemetryError::ExporterBuild(e.to_string()))?
747        }
748        OtlpTransport::Grpc => {
749            let mut builder = OtlpSpanExporter::builder()
750                .with_tonic()
751                .with_protocol(Protocol::Grpc)
752                .with_timeout(config.timeout);
753            builder = builder.with_endpoint(config.endpoint.clone());
754            if !config.headers.is_empty() {
755                builder = builder.with_metadata(build_grpc_metadata(&config.headers)?);
756            }
757            builder
758                .build()
759                .map_err(|e| OpenTelemetryError::ExporterBuild(e.to_string()))?
760        }
761    };
762
763    let mut resource_attributes = vec![KeyValue::new("service.name", config.service_name.clone())];
764    if let Some(service_namespace) = &config.service_namespace {
765        resource_attributes.push(KeyValue::new(
766            "service.namespace",
767            service_namespace.clone(),
768        ));
769    }
770    if let Some(service_version) = &config.service_version {
771        resource_attributes.push(KeyValue::new("service.version", service_version.clone()));
772    }
773    for (key, value) in &config.resource_attributes {
774        resource_attributes.push(KeyValue::new(key.clone(), value.clone()));
775    }
776
777    // Disable per-span attribute caps. Consumers may emit large attribute
778    // sets on long-running spans; the OTel SDK default (128) silently drops
779    // attributes added last in the span's lifecycle.
780    let builder = SdkTracerProvider::builder()
781        .with_resource(
782            Resource::builder_empty()
783                .with_attributes(resource_attributes)
784                .build(),
785        )
786        .with_id_generator(RelayIdGenerator)
787        .with_max_attributes_per_span(u32::MAX)
788        .with_max_attributes_per_event(u32::MAX);
789
790    let processor =
791        DiagnosticBatchSpanProcessor::new(exporter, config.endpoint.clone(), diagnostic_field);
792    Ok(builder.with_span_processor(processor).build())
793}
794
795#[derive(Debug)]
796struct CountingSpanExporter<E> {
797    inner: E,
798    accepted_spans: Arc<AtomicU64>,
799}
800
801impl<E: SpanExporter> SpanExporter for CountingSpanExporter<E> {
802    async fn export(&self, batch: Vec<SpanData>) -> OTelSdkResult {
803        self.accepted_spans
804            .fetch_add(batch.len() as u64, Ordering::Relaxed);
805        self.inner.export(batch).await
806    }
807
808    fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
809        self.inner.shutdown_with_timeout(timeout)
810    }
811
812    fn force_flush(&self) -> OTelSdkResult {
813        self.inner.force_flush()
814    }
815
816    fn set_resource(&mut self, resource: &Resource) {
817        self.inner.set_resource(resource);
818    }
819}
820
821#[derive(Debug)]
822struct DiagnosticBatchSpanProcessor {
823    inner: BatchSpanProcessor,
824    completed_spans: AtomicU64,
825    accepted_spans: Arc<AtomicU64>,
826    endpoint: String,
827    diagnostic_field: Option<String>,
828    diagnostic_reported: AtomicBool,
829}
830
831impl DiagnosticBatchSpanProcessor {
832    fn new<E: SpanExporter + 'static>(
833        exporter: E,
834        endpoint: String,
835        diagnostic_field: Option<String>,
836    ) -> Self {
837        Self::new_with_batch_config(
838            exporter,
839            endpoint,
840            diagnostic_field,
841            opentelemetry_sdk::trace::BatchConfig::default(),
842        )
843    }
844
845    fn new_with_batch_config<E: SpanExporter + 'static>(
846        exporter: E,
847        endpoint: String,
848        diagnostic_field: Option<String>,
849        batch_config: opentelemetry_sdk::trace::BatchConfig,
850    ) -> Self {
851        let accepted_spans = Arc::new(AtomicU64::new(0));
852        let exporter = CountingSpanExporter {
853            inner: exporter,
854            accepted_spans: Arc::clone(&accepted_spans),
855        };
856        Self {
857            inner: BatchSpanProcessor::builder(exporter)
858                .with_batch_config(batch_config)
859                .build(),
860            completed_spans: AtomicU64::new(0),
861            accepted_spans,
862            endpoint,
863            diagnostic_field,
864            diagnostic_reported: AtomicBool::new(false),
865        }
866    }
867
868    fn record_dropped_spans(&self) -> u64 {
869        let dropped = self
870            .completed_spans
871            .load(Ordering::Relaxed)
872            .saturating_sub(self.accepted_spans.load(Ordering::Relaxed));
873        if dropped == 0
874            || self.diagnostic_field.is_none()
875            || self.diagnostic_reported.swap(true, Ordering::Relaxed)
876        {
877            return dropped;
878        }
879        record_active_plugin_runtime_diagnostic(RuntimeDiagnostic {
880            code: "otel.spans_dropped".to_string(),
881            component: "observability".to_string(),
882            field: self.diagnostic_field.clone(),
883            message: format!(
884                "OpenTelemetry dropped {dropped} spans before export to endpoint {} because the batch queue was full",
885                self.endpoint
886            ),
887            session_id: None,
888            count: dropped,
889        });
890        dropped
891    }
892}
893
894impl SpanProcessor for DiagnosticBatchSpanProcessor {
895    fn on_start(&self, span: &mut Span, cx: &Context) {
896        self.inner.on_start(span, cx);
897    }
898
899    fn on_end(&self, span: SpanData) {
900        self.completed_spans.fetch_add(1, Ordering::Relaxed);
901        self.inner.on_end(span);
902    }
903
904    fn force_flush(&self) -> OTelSdkResult {
905        self.inner.force_flush()
906    }
907
908    fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
909        let result = self.inner.shutdown_with_timeout(timeout);
910        if result.is_ok() {
911            let dropped = self.record_dropped_spans();
912            if dropped > 0 && self.diagnostic_field.is_some() {
913                return Err(OTelSdkError::InternalFailure(format!(
914                    "{OTEL_RUNTIME_DELIVERY_FAILURE_MARKER}: otel.spans_dropped ({dropped})"
915                )));
916            }
917        }
918        result
919    }
920
921    fn set_resource(&mut self, resource: &Resource) {
922        self.inner.set_resource(resource);
923    }
924}
925
926fn build_grpc_metadata(headers: &HashMap<String, String>) -> Result<MetadataMap> {
927    let mut metadata = MetadataMap::new();
928    for (key, value) in headers {
929        let metadata_key = MetadataKey::from_bytes(key.as_bytes()).map_err(|e| {
930            OpenTelemetryError::InvalidGrpcHeader {
931                key: key.clone(),
932                message: e.to_string(),
933            }
934        })?;
935        let metadata_value = MetadataValue::try_from(value.as_str()).map_err(|e| {
936            OpenTelemetryError::InvalidGrpcHeader {
937                key: key.clone(),
938                message: e.to_string(),
939            }
940        })?;
941        metadata.insert(metadata_key, metadata_value);
942    }
943    Ok(metadata)
944}
945
946pub(super) struct ActiveSpan {
947    span: Span,
948    span_context: SpanContext,
949    start_model_name: Option<String>,
950    projected_attributes: Vec<KeyValue>,
951    descendant_error_type: Option<String>,
952    descendant_exception_type: Option<String>,
953}
954
955pub(super) struct OtelEventProcessor {
956    pub(super) active_spans: HashMap<Uuid, ActiveSpan>,
957    pub(super) completed_span_contexts: HashMap<Uuid, SpanContext>,
958    pub(super) completed_span_order: VecDeque<Uuid>,
959    suppressed_parent_contexts: HashMap<Uuid, SpanContext>,
960    suppressed_parent_order: VecDeque<Uuid>,
961    provider: SdkTracerProvider,
962    tracer: SdkTracer,
963    otel_type: OpenTelemetryType,
964    mark_projection: MarkProjection,
965    mark_exclude_names: Vec<String>,
966    attribute_mappings: Vec<OtlpAttributeMapping>,
967}
968
969impl OtelEventProcessor {
970    #[cfg(test)]
971    fn new(provider: SdkTracerProvider, instrumentation_scope: String) -> Self {
972        Self::new_with_mark_projection(provider, instrumentation_scope, MarkProjection::default())
973    }
974
975    #[cfg(test)]
976    pub(super) fn new_openinference(
977        provider: SdkTracerProvider,
978        instrumentation_scope: String,
979    ) -> Self {
980        Self::new_with_mark_projection_and_exclusions_and_mappings(
981            provider,
982            instrumentation_scope,
983            OpenTelemetryType::OpenInference,
984            MarkProjection::default(),
985            default_mark_exclude_names(),
986            Vec::new(),
987        )
988    }
989
990    #[cfg(test)]
991    pub(super) fn new_openinference_with_mark_projection(
992        provider: SdkTracerProvider,
993        instrumentation_scope: String,
994        mark_projection: MarkProjection,
995    ) -> Self {
996        Self::new_with_mark_projection_and_exclusions_and_mappings(
997            provider,
998            instrumentation_scope,
999            OpenTelemetryType::OpenInference,
1000            mark_projection,
1001            default_mark_exclude_names(),
1002            Vec::new(),
1003        )
1004    }
1005
1006    #[cfg(test)]
1007    pub(super) fn new_openinference_with_mark_projection_and_exclusions(
1008        provider: SdkTracerProvider,
1009        instrumentation_scope: String,
1010        mark_projection: MarkProjection,
1011        mark_exclude_names: Vec<String>,
1012    ) -> Self {
1013        Self::new_with_mark_projection_and_exclusions_and_mappings(
1014            provider,
1015            instrumentation_scope,
1016            OpenTelemetryType::OpenInference,
1017            mark_projection,
1018            mark_exclude_names,
1019            Vec::new(),
1020        )
1021    }
1022
1023    #[cfg(test)]
1024    fn new_with_mark_projection(
1025        provider: SdkTracerProvider,
1026        instrumentation_scope: String,
1027        mark_projection: MarkProjection,
1028    ) -> Self {
1029        Self::new_with_mark_projection_and_exclusions(
1030            provider,
1031            instrumentation_scope,
1032            mark_projection,
1033            default_mark_exclude_names(),
1034        )
1035    }
1036
1037    #[cfg(test)]
1038    fn new_with_mark_projection_and_exclusions(
1039        provider: SdkTracerProvider,
1040        instrumentation_scope: String,
1041        mark_projection: MarkProjection,
1042        mark_exclude_names: Vec<String>,
1043    ) -> Self {
1044        Self::new_with_mark_projection_and_exclusions_and_mappings(
1045            provider,
1046            instrumentation_scope,
1047            OpenTelemetryType::Full,
1048            mark_projection,
1049            mark_exclude_names,
1050            Vec::new(),
1051        )
1052    }
1053
1054    fn new_with_mark_projection_and_exclusions_and_mappings(
1055        provider: SdkTracerProvider,
1056        instrumentation_scope: String,
1057        otel_type: OpenTelemetryType,
1058        mark_projection: MarkProjection,
1059        mark_exclude_names: Vec<String>,
1060        attribute_mappings: Vec<OtlpAttributeMapping>,
1061    ) -> Self {
1062        let tracer = provider.tracer(instrumentation_scope);
1063        Self {
1064            active_spans: HashMap::new(),
1065            completed_span_contexts: HashMap::new(),
1066            completed_span_order: VecDeque::new(),
1067            suppressed_parent_contexts: HashMap::new(),
1068            suppressed_parent_order: VecDeque::new(),
1069            provider,
1070            tracer,
1071            otel_type,
1072            mark_projection,
1073            mark_exclude_names,
1074            attribute_mappings,
1075        }
1076    }
1077
1078    pub(super) fn process(&mut self, event: &Event) {
1079        match event.scope_category() {
1080            Some(ScopeCategory::Start) => self.process_start(event),
1081            Some(ScopeCategory::End) => self.process_end(event),
1082            None => self.process_mark(event),
1083        }
1084    }
1085
1086    pub(super) fn force_flush(&self) -> Result<()> {
1087        self.provider
1088            .force_flush()
1089            .map_err(|e| OpenTelemetryError::Provider(e.to_string()))
1090    }
1091
1092    fn shutdown(&self) -> Result<()> {
1093        self.provider
1094            .shutdown()
1095            .map_err(|e| OpenTelemetryError::Provider(e.to_string()))
1096    }
1097
1098    fn process_start(&mut self, event: &Event) {
1099        self.remove_completed_span_context(event.uuid());
1100        self.remove_suppressed_parent_context(event.uuid());
1101        let parent_context = self.parent_context(event);
1102        if self.otel_type == OpenTelemetryType::GenAi && !super::otel_genai::supports(event) {
1103            let parent_span_context = parent_context.span().span_context().clone();
1104            if parent_span_context.is_valid() {
1105                self.record_suppressed_parent_context(event.uuid(), parent_span_context);
1106            }
1107            return;
1108        }
1109        let is_trace_root = !parent_context.span().span_context().is_valid();
1110        let start_model_name = model_name_for_llm_event(event);
1111        let span_name = match self.otel_type {
1112            OpenTelemetryType::Full => span_name(event),
1113            OpenTelemetryType::GenAi => super::otel_genai::span_name(event),
1114            OpenTelemetryType::OpenInference => super::openinference::span_name(event),
1115        };
1116        let span_kind = match self.otel_type {
1117            OpenTelemetryType::Full => span_kind(event),
1118            OpenTelemetryType::GenAi => super::otel_genai::span_kind(event),
1119            OpenTelemetryType::OpenInference => super::openinference::span_kind(event),
1120        };
1121        let mut span = with_relay_ids(event.uuid(), || {
1122            self.tracer
1123                .span_builder(span_name)
1124                .with_kind(span_kind)
1125                .with_start_time(to_system_time(*event.timestamp()))
1126                .start_with_context(&self.tracer, &parent_context)
1127        });
1128        let mut attributes = match self.otel_type {
1129            OpenTelemetryType::Full => start_attributes(event),
1130            OpenTelemetryType::GenAi => super::otel_genai::start_attributes(event),
1131            OpenTelemetryType::OpenInference => super::openinference::start_attributes(event),
1132        };
1133        if self.otel_type == OpenTelemetryType::Full && start_model_name.is_some() {
1134            attributes.retain(|attribute| attribute.key.as_str() != "nemo_relay.model_name");
1135        }
1136        if self.otel_type == OpenTelemetryType::OpenInference && start_model_name.is_some() {
1137            super::openinference::remove_start_model_name(&mut attributes);
1138        }
1139        if self.otel_type != OpenTelemetryType::GenAi && is_trace_root {
1140            push_session_identity_attributes(&mut attributes, event);
1141        }
1142        let projected_attributes = if self.otel_type == OpenTelemetryType::GenAi {
1143            Vec::new()
1144        } else {
1145            attribute_mapping_inputs(&attributes, &self.attribute_mappings)
1146        };
1147        span.set_attributes(attributes);
1148        let span_context = local_parent_span_context(span.span_context());
1149        self.active_spans.insert(
1150            event.uuid(),
1151            ActiveSpan {
1152                span,
1153                span_context,
1154                start_model_name,
1155                projected_attributes,
1156                descendant_error_type: None,
1157                descendant_exception_type: None,
1158            },
1159        );
1160    }
1161
1162    fn process_end(&mut self, event: &Event) {
1163        let Some(mut active_span) = self.active_spans.remove(&event.uuid()) else {
1164            self.propagate_suppressed_error_metadata(event);
1165            return;
1166        };
1167        self.record_completed_span_context(event.uuid(), active_span.span_context.clone());
1168
1169        super::set_span_status_from_event_metadata(&mut active_span.span, event);
1170        let mut attributes = match self.otel_type {
1171            OpenTelemetryType::Full => end_attributes(event),
1172            OpenTelemetryType::GenAi => super::otel_genai::end_attributes(event),
1173            OpenTelemetryType::OpenInference => super::openinference::end_attributes(event),
1174        };
1175        let is_error = metadata_string(event, "otel.status_code") == Some("ERROR");
1176        let explicit_error_type = metadata_string(event, "error.type");
1177        let error_type = is_error.then(|| {
1178            explicit_error_type
1179                .map(ToOwned::to_owned)
1180                .or(active_span.descendant_error_type.take())
1181                .unwrap_or_else(|| "_OTHER".to_string())
1182        });
1183        let exception_type = is_error
1184            .then(|| {
1185                metadata_string(event, "exception.type")
1186                    .map(ToOwned::to_owned)
1187                    .or(active_span.descendant_exception_type.take())
1188            })
1189            .flatten();
1190        if matches!(
1191            self.otel_type,
1192            OpenTelemetryType::Full | OpenTelemetryType::GenAi
1193        ) && let Some(error_type) = error_type.as_ref()
1194        {
1195            attributes.retain(|attribute| attribute.key.as_str() != "error.type");
1196            attributes.push(KeyValue::new("error.type", error_type.clone()));
1197        }
1198        if let Some(exception_type) = exception_type.as_ref() {
1199            active_span.span.add_event_with_timestamp(
1200                "exception",
1201                to_system_time(*event.timestamp()),
1202                vec![KeyValue::new("exception.type", exception_type.clone())],
1203            );
1204        }
1205        let end_model_name =
1206            model_name_for_llm_event(event).or_else(|| active_span.start_model_name.take());
1207        if self.otel_type == OpenTelemetryType::Full
1208            && let Some(model_name) = end_model_name.clone()
1209        {
1210            attributes.push(KeyValue::new("nemo_relay.model_name", model_name));
1211        }
1212        if self.otel_type == OpenTelemetryType::OpenInference
1213            && let Some(model_name) = end_model_name
1214        {
1215            super::openinference::push_model_name(&mut attributes, model_name);
1216        }
1217        if self.otel_type != OpenTelemetryType::GenAi && !self.attribute_mappings.is_empty() {
1218            let mut projected_attributes = active_span.projected_attributes;
1219            projected_attributes.extend(attributes.iter().cloned());
1220            attributes.extend(attribute_mapping_aliases(
1221                &projected_attributes,
1222                &self.attribute_mappings,
1223            ));
1224        }
1225        if is_error && let Some(parent_span) = self.find_parent_span_mut(event) {
1226            if parent_span.descendant_error_type.is_none() {
1227                parent_span.descendant_error_type = error_type;
1228            }
1229            if parent_span.descendant_exception_type.is_none() {
1230                parent_span.descendant_exception_type = exception_type;
1231            }
1232        }
1233        active_span.span.set_attributes(attributes);
1234        active_span
1235            .span
1236            .end_with_timestamp(to_system_time(*event.timestamp()));
1237    }
1238
1239    fn propagate_suppressed_error_metadata(&mut self, event: &Event) {
1240        if self.otel_type != OpenTelemetryType::GenAi
1241            || !self.suppressed_parent_contexts.contains_key(&event.uuid())
1242            || metadata_string(event, "otel.status_code") != Some("ERROR")
1243        {
1244            return;
1245        }
1246        let error_type = metadata_string(event, "error.type").map(ToOwned::to_owned);
1247        let exception_type = metadata_string(event, "exception.type").map(ToOwned::to_owned);
1248        let Some(parent_span) = self.find_parent_span_mut(event) else {
1249            return;
1250        };
1251        if parent_span.descendant_error_type.is_none() {
1252            parent_span.descendant_error_type = error_type;
1253        }
1254        if parent_span.descendant_exception_type.is_none() {
1255            parent_span.descendant_exception_type = exception_type;
1256        }
1257    }
1258
1259    fn process_mark(&mut self, event: &Event) {
1260        if self.otel_type == OpenTelemetryType::GenAi {
1261            return;
1262        }
1263        if effective_mark_projection(event, self.mark_projection, &self.mark_exclude_names)
1264            == MarkProjection::Tool
1265        {
1266            self.process_mark_as_tool(event);
1267            return;
1268        }
1269        let mark_name = event.name().to_string();
1270        let timestamp = to_system_time(*event.timestamp());
1271        let mut attributes = self.mark_attributes(event);
1272        if event.name() == "session.start" {
1273            push_session_identity_attributes(&mut attributes, event);
1274        }
1275
1276        if self.find_parent_span(event).is_some() {
1277            apply_attribute_mappings(&mut attributes, &self.attribute_mappings);
1278            let parent_span = self
1279                .find_parent_span_mut(event)
1280                .expect("parent span was present during mark projection");
1281            parent_span
1282                .span
1283                .add_event_with_timestamp(mark_name, timestamp, attributes);
1284            return;
1285        }
1286
1287        let mut span = with_relay_ids(event.uuid(), || {
1288            self.tracer
1289                .span_builder(format!("mark:{mark_name}"))
1290                .with_kind(SpanKind::Internal)
1291                .with_start_time(timestamp)
1292                .start_with_context(&self.tracer, &self.parent_context(event))
1293        });
1294        if self.otel_type == OpenTelemetryType::OpenInference {
1295            super::openinference::push_orphan_mark_attributes(&mut attributes);
1296        } else {
1297            attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
1298        }
1299        apply_attribute_mappings(&mut attributes, &self.attribute_mappings);
1300        span.set_attributes(attributes);
1301        span.end_with_timestamp(timestamp);
1302    }
1303
1304    fn process_mark_as_tool(&mut self, event: &Event) {
1305        let timestamp = to_system_time(*event.timestamp());
1306        let orphan = self.find_parent_span(event).is_none();
1307        let mut attributes = self.mark_attributes(event);
1308        if event.name() == "session.start" {
1309            push_session_identity_attributes(&mut attributes, event);
1310        }
1311        attributes.push(KeyValue::new("nemo_relay.mark.projection", "tool"));
1312        if self.otel_type == OpenTelemetryType::OpenInference {
1313            super::openinference::push_tool_mark_attributes(&mut attributes, event);
1314        } else {
1315            attributes.push(KeyValue::new("nemo_relay.scope_type", "tool"));
1316        }
1317        if orphan {
1318            attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
1319        }
1320        apply_attribute_mappings(&mut attributes, &self.attribute_mappings);
1321
1322        let mut span = with_relay_ids(event.uuid(), || {
1323            self.tracer
1324                .span_builder(format!("mark:{}", event.name()))
1325                .with_kind(SpanKind::Internal)
1326                .with_start_time(timestamp)
1327                .start_with_context(&self.tracer, &self.parent_context(event))
1328        });
1329        span.set_attributes(attributes);
1330        span.end_with_timestamp(timestamp);
1331    }
1332
1333    fn mark_attributes(&self, event: &Event) -> Vec<KeyValue> {
1334        match self.otel_type {
1335            OpenTelemetryType::Full => mark_attributes(event),
1336            OpenTelemetryType::OpenInference => super::openinference::mark_attributes(event),
1337            OpenTelemetryType::GenAi => Vec::new(),
1338        }
1339    }
1340
1341    fn parent_context(&self, event: &Event) -> Context {
1342        if let Some(active_span) = self.find_parent_span(event) {
1343            return Context::new().with_remote_span_context(active_span.span_context.clone());
1344        }
1345        if let Some(span_context) = event
1346            .parent_uuid()
1347            .and_then(|uuid| self.completed_span_contexts.get(&uuid))
1348        {
1349            return Context::new().with_remote_span_context(span_context.clone());
1350        }
1351        if let Some(span_context) = event
1352            .parent_uuid()
1353            .and_then(|uuid| self.suppressed_parent_contexts.get(&uuid))
1354        {
1355            return Context::new().with_remote_span_context(span_context.clone());
1356        }
1357        let Some(parent_uuid) = event.parent_uuid() else {
1358            return Context::new();
1359        };
1360        let stack = current_scope_stack();
1361        let stack = stack.read().expect("scope stack lock poisoned");
1362        if !stack.is_propagated_parent(parent_uuid) {
1363            return Context::new();
1364        }
1365        let root_uuid = stack.root_uuid();
1366        Context::new().with_remote_span_context(SpanContext::new(
1367            relay_trace_id(root_uuid),
1368            relay_span_id(parent_uuid),
1369            TraceFlags::SAMPLED,
1370            true,
1371            TraceState::default(),
1372        ))
1373    }
1374
1375    fn parent_span_uuid(&self, event: &Event) -> Option<Uuid> {
1376        let parent_uuid = event.parent_uuid()?;
1377        if self.active_spans.contains_key(&parent_uuid) {
1378            return Some(parent_uuid);
1379        }
1380        let suppressed_parent = self.suppressed_parent_contexts.get(&parent_uuid)?;
1381        self.active_spans.iter().find_map(|(uuid, active_span)| {
1382            (active_span.span_context.trace_id() == suppressed_parent.trace_id()
1383                && active_span.span_context.span_id() == suppressed_parent.span_id())
1384            .then_some(*uuid)
1385        })
1386    }
1387
1388    fn find_parent_span(&self, event: &Event) -> Option<&ActiveSpan> {
1389        self.parent_span_uuid(event)
1390            .and_then(|uuid| self.active_spans.get(&uuid))
1391    }
1392
1393    fn find_parent_span_mut(&mut self, event: &Event) -> Option<&mut ActiveSpan> {
1394        self.parent_span_uuid(event)
1395            .and_then(|uuid| self.active_spans.get_mut(&uuid))
1396    }
1397
1398    fn remove_completed_span_context(&mut self, uuid: Uuid) {
1399        self.completed_span_contexts.remove(&uuid);
1400        self.completed_span_order
1401            .retain(|completed_uuid| *completed_uuid != uuid);
1402    }
1403
1404    fn remove_suppressed_parent_context(&mut self, uuid: Uuid) {
1405        self.suppressed_parent_contexts.remove(&uuid);
1406        self.suppressed_parent_order
1407            .retain(|suppressed_uuid| *suppressed_uuid != uuid);
1408    }
1409
1410    fn record_completed_span_context(&mut self, uuid: Uuid, span_context: SpanContext) {
1411        if self
1412            .completed_span_contexts
1413            .insert(uuid, span_context)
1414            .is_none()
1415        {
1416            self.completed_span_order.push_back(uuid);
1417        }
1418        while self.completed_span_order.len() > COMPLETED_SPAN_CONTEXT_LIMIT {
1419            if let Some(expired) = self.completed_span_order.pop_front() {
1420                self.completed_span_contexts.remove(&expired);
1421            }
1422        }
1423    }
1424
1425    fn record_suppressed_parent_context(&mut self, uuid: Uuid, span_context: SpanContext) {
1426        if self
1427            .suppressed_parent_contexts
1428            .insert(uuid, span_context)
1429            .is_none()
1430        {
1431            self.suppressed_parent_order.push_back(uuid);
1432        }
1433        while self.suppressed_parent_order.len() > COMPLETED_SPAN_CONTEXT_LIMIT {
1434            if let Some(expired) = self.suppressed_parent_order.pop_front() {
1435                self.suppressed_parent_contexts.remove(&expired);
1436            }
1437        }
1438    }
1439}
1440
1441fn metadata_string<'a>(event: &'a Event, key: &str) -> Option<&'a str> {
1442    event.metadata()?.get(key)?.as_str()
1443}
1444
1445fn span_kind(event: &Event) -> SpanKind {
1446    match semantic_scope_type(event) {
1447        Some(ScopeType::Llm) => SpanKind::Client,
1448        Some(
1449            ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
1450        ) => SpanKind::Client,
1451        _ => SpanKind::Internal,
1452    }
1453}
1454
1455fn span_name(event: &Event) -> String {
1456    event.name().to_string()
1457}
1458
1459fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
1460    event.scope_type()
1461}
1462
1463fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
1464    match scope_type {
1465        Some(ScopeType::Agent) => "agent",
1466        Some(ScopeType::Function) => "function",
1467        Some(ScopeType::Tool) => "tool",
1468        Some(ScopeType::Llm) => "llm",
1469        Some(ScopeType::Retriever) => "retriever",
1470        Some(ScopeType::Embedder) => "embedder",
1471        Some(ScopeType::Reranker) => "reranker",
1472        Some(ScopeType::Guardrail) => "guardrail",
1473        Some(ScopeType::Evaluator) => "evaluator",
1474        Some(ScopeType::Custom) => "custom",
1475        Some(ScopeType::Unknown) | None => "unknown",
1476    }
1477}
1478
1479fn start_attributes(event: &Event) -> Vec<KeyValue> {
1480    let mut attributes = common_attributes(event);
1481    push_serialized_top_level_attributes(
1482        &mut attributes,
1483        "nemo_relay.handle_attributes",
1484        event.attributes(),
1485    );
1486    push_top_level_json_attributes(&mut attributes, "nemo_relay.start.data", event.data());
1487    push_top_level_json_attributes(
1488        &mut attributes,
1489        "nemo_relay.start.metadata",
1490        event.metadata(),
1491    );
1492    push_top_level_json_attributes(&mut attributes, "nemo_relay.start.input", event.input());
1493    attributes
1494}
1495
1496fn end_attributes(event: &Event) -> Vec<KeyValue> {
1497    let mut attributes = Vec::new();
1498    push_top_level_json_attributes(&mut attributes, "nemo_relay.end.data", event.data());
1499    push_top_level_json_attributes(&mut attributes, "nemo_relay.end.metadata", event.metadata());
1500    push_top_level_json_attributes(&mut attributes, "nemo_relay.end.output", event.output());
1501    if event
1502        .category()
1503        .is_some_and(|category| category.as_str() == "llm")
1504        && let Some((cost, currency)) = cost_from_llm_event(event)
1505    {
1506        attributes.push(KeyValue::new("nemo_relay.llm.cost.total", cost));
1507        attributes.push(KeyValue::new("nemo_relay.llm.cost.currency", currency));
1508    }
1509    if let Some(response) = event.annotated_response()
1510        && let Some(summary) = response.optimization_summary.as_ref()
1511    {
1512        push_optimization_attributes(&mut attributes, summary);
1513    }
1514    attributes
1515}
1516
1517fn push_optimization_attributes(
1518    attributes: &mut Vec<KeyValue>,
1519    summary: &crate::codec::optimization::LlmOptimizationSummary,
1520) {
1521    crate::observability::push_common_optimization_attributes(attributes, summary);
1522}
1523
1524fn cost_from_llm_event(event: &Event) -> Option<(f64, String)> {
1525    if let Some(response) = event.normalized_llm_response() {
1526        let response = response.as_ref();
1527        if let Some(usage) = response.usage.as_ref() {
1528            if let Some(cost) = usage.cost.as_ref() {
1529                return cost_total_and_currency(cost);
1530            }
1531            if let Some(cost) = estimate_cost_for_response_or_requested_model(
1532                event,
1533                response.model.as_deref(),
1534                usage,
1535            ) {
1536                return cost_total_and_currency(&cost);
1537            }
1538        }
1539    }
1540    if let Some(cost) =
1541        manual::cost_from_manual_llm_output(event.output(), manual::ManualCostPolicy::AnyCurrency)
1542    {
1543        return Some(cost);
1544    }
1545    let usage = manual::usage_from_manual_llm_output(event.output())?;
1546    estimate_cost_for_response_or_model(
1547        Some(event.name()),
1548        event.model_name(),
1549        manual::model_name_from_manual_llm_output(event.output()),
1550        &usage,
1551    )
1552    .and_then(|cost| cost_total_and_currency(&cost))
1553}
1554
1555fn cost_total_and_currency(cost: &CostEstimate) -> Option<(f64, String)> {
1556    Some((cost.total_or_component_sum()?, cost.currency.clone()))
1557}
1558
1559fn mark_attributes(event: &Event) -> Vec<KeyValue> {
1560    let mut attributes = vec![
1561        KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
1562        KeyValue::new(
1563            "nemo_relay.mark.parent_uuid",
1564            event
1565                .parent_uuid()
1566                .map(|uuid| uuid.to_string())
1567                .unwrap_or_default(),
1568        ),
1569    ];
1570    push_serialized_top_level_attributes(
1571        &mut attributes,
1572        "nemo_relay.mark.attributes",
1573        event.attributes(),
1574    );
1575    push_top_level_json_attributes(&mut attributes, "nemo_relay.mark.data", event.data());
1576    push_top_level_json_attributes(
1577        &mut attributes,
1578        "nemo_relay.mark.metadata",
1579        event.metadata(),
1580    );
1581    if let Some(category) = event.category() {
1582        attributes.push(KeyValue::new(
1583            "nemo_relay.mark.category",
1584            category.as_str().to_string(),
1585        ));
1586    }
1587    push_serialized_top_level_attributes(
1588        &mut attributes,
1589        "nemo_relay.mark.category_profile",
1590        event.category_profile(),
1591    );
1592    attributes
1593}
1594
1595fn common_attributes(event: &Event) -> Vec<KeyValue> {
1596    let mut attributes = vec![
1597        KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
1598        KeyValue::new(
1599            "nemo_relay.parent_uuid",
1600            event
1601                .parent_uuid()
1602                .map(|uuid| uuid.to_string())
1603                .unwrap_or_default(),
1604        ),
1605        KeyValue::new(
1606            "nemo_relay.scope_type",
1607            scope_type_name(semantic_scope_type(event)),
1608        ),
1609    ];
1610
1611    if let Some(model_name) = model_name_for_llm_event(event) {
1612        attributes.push(KeyValue::new("nemo_relay.model_name", model_name));
1613    }
1614    if let Some(tool_call_id) = event.tool_call_id() {
1615        attributes.push(KeyValue::new(
1616            "nemo_relay.tool_call_id",
1617            tool_call_id.to_string(),
1618        ));
1619    }
1620
1621    attributes
1622}
1623
1624fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
1625    SpanContext::new(
1626        span_context.trace_id(),
1627        span_context.span_id(),
1628        span_context.trace_flags(),
1629        false,
1630        span_context.trace_state().clone(),
1631    )
1632}
1633
1634fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
1635    let seconds = timestamp.timestamp();
1636    let nanos = timestamp.timestamp_subsec_nanos();
1637    if seconds >= 0 {
1638        UNIX_EPOCH + Duration::new(seconds as u64, nanos)
1639    } else if nanos == 0 {
1640        UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
1641    } else {
1642        UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
1643    }
1644}
1645
1646#[cfg(test)]
1647#[path = "../../tests/unit/observability/otel_tests.rs"]
1648mod tests;