1use 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
109pub type Result<T> = std::result::Result<T, OpenTelemetryError>;
111
112#[derive(Debug, thiserror::Error)]
114pub enum OpenTelemetryError {
115 #[error("invalid OTLP gRPC header {key:?}: {message}")]
117 InvalidGrpcHeader {
118 key: String,
120 message: String,
122 },
123 #[error("invalid OTLP header {key:?}: {message}")]
125 InvalidHeader {
126 key: String,
128 message: String,
130 },
131 #[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 variable: &'static str,
138 },
139 #[error("failed to build the OTLP exporter: {0}")]
141 ExporterBuild(String),
142 #[error("OpenTelemetry tracer provider error: {0}")]
144 Provider(String),
145 #[error("invalid attribute mappings: {0}")]
147 InvalidAttributeMappings(String),
148 #[error(transparent)]
150 Core(#[from] FlowError),
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
155pub enum OtlpTransport {
156 #[default]
158 HttpBinary,
159 Grpc,
161}
162
163#[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#[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 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 #[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 #[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 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
252 self.endpoint = endpoint.into();
253 self
254 }
255
256 pub fn with_transport(mut self, transport: OtlpTransport) -> Self {
258 self.transport = transport;
259 self
260 }
261
262 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 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 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 pub fn with_timeout(mut self, timeout: Duration) -> Self {
291 self.timeout = timeout;
292 self
293 }
294
295 pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
297 self.service_namespace = Some(namespace.into());
298 self
299 }
300
301 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
303 self.service_version = Some(version.into());
304 self
305 }
306
307 pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
309 self.instrumentation_scope = scope.into();
310 self
311 }
312
313 pub fn with_mark_projection(mut self, mark_projection: MarkProjection) -> Self {
315 self.mark_projection = mark_projection;
316 self
317 }
318
319 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 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 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#[derive(Clone)]
359pub struct OpenTelemetrySubscriber {
360 inner: Arc<Inner>,
361}
362
363#[derive(Debug, Clone)]
365pub struct OpenTelemetrySubscriberOptions {
366 pub mark_projection: MarkProjection,
368 pub mark_exclude_names: Vec<String>,
370 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 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 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 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 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 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 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 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 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 pub fn subscriber(&self) -> EventSubscriberFn {
579 Arc::clone(&self.inner.subscriber)
580 }
581
582 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 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 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 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 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;