1use std::collections::HashMap;
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::api::event::{Event, ScopeCategory};
24use crate::api::runtime::EventSubscriberFn;
25use crate::api::scope::ScopeType;
26use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
27use crate::codec::response::Usage;
28use crate::error::FlowError;
29use crate::json::Json;
30use chrono::{DateTime, Utc};
31use openinference_semantic_conventions::SpanKind as OpenInferenceSpanKind;
32use openinference_semantic_conventions::attributes as oi;
33use opentelemetry::trace::{
34 Span as _, SpanContext, SpanKind, TraceContextExt, Tracer, TracerProvider as _,
35};
36use opentelemetry::{Context, KeyValue};
37use opentelemetry_otlp::{Protocol, SpanExporter, WithExportConfig, WithHttpConfig};
38use opentelemetry_sdk::Resource;
39use opentelemetry_sdk::trace::{SdkTracer, SdkTracerProvider, Span};
40use serde::Serialize;
41use uuid::Uuid;
42
43#[cfg(target_arch = "wasm32")]
44use async_trait::async_trait;
45#[cfg(target_arch = "wasm32")]
46use opentelemetry_http::{
47 Bytes, HttpClient, HttpError, Request as HttpRequest, Response as HttpResponse,
48};
49#[cfg(not(target_arch = "wasm32"))]
50use opentelemetry_otlp::WithTonicConfig;
51#[cfg(not(target_arch = "wasm32"))]
52use tokio::runtime::Handle;
53#[cfg(not(target_arch = "wasm32"))]
54use tonic::metadata::{MetadataKey, MetadataMap, MetadataValue};
55#[cfg(target_arch = "wasm32")]
56use wasm_bindgen::{JsCast, JsValue};
57#[cfg(target_arch = "wasm32")]
58use wasm_bindgen_futures::{JsFuture, spawn_local};
59#[cfg(target_arch = "wasm32")]
60use web_sys::{Request as WebRequest, RequestInit};
61
62pub type Result<T> = std::result::Result<T, OpenInferenceError>;
64
65#[derive(Debug, thiserror::Error)]
67pub enum OpenInferenceError {
68 #[error("the OTLP gRPC exporter requires an active Tokio runtime")]
70 MissingTokioRuntime,
71 #[error("the OTLP {transport} transport is not supported on this target")]
73 UnsupportedTransport {
74 transport: &'static str,
76 },
77 #[error("invalid OTLP gRPC header {key:?}: {message}")]
79 InvalidGrpcHeader {
80 key: String,
82 message: String,
84 },
85 #[error("failed to build the OTLP exporter: {0}")]
87 ExporterBuild(String),
88 #[error("OpenInference tracer provider error: {0}")]
90 Provider(String),
91 #[error(transparent)]
93 Core(#[from] FlowError),
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
98pub enum OtlpTransport {
99 #[default]
101 HttpBinary,
102 Grpc,
104}
105
106#[derive(Debug, Clone)]
108pub struct OpenInferenceConfig {
109 endpoint: Option<String>,
110 headers: HashMap<String, String>,
111 resource_attributes: HashMap<String, String>,
112 service_name: String,
113 service_namespace: Option<String>,
114 service_version: Option<String>,
115 instrumentation_scope: String,
116 timeout: Duration,
117 transport: OtlpTransport,
118}
119
120impl Default for OpenInferenceConfig {
121 fn default() -> Self {
122 Self {
123 endpoint: None,
124 headers: HashMap::new(),
125 resource_attributes: HashMap::new(),
126 service_name: "nemo-relay".to_string(),
127 service_namespace: None,
128 service_version: None,
129 instrumentation_scope: "nemo-relay-openinference".to_string(),
130 timeout: Duration::from_secs(3),
131 transport: OtlpTransport::HttpBinary,
132 }
133 }
134}
135
136impl OpenInferenceConfig {
137 pub fn new() -> Self {
139 Self::default()
140 }
141
142 pub fn with_transport(mut self, transport: OtlpTransport) -> Self {
144 self.transport = transport;
145 self
146 }
147
148 pub fn with_service_name(mut self, service_name: impl Into<String>) -> Self {
150 self.service_name = service_name.into();
151 self
152 }
153
154 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
156 self.endpoint = Some(endpoint.into());
157 self
158 }
159
160 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
162 self.headers.insert(key.into(), value.into());
163 self
164 }
165
166 pub fn with_resource_attribute(
168 mut self,
169 key: impl Into<String>,
170 value: impl Into<String>,
171 ) -> Self {
172 self.resource_attributes.insert(key.into(), value.into());
173 self
174 }
175
176 pub fn with_timeout(mut self, timeout: Duration) -> Self {
178 self.timeout = timeout;
179 self
180 }
181
182 pub fn with_service_namespace(mut self, namespace: impl Into<String>) -> Self {
184 self.service_namespace = Some(namespace.into());
185 self
186 }
187
188 pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
190 self.service_version = Some(version.into());
191 self
192 }
193
194 pub fn with_instrumentation_scope(mut self, scope: impl Into<String>) -> Self {
196 self.instrumentation_scope = scope.into();
197 self
198 }
199}
200
201#[derive(Clone)]
203pub struct OpenInferenceSubscriber {
204 inner: Arc<Inner>,
205}
206
207struct Inner {
208 processor: Arc<Mutex<OpenInferenceEventProcessor>>,
209 subscriber: EventSubscriberFn,
210}
211
212impl OpenInferenceSubscriber {
213 pub fn new(config: OpenInferenceConfig) -> Result<Self> {
215 #[cfg(not(target_arch = "wasm32"))]
216 if config.transport == OtlpTransport::Grpc && tokio::runtime::Handle::try_current().is_err()
217 {
218 return Err(OpenInferenceError::MissingTokioRuntime);
219 }
220 #[cfg(target_arch = "wasm32")]
221 if config.transport == OtlpTransport::Grpc {
222 return Err(OpenInferenceError::UnsupportedTransport { transport: "gRPC" });
223 }
224
225 let provider = build_tracer_provider(&config)?;
226 Ok(Self::from_tracer_provider_with_scope(
227 provider,
228 config.instrumentation_scope,
229 ))
230 }
231
232 pub fn from_tracer_provider(
234 provider: SdkTracerProvider,
235 instrumentation_scope: impl Into<String>,
236 ) -> Self {
237 Self::from_tracer_provider_with_scope(provider, instrumentation_scope.into())
238 }
239
240 fn from_tracer_provider_with_scope(
241 provider: SdkTracerProvider,
242 instrumentation_scope: String,
243 ) -> Self {
244 let processor = Arc::new(Mutex::new(OpenInferenceEventProcessor::new(
245 provider,
246 instrumentation_scope,
247 )));
248 let processor_for_callback = Arc::clone(&processor);
249 let subscriber: EventSubscriberFn = Arc::new(move |event: &Event| {
250 let Ok(mut guard) = processor_for_callback.lock() else {
251 return;
254 };
255 guard.process(event);
256 });
257
258 Self {
259 inner: Arc::new(Inner {
260 processor,
261 subscriber,
262 }),
263 }
264 }
265
266 pub fn subscriber(&self) -> EventSubscriberFn {
268 Arc::clone(&self.inner.subscriber)
269 }
270
271 pub fn register(&self, name: &str) -> Result<()> {
273 register_subscriber(name, self.subscriber()).map_err(Into::into)
274 }
275
276 pub fn deregister(&self, name: &str) -> Result<bool> {
278 deregister_subscriber(name).map_err(Into::into)
279 }
280
281 pub fn force_flush(&self) -> Result<()> {
283 flush_subscribers()?;
284 let guard = self.inner.processor.lock().map_err(|_| {
285 OpenInferenceError::Provider("the subscriber state lock was poisoned".to_string())
286 })?;
287 guard.force_flush()
288 }
289
290 pub fn shutdown(&self) -> Result<()> {
294 flush_subscribers()?;
295 let guard = self.inner.processor.lock().map_err(|_| {
296 OpenInferenceError::Provider("the subscriber state lock was poisoned".to_string())
297 })?;
298 guard.shutdown()
299 }
300}
301
302#[cfg(target_arch = "wasm32")]
303#[derive(Debug, Clone, Copy, Default)]
304struct WasmHttpClient;
305
306#[cfg(target_arch = "wasm32")]
307#[async_trait]
308impl HttpClient for WasmHttpClient {
309 async fn send_bytes(
310 &self,
311 request: HttpRequest<Bytes>,
312 ) -> std::result::Result<HttpResponse<Bytes>, HttpError> {
313 let (parts, body) = request.into_parts();
314
315 let request = {
316 let request_url = parts.uri.to_string();
317 let init = RequestInit::new();
318 init.set_method(parts.method.as_str());
319 if !body.is_empty() {
320 let body_bytes = js_sys::Uint8Array::from(body.as_ref());
321 init.set_body_opt_u8_array(Some(&body_bytes));
322 }
323
324 let request =
325 WebRequest::new_with_str_and_init(&request_url, &init).map_err(js_error)?;
326 let request_headers = request.headers();
327 for (name, value) in &parts.headers {
328 let value = value
329 .to_str()
330 .map_err(|e| http_error(format!("invalid OTLP HTTP header {name}: {e}")))?;
331 request_headers
332 .set(name.as_str(), value)
333 .map_err(js_error)?;
334 }
335 request
336 };
337
338 let fetch_promise = if let Some(window) = web_sys::window() {
339 window.fetch_with_request(&request)
340 } else {
341 let global = js_sys::global();
342 let fetch = js_sys::Reflect::get(&global, &JsValue::from_str("fetch"))
343 .map_err(js_error)?
344 .dyn_into::<js_sys::Function>()
345 .map_err(js_error)?;
346 fetch.call1(&global, &request).map_err(js_error)?.into()
347 };
348 spawn_local(async move {
351 if let Err(error) = JsFuture::from(fetch_promise).await {
352 web_sys::console::warn_1(&JsValue::from_str(&format!(
353 "OpenInference OTLP/HTTP export failed: {error:?}"
354 )));
355 }
356 });
357
358 HttpResponse::builder()
359 .status(202)
360 .body(Bytes::new())
361 .map_err(|e| http_error(e.to_string()))
362 }
363}
364
365#[cfg(target_arch = "wasm32")]
366fn js_error(value: JsValue) -> HttpError {
367 http_error(
368 value
369 .as_string()
370 .unwrap_or_else(|| format!("JavaScript error: {value:?}")),
371 )
372}
373
374#[cfg(target_arch = "wasm32")]
375fn http_error(message: impl Into<String>) -> HttpError {
376 Box::new(std::io::Error::other(message.into()))
377}
378
379fn build_tracer_provider(config: &OpenInferenceConfig) -> Result<SdkTracerProvider> {
380 let exporter = match config.transport {
381 OtlpTransport::HttpBinary => {
382 #[cfg(not(target_arch = "wasm32"))]
383 install_rustls_crypto_provider();
384 let mut builder = SpanExporter::builder()
385 .with_http()
386 .with_protocol(Protocol::HttpBinary)
387 .with_timeout(config.timeout);
388 if let Some(endpoint) = &config.endpoint {
389 builder = builder.with_endpoint(endpoint.clone());
390 }
391 if !config.headers.is_empty() {
392 builder = builder.with_headers(config.headers.clone());
393 }
394 #[cfg(target_arch = "wasm32")]
395 {
396 builder = builder.with_http_client(WasmHttpClient);
397 }
398 builder
399 .build()
400 .map_err(|e| OpenInferenceError::ExporterBuild(e.to_string()))?
401 }
402 #[cfg(not(target_arch = "wasm32"))]
403 OtlpTransport::Grpc => {
404 let mut builder = SpanExporter::builder()
405 .with_tonic()
406 .with_protocol(Protocol::Grpc)
407 .with_timeout(config.timeout);
408 if let Some(endpoint) = &config.endpoint {
409 builder = builder.with_endpoint(endpoint.clone());
410 }
411 if !config.headers.is_empty() {
412 builder = builder.with_metadata(build_grpc_metadata(&config.headers)?);
413 }
414 builder
415 .build()
416 .map_err(|e| OpenInferenceError::ExporterBuild(e.to_string()))?
417 }
418 #[cfg(target_arch = "wasm32")]
419 OtlpTransport::Grpc => {
420 return Err(OpenInferenceError::UnsupportedTransport { transport: "gRPC" });
421 }
422 };
423
424 let mut resource_attributes = vec![KeyValue::new("service.name", config.service_name.clone())];
425 if let Some(service_namespace) = &config.service_namespace {
426 resource_attributes.push(KeyValue::new(
427 "service.namespace",
428 service_namespace.clone(),
429 ));
430 }
431 if let Some(service_version) = &config.service_version {
432 resource_attributes.push(KeyValue::new("service.version", service_version.clone()));
433 }
434 for (key, value) in &config.resource_attributes {
435 resource_attributes.push(KeyValue::new(key.clone(), value.clone()));
436 }
437
438 let builder = SdkTracerProvider::builder()
443 .with_resource(
444 Resource::builder_empty()
445 .with_attributes(resource_attributes)
446 .build(),
447 )
448 .with_max_attributes_per_span(u32::MAX)
449 .with_max_attributes_per_event(u32::MAX);
450
451 #[cfg(not(target_arch = "wasm32"))]
452 {
453 if Handle::try_current().is_ok() {
454 Ok(builder.with_batch_exporter(exporter).build())
455 } else {
456 Ok(builder.with_simple_exporter(exporter).build())
457 }
458 }
459 #[cfg(target_arch = "wasm32")]
460 {
461 Ok(builder.with_simple_exporter(exporter).build())
462 }
463}
464
465#[cfg(not(target_arch = "wasm32"))]
466fn install_rustls_crypto_provider() {
467 let _ = rustls::crypto::ring::default_provider().install_default();
468}
469
470#[cfg(not(target_arch = "wasm32"))]
471fn build_grpc_metadata(headers: &HashMap<String, String>) -> Result<MetadataMap> {
472 let mut metadata = MetadataMap::new();
473 for (key, value) in headers {
474 let metadata_key = MetadataKey::from_bytes(key.as_bytes()).map_err(|e| {
475 OpenInferenceError::InvalidGrpcHeader {
476 key: key.clone(),
477 message: e.to_string(),
478 }
479 })?;
480 let metadata_value = MetadataValue::try_from(value.as_str()).map_err(|e| {
481 OpenInferenceError::InvalidGrpcHeader {
482 key: key.clone(),
483 message: e.to_string(),
484 }
485 })?;
486 metadata.insert(metadata_key, metadata_value);
487 }
488 Ok(metadata)
489}
490
491struct ActiveSpan {
492 span: Span,
493 span_context: SpanContext,
494}
495
496struct OpenInferenceEventProcessor {
497 active_spans: HashMap<Uuid, ActiveSpan>,
498 provider: SdkTracerProvider,
499 tracer: SdkTracer,
500}
501
502impl OpenInferenceEventProcessor {
503 fn new(provider: SdkTracerProvider, instrumentation_scope: String) -> Self {
504 let tracer = provider.tracer(instrumentation_scope);
505 Self {
506 active_spans: HashMap::new(),
507 provider,
508 tracer,
509 }
510 }
511
512 fn process(&mut self, event: &Event) {
513 match event.scope_category() {
514 Some(ScopeCategory::Start) => self.process_start(event),
515 Some(ScopeCategory::End) => self.process_end(event),
516 None => self.process_mark(event),
517 }
518 }
519
520 fn force_flush(&self) -> Result<()> {
521 self.provider
522 .force_flush()
523 .map_err(|e| OpenInferenceError::Provider(e.to_string()))
524 }
525
526 fn shutdown(&self) -> Result<()> {
527 self.provider
528 .shutdown()
529 .map_err(|e| OpenInferenceError::Provider(e.to_string()))
530 }
531
532 fn process_start(&mut self, event: &Event) {
533 let mut span = self
534 .tracer
535 .span_builder(span_name(event))
536 .with_kind(span_kind(event))
537 .with_start_time(to_system_time(*event.timestamp()))
538 .start_with_context(&self.tracer, &self.parent_context(event));
539 span.set_attributes(start_attributes(event));
540 let span_context = local_parent_span_context(span.span_context());
541 self.active_spans
542 .insert(event.uuid(), ActiveSpan { span, span_context });
543 }
544
545 fn process_end(&mut self, event: &Event) {
546 let Some(mut active_span) = self.active_spans.remove(&event.uuid()) else {
547 return;
548 };
549 active_span.span.set_attributes(end_attributes(event));
550 active_span
551 .span
552 .end_with_timestamp(to_system_time(*event.timestamp()));
553 }
554
555 fn process_mark(&mut self, event: &Event) {
556 let mark_name = event.name().to_string();
557 let timestamp = to_system_time(*event.timestamp());
558 let attributes = mark_attributes(event);
559
560 if let Some(parent_span) = self.find_parent_span_mut(event) {
561 parent_span
562 .span
563 .add_event_with_timestamp(mark_name, timestamp, attributes);
564 return;
565 }
566
567 let mut span = self
568 .tracer
569 .span_builder(format!("mark:{mark_name}"))
570 .with_kind(SpanKind::Internal)
571 .with_start_time(timestamp)
572 .start_with_context(&self.tracer, &self.parent_context(event));
573 let mut span_attributes = attributes;
574 span_attributes.push(KeyValue::new(
575 oi::OPENINFERENCE_SPAN_KIND,
576 OpenInferenceSpanKind::Chain,
577 ));
578 span_attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
579 span.set_attributes(span_attributes);
580 span.end_with_timestamp(timestamp);
581 }
582
583 fn parent_context(&self, event: &Event) -> Context {
584 self.find_parent_span(event)
585 .map(|active_span| {
586 Context::new().with_remote_span_context(active_span.span_context.clone())
587 })
588 .unwrap_or_default()
589 }
590
591 fn parent_span_uuid(&self, event: &Event) -> Option<Uuid> {
592 event
593 .parent_uuid()
594 .filter(|uuid| self.active_spans.contains_key(uuid))
595 }
596
597 fn find_parent_span(&self, event: &Event) -> Option<&ActiveSpan> {
598 self.parent_span_uuid(event)
599 .and_then(|uuid| self.active_spans.get(&uuid))
600 }
601
602 fn find_parent_span_mut(&mut self, event: &Event) -> Option<&mut ActiveSpan> {
603 self.parent_span_uuid(event)
604 .and_then(|uuid| self.active_spans.get_mut(&uuid))
605 }
606}
607
608fn span_kind(event: &Event) -> SpanKind {
609 match semantic_scope_type(event) {
610 Some(ScopeType::Llm) => SpanKind::Client,
611 Some(
612 ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
613 ) => SpanKind::Client,
614 _ => SpanKind::Internal,
615 }
616}
617
618fn span_name(event: &Event) -> String {
619 event.name().to_string()
620}
621
622fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
623 event.scope_type()
624}
625
626fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
627 match scope_type {
628 Some(ScopeType::Agent) => "agent",
629 Some(ScopeType::Function) => "function",
630 Some(ScopeType::Tool) => "tool",
631 Some(ScopeType::Llm) => "llm",
632 Some(ScopeType::Retriever) => "retriever",
633 Some(ScopeType::Embedder) => "embedder",
634 Some(ScopeType::Reranker) => "reranker",
635 Some(ScopeType::Guardrail) => "guardrail",
636 Some(ScopeType::Evaluator) => "evaluator",
637 Some(ScopeType::Custom) => "custom",
638 Some(ScopeType::Unknown) | None => "unknown",
639 }
640}
641
642fn start_attributes(event: &Event) -> Vec<KeyValue> {
643 let mut attributes = common_attributes(event);
644 let handle_attributes = event.attributes();
645 if handle_attributes.is_some_and(|attributes| !attributes.is_empty()) {
646 push_serialized(
647 &mut attributes,
648 "nemo_relay.handle_attributes_json",
649 handle_attributes,
650 );
651 }
652 if event
653 .category()
654 .is_none_or(|category| category.as_str() != "llm")
655 {
656 push_serialized(
657 &mut attributes,
658 "nemo_relay.start.input_json",
659 event.input(),
660 );
661 }
662 if event
663 .category()
664 .is_some_and(|category| category.as_str() == "tool")
665 {
666 attributes.push(KeyValue::new(oi::tool::NAME, event.name().to_string()));
667 attributes.push(KeyValue::new(
668 oi::tool_call::function::NAME,
669 event.name().to_string(),
670 ));
671 }
672
673 if let Some((input, mime_type)) = openinference_input_value(event) {
674 attributes.push(KeyValue::new(oi::input::VALUE, input.clone()));
675 attributes.push(KeyValue::new(oi::input::MIME_TYPE, mime_type));
676
677 if event
678 .category()
679 .is_some_and(|category| category.as_str() == "tool")
680 {
681 attributes.push(KeyValue::new(oi::tool::PARAMETERS, input.clone()));
682 attributes.push(KeyValue::new(oi::tool_call::function::ARGUMENTS, input));
683 }
684 }
685 attributes
686}
687
688fn end_attributes(event: &Event) -> Vec<KeyValue> {
689 let mut attributes = Vec::new();
690 push_serialized(
691 &mut attributes,
692 "nemo_relay.end.output_json",
693 event.output(),
694 );
695 if let Some((output, mime_type)) = openinference_output_value(event) {
696 attributes.push(KeyValue::new(oi::output::VALUE, output));
697 attributes.push(KeyValue::new(oi::output::MIME_TYPE, mime_type));
698 }
699 let fallback_usage = if event
700 .category()
701 .is_some_and(|category| category.as_str() == "llm")
702 {
703 usage_from_manual_llm_output(event.output())
704 } else {
705 None
706 };
707 let usage = event
708 .annotated_response()
709 .and_then(|response| response.usage.as_ref())
710 .or(fallback_usage.as_ref());
711 if event
712 .category()
713 .is_some_and(|category| category.as_str() == "llm")
714 && let Some(usage) = usage
715 {
716 if let Some(v) = usage.prompt_tokens {
717 attributes.push(KeyValue::new(oi::llm::token_count::PROMPT, v as i64));
718 }
719 if let Some(v) = usage.completion_tokens {
720 attributes.push(KeyValue::new(oi::llm::token_count::COMPLETION, v as i64));
721 }
722 if let Some(v) = usage.total_tokens {
723 attributes.push(KeyValue::new(oi::llm::token_count::TOTAL, v as i64));
724 }
725 if let Some(v) = usage.cache_read_tokens {
726 attributes.push(KeyValue::new(
727 oi::llm::token_count::prompt_details::CACHE_READ,
728 v as i64,
729 ));
730 }
731 if let Some(v) = usage.cache_write_tokens {
732 attributes.push(KeyValue::new(
733 oi::llm::token_count::prompt_details::CACHE_WRITE,
734 v as i64,
735 ));
736 }
737 }
738 attributes
739}
740
741fn usage_from_manual_llm_output(output: Option<&Json>) -> Option<Usage> {
742 let object = output?.as_object()?;
743 let usage = object.get("usage").and_then(Json::as_object);
744 let token_usage = object.get("token_usage").and_then(Json::as_object);
745 if usage.is_none() && token_usage.is_none() {
746 return None;
747 }
748
749 let prompt_tokens = first_u64_from_manual_usage(
750 usage,
751 token_usage,
752 &["prompt_tokens", "input_tokens", "inputTokens", "input"],
753 );
754 let completion_tokens = first_u64_from_manual_usage(
755 usage,
756 token_usage,
757 &[
758 "completion_tokens",
759 "output_tokens",
760 "completionTokens",
761 "outputTokens",
762 "output",
763 ],
764 );
765 let reported_total_tokens = first_u64_from_manual_usage(
766 usage,
767 token_usage,
768 &["total_tokens", "totalTokens", "total"],
769 );
770 let cache_read_tokens = first_u64_from_manual_usage(
771 usage,
772 token_usage,
773 &[
774 "cache_read_tokens",
775 "cached_tokens",
776 "cache_read_input_tokens",
777 "cacheReadTokens",
778 "cachedTokens",
779 "cacheReadInputTokens",
780 "cacheRead",
781 ],
782 );
783 let cache_write_tokens = first_u64_from_manual_usage(
784 usage,
785 token_usage,
786 &[
787 "cache_write_tokens",
788 "cache_creation_input_tokens",
789 "cacheWriteTokens",
790 "cacheCreationInputTokens",
791 "cacheWrite",
792 ],
793 );
794
795 if prompt_tokens.is_none()
796 && completion_tokens.is_none()
797 && reported_total_tokens.is_none()
798 && cache_read_tokens.is_none()
799 && cache_write_tokens.is_none()
800 {
801 return None;
802 }
803 let total_tokens =
804 normalize_total_tokens(reported_total_tokens, prompt_tokens, completion_tokens);
805
806 Some(Usage {
807 prompt_tokens,
808 completion_tokens,
809 total_tokens,
810 cache_read_tokens,
811 cache_write_tokens,
812 })
813}
814
815fn normalize_total_tokens(
816 total_tokens: Option<u64>,
817 prompt_tokens: Option<u64>,
818 completion_tokens: Option<u64>,
819) -> Option<u64> {
820 let total_tokens = total_tokens?;
821 let minimum_total = prompt_tokens
822 .unwrap_or(0)
823 .saturating_add(completion_tokens.unwrap_or(0));
824 if minimum_total == 0 || total_tokens >= minimum_total {
825 Some(total_tokens)
826 } else {
827 None
828 }
829}
830
831fn first_u64_from_manual_usage(
832 usage: Option<&serde_json::Map<String, Json>>,
833 token_usage: Option<&serde_json::Map<String, Json>>,
834 keys: &[&str],
835) -> Option<u64> {
836 usage
837 .and_then(|value| first_u64(value, keys))
838 .or_else(|| token_usage.and_then(|value| first_u64(value, keys)))
839}
840
841fn first_u64(usage: &serde_json::Map<String, Json>, keys: &[&str]) -> Option<u64> {
842 keys.iter()
843 .find_map(|key| usage.get(*key).and_then(Json::as_u64))
844}
845
846fn mark_attributes(event: &Event) -> Vec<KeyValue> {
847 let handle_attributes = event.attributes();
848 let mut attributes = vec![
849 KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
850 KeyValue::new(
851 "nemo_relay.mark.parent_uuid",
852 event
853 .parent_uuid()
854 .map(|uuid| uuid.to_string())
855 .unwrap_or_default(),
856 ),
857 ];
858 push_serialized(
859 &mut attributes,
860 "nemo_relay.mark.attributes_json",
861 handle_attributes,
862 );
863 push_serialized(&mut attributes, "nemo_relay.mark.data_json", event.data());
864 push_serialized(
865 &mut attributes,
866 "nemo_relay.mark.metadata_json",
867 event.metadata(),
868 );
869 attributes
870}
871
872fn common_attributes(event: &Event) -> Vec<KeyValue> {
873 let mut attributes = vec![
874 KeyValue::new(
875 oi::OPENINFERENCE_SPAN_KIND,
876 openinference_span_kind(semantic_scope_type(event)),
877 ),
878 KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
879 KeyValue::new(
880 "nemo_relay.parent_uuid",
881 event
882 .parent_uuid()
883 .map(|uuid| uuid.to_string())
884 .unwrap_or_default(),
885 ),
886 KeyValue::new(
887 "nemo_relay.scope_type",
888 scope_type_name(semantic_scope_type(event)),
889 ),
890 ];
891
892 if let Some(model_name) = event.model_name() {
893 attributes.push(KeyValue::new(oi::llm::MODEL_NAME, model_name.to_string()));
894 }
895 if let Some(tool_call_id) = event.tool_call_id() {
896 attributes.push(KeyValue::new(oi::tool_call::ID, tool_call_id.to_string()));
897 }
898 if let Some(metadata) = event.metadata().and_then(to_json_string) {
899 attributes.push(KeyValue::new(oi::METADATA, metadata));
900 }
901
902 attributes
903}
904
905fn openinference_span_kind(scope_type: Option<ScopeType>) -> OpenInferenceSpanKind {
906 match scope_type {
907 Some(ScopeType::Agent) => OpenInferenceSpanKind::Agent,
908 Some(ScopeType::Tool) => OpenInferenceSpanKind::Tool,
909 Some(ScopeType::Llm) => OpenInferenceSpanKind::Llm,
910 Some(ScopeType::Retriever) => OpenInferenceSpanKind::Retriever,
911 Some(ScopeType::Embedder) => OpenInferenceSpanKind::Embedding,
912 Some(ScopeType::Reranker) => OpenInferenceSpanKind::Reranker,
913 Some(ScopeType::Guardrail) => OpenInferenceSpanKind::Guardrail,
914 Some(ScopeType::Evaluator) => OpenInferenceSpanKind::Evaluator,
915 Some(ScopeType::Function | ScopeType::Custom | ScopeType::Unknown) | None => {
916 OpenInferenceSpanKind::Chain
917 }
918 }
919}
920
921fn push_serialized<T: Serialize + ?Sized>(
922 attributes: &mut Vec<KeyValue>,
923 key: &'static str,
924 value: Option<&T>,
925) {
926 if let Some(value) = value
927 && let Ok(json) = serde_json::to_string(value)
928 {
929 attributes.push(KeyValue::new(key, json));
930 }
931}
932
933fn openinference_input_value(event: &Event) -> Option<(String, &'static str)> {
934 let input = event.input()?;
935
936 if event
937 .category()
938 .is_some_and(|category| category.as_str() == "llm")
939 {
940 return llm_input_display_value(input)
941 .map(|display| (display, "text/plain"))
942 .or_else(|| sanitized_llm_input_json(input).map(|json| (json, "application/json")));
943 }
944
945 to_json_string(input).map(|json| (json, "application/json"))
946}
947
948fn openinference_output_value(event: &Event) -> Option<(String, &'static str)> {
949 let output = event.output()?;
950 display_text_from_json(output)
951 .map(|display| (display, "text/plain"))
952 .or_else(|| to_json_string(output).map(|json| (json, "application/json")))
953}
954
955fn llm_input_display_value(input: &Json) -> Option<String> {
956 let content = match input {
957 Json::Object(object) => object.get("content").unwrap_or(input),
958 _ => input,
959 };
960
961 content
962 .get("messages")
963 .and_then(display_text_from_messages)
964 .or_else(|| display_text_from_json(content))
965}
966
967fn sanitized_llm_input_json(input: &Json) -> Option<String> {
968 match input {
969 Json::Object(object) => {
970 let mut sanitized = object.clone();
971 sanitized.remove("headers");
972 to_json_string(&Json::Object(sanitized))
973 }
974 _ => to_json_string(input),
975 }
976}
977
978fn display_text_from_json(value: &Json) -> Option<String> {
979 match value {
980 Json::String(text) => display_text_from_string(text),
981 Json::Object(object) => {
982 for key in ["content", "summary", "message", "text", "prompt"] {
983 if let Some(display) = object.get(key).and_then(display_text_from_json) {
984 return Some(display);
985 }
986 }
987 object
988 .get("choices")
989 .and_then(display_text_from_chat_choices)
990 .or_else(|| {
991 object
992 .get("tool_calls")
993 .and_then(display_text_from_tool_calls)
994 })
995 }
996 Json::Array(items) => display_text_from_content_blocks(items),
997 _ => None,
998 }
999}
1000
1001fn display_text_from_messages(value: &Json) -> Option<String> {
1002 let messages = value.as_array()?;
1003 let text = messages
1004 .iter()
1005 .filter_map(display_text_from_message)
1006 .collect::<Vec<_>>()
1007 .join("\n\n")
1008 .trim()
1009 .to_string();
1010 if text.is_empty() { None } else { Some(text) }
1011}
1012
1013fn display_text_from_message(value: &Json) -> Option<String> {
1014 let role = value
1015 .get("role")
1016 .and_then(Json::as_str)
1017 .unwrap_or("message");
1018 if role == "tool" {
1019 return Some("tool: Tool result omitted".to_string());
1020 }
1021 let display = value
1022 .get("content")
1023 .and_then(display_text_from_json)
1024 .or_else(|| {
1025 value
1026 .get("tool_calls")
1027 .and_then(display_text_from_tool_calls)
1028 })?;
1029 Some(format!("{role}: {display}"))
1030}
1031
1032fn display_text_from_string(text: &str) -> Option<String> {
1033 let trimmed = text.trim();
1034 if trimmed.is_empty() {
1035 return None;
1036 }
1037 if let Ok(parsed) = serde_json::from_str::<Json>(trimmed)
1038 && let Some(display) = display_text_from_json(&parsed)
1039 {
1040 return Some(display);
1041 }
1042 Some(trimmed.to_string())
1043}
1044
1045fn display_text_from_chat_choices(value: &Json) -> Option<String> {
1046 let choices = value.as_array()?;
1047 for choice in choices {
1048 let Some(message) = choice.get("message") else {
1049 continue;
1050 };
1051 let content = message.get("content").and_then(display_text_from_json);
1052 let tool_calls = message
1053 .get("tool_calls")
1054 .and_then(display_text_from_tool_calls);
1055 match (content, tool_calls) {
1056 (Some(content), Some(tool_calls)) => return Some(format!("{content}\n{tool_calls}")),
1057 (Some(content), None) => return Some(content),
1058 (None, Some(tool_calls)) => return Some(tool_calls),
1059 (None, None) => {}
1060 }
1061 }
1062 None
1063}
1064
1065fn display_text_from_content_blocks(items: &[Json]) -> Option<String> {
1066 let mut entries = items
1067 .iter()
1068 .filter_map(content_block_display_text)
1069 .collect::<Vec<_>>();
1070 let tool_calls = items.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1071 if !tool_calls.is_empty() {
1072 entries.push(format!("Requested tools: {}", tool_calls.join(", ")));
1073 }
1074 let text = entries
1075 .into_iter()
1076 .filter(|item| !item.trim().is_empty())
1077 .collect::<Vec<_>>()
1078 .join("\n")
1079 .trim()
1080 .to_string();
1081 if text.is_empty() { None } else { Some(text) }
1082}
1083
1084fn content_block_display_text(item: &Json) -> Option<String> {
1085 if let Some(text) = item.as_str() {
1086 return Some(text.to_string());
1087 }
1088 if item.get("stripped").and_then(Json::as_bool) == Some(true) {
1089 return None;
1090 }
1091 if let Some("thinking" | "reasoning" | "toolResult" | "tool_result") =
1092 item.get("type").and_then(Json::as_str)
1093 {
1094 return None;
1095 }
1096 item.get("text").and_then(Json::as_str).map(str::to_string)
1097}
1098
1099fn display_text_from_tool_calls(value: &Json) -> Option<String> {
1100 let calls = value.as_array()?;
1101 let names = calls.iter().filter_map(tool_call_name).collect::<Vec<_>>();
1102 if names.is_empty() {
1103 None
1104 } else {
1105 Some(format!("Requested tools: {}", names.join(", ")))
1106 }
1107}
1108
1109fn tool_call_name(value: &Json) -> Option<String> {
1110 value
1111 .get("name")
1112 .and_then(Json::as_str)
1113 .or_else(|| value.get("toolName").and_then(Json::as_str))
1114 .or_else(|| {
1115 value
1116 .get("function")
1117 .and_then(|function| function.get("name"))
1118 .and_then(Json::as_str)
1119 })
1120 .map(str::to_string)
1121}
1122
1123fn to_json_string<T: Serialize>(value: &T) -> Option<String> {
1124 serde_json::to_string(value).ok()
1125}
1126
1127fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
1128 SpanContext::new(
1129 span_context.trace_id(),
1130 span_context.span_id(),
1131 span_context.trace_flags(),
1132 false,
1133 span_context.trace_state().clone(),
1134 )
1135}
1136
1137fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
1138 let seconds = timestamp.timestamp();
1139 let nanos = timestamp.timestamp_subsec_nanos();
1140 if seconds >= 0 {
1141 UNIX_EPOCH + Duration::new(seconds as u64, nanos)
1142 } else if nanos == 0 {
1143 UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
1144 } else {
1145 UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
1146 }
1147}
1148
1149#[cfg(test)]
1150#[path = "../../tests/unit/observability/openinference_tests.rs"]
1151mod tests;