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