1use super::{
9 estimate_cost_for_response_or_model, estimate_cost_for_response_or_requested_model, manual,
10 merge_usage, model_name_for_llm_event, push_serialized_top_level_attributes,
11 push_top_level_json_attributes,
12};
13use crate::api::event::{Event, EventNormalizationExt};
14use crate::api::scope::ScopeType;
15use crate::codec::request::{
16 AnnotatedLlmRequest, ContentPart, Message, MessageContent, ToolDefinition,
17};
18use crate::codec::response::{AnnotatedLlmResponse, FinishReason, ResponseToolCall, Usage};
19use crate::json::Json;
20#[cfg(test)]
21use chrono::{DateTime, Utc};
22use opentelemetry::KeyValue;
23#[cfg(test)]
24use opentelemetry::trace::SpanContext;
25use opentelemetry::trace::SpanKind;
26use serde::Serialize;
27#[cfg(test)]
28use std::time::{Duration, SystemTime, UNIX_EPOCH};
29
30pub(super) fn span_kind(event: &Event) -> SpanKind {
31 match semantic_scope_type(event) {
32 Some(ScopeType::Llm) => SpanKind::Client,
33 Some(
34 ScopeType::Tool | ScopeType::Retriever | ScopeType::Embedder | ScopeType::Reranker,
35 ) => SpanKind::Client,
36 _ => SpanKind::Internal,
37 }
38}
39
40pub(super) fn span_name(event: &Event) -> String {
41 event.name().to_string()
42}
43
44fn semantic_scope_type(event: &Event) -> Option<ScopeType> {
45 event.scope_type()
46}
47
48fn scope_type_name(scope_type: Option<ScopeType>) -> &'static str {
49 match scope_type {
50 Some(ScopeType::Agent) => "agent",
51 Some(ScopeType::Function) => "function",
52 Some(ScopeType::Tool) => "tool",
53 Some(ScopeType::Llm) => "llm",
54 Some(ScopeType::Retriever) => "retriever",
55 Some(ScopeType::Embedder) => "embedder",
56 Some(ScopeType::Reranker) => "reranker",
57 Some(ScopeType::Guardrail) => "guardrail",
58 Some(ScopeType::Evaluator) => "evaluator",
59 Some(ScopeType::Custom) => "custom",
60 Some(ScopeType::Unknown) | None => "unknown",
61 }
62}
63
64pub(super) fn start_attributes(event: &Event) -> Vec<KeyValue> {
65 let mut attributes = common_attributes(event);
66 let is_llm = event
67 .category()
68 .is_some_and(|category| category.as_str() == "llm")
69 || semantic_scope_type(event) == Some(ScopeType::Llm);
70 if is_llm {
71 attributes.retain(|attribute| {
74 attribute.key.as_str() != "metadata"
75 && !attribute.key.as_str().starts_with("openinference.metadata")
76 });
77 }
78 if !is_llm {
79 push_serialized_top_level_attributes(
80 &mut attributes,
81 "nemo_relay.handle_attributes",
82 event.attributes(),
83 );
84 push_top_level_json_attributes(&mut attributes, "nemo_relay.start.data", event.data());
85 push_top_level_json_attributes(&mut attributes, "nemo_relay.start.input", event.input());
86 }
87 if event
88 .category()
89 .is_some_and(|category| category.as_str() == "tool")
90 {
91 attributes.push(KeyValue::new("tool.name", event.name().to_string()));
92 attributes.push(KeyValue::new(
93 "tool_call.function.name",
94 event.name().to_string(),
95 ));
96 }
97
98 if let Some((input, mime_type)) = openinference_input_value(event) {
99 attributes.push(KeyValue::new("input.value", input.clone()));
100 attributes.push(KeyValue::new("input.mime_type", mime_type));
101
102 if event
103 .category()
104 .is_some_and(|category| category.as_str() == "tool")
105 {
106 attributes.push(KeyValue::new("tool.parameters", input.clone()));
107 attributes.push(KeyValue::new("tool_call.function.arguments", input));
108 }
109 }
110 if is_llm {
111 push_llm_request_attributes(&mut attributes, event);
112 }
113 attributes
114}
115
116pub(super) fn end_attributes(event: &Event) -> Vec<KeyValue> {
117 let mut attributes = Vec::new();
118 let is_llm = event
119 .category()
120 .is_some_and(|category| category.as_str() == "llm")
121 || semantic_scope_type(event) == Some(ScopeType::Llm);
122
123 push_top_level_json_attributes(&mut attributes, "nemo_relay.end.data", event.data());
124 if let Some(metadata) = event.metadata().and_then(to_json_string) {
125 attributes.push(KeyValue::new("metadata", metadata));
126 }
127 push_top_level_json_attributes(&mut attributes, "openinference.metadata", event.metadata());
128 push_top_level_json_attributes(&mut attributes, "nemo_relay.end.output", event.output());
129 if let Some((output, mime_type)) = openinference_output_value(event) {
130 attributes.push(KeyValue::new("output.value", output));
131 attributes.push(KeyValue::new("output.mime_type", mime_type));
132 }
133 let fallback_usage = if is_llm {
134 manual::usage_from_manual_llm_output(event.output())
135 } else {
136 None
137 };
138 let normalized = if is_llm {
142 event.normalized_llm_response()
143 } else {
144 None
145 };
146 let usage = merge_usage(
147 normalized
148 .as_ref()
149 .and_then(|response| response.usage.as_ref()),
150 fallback_usage.as_ref(),
151 );
152 if is_llm {
153 push_llm_usage_attributes(&mut attributes, usage.as_ref());
154 }
155 if is_llm
156 && let Some(cost_total) =
157 cost_total_from_llm_event(event, normalized.as_deref(), fallback_usage.as_ref())
158 {
159 attributes.push(KeyValue::new("llm.cost.total", cost_total));
160 }
161 if is_llm {
162 push_llm_response_attributes(&mut attributes, event, normalized.as_deref());
163 }
164 attributes
165}
166
167fn push_llm_usage_attributes(attributes: &mut Vec<KeyValue>, usage: Option<&Usage>) {
168 let Some(usage) = usage else {
169 return;
170 };
171 if let Some(v) = usage.prompt_tokens {
172 attributes.push(KeyValue::new("llm.token_count.prompt", v as i64));
173 }
174 if let Some(v) = usage.completion_tokens {
175 attributes.push(KeyValue::new("llm.token_count.completion", v as i64));
176 }
177 if let Some(v) = usage.total_tokens {
178 attributes.push(KeyValue::new("llm.token_count.total", v as i64));
179 }
180 if let Some(v) = usage.cache_read_tokens {
181 attributes.push(KeyValue::new(
182 "llm.token_count.prompt_details.cache_read",
183 v as i64,
184 ));
185 }
186 if let Some(v) = usage.cache_write_tokens {
187 attributes.push(KeyValue::new(
188 "llm.token_count.prompt_details.cache_write",
189 v as i64,
190 ));
191 }
192}
193
194fn push_llm_request_attributes(attributes: &mut Vec<KeyValue>, event: &Event) {
195 if let Some(request) = event.annotated_request() {
196 push_annotated_request_attributes(attributes, request);
197 return;
198 }
199
200 if let Some(input) = event.input().and_then(replay_llm_payload) {
203 if let Some(provider) = input.get("provider").and_then(Json::as_str) {
204 attributes.push(KeyValue::new("llm.provider", provider.to_string()));
205 }
206 push_replay_input_messages(attributes, input);
207 return;
208 }
209
210 if let Some(request) = event.normalized_llm_request() {
211 push_annotated_request_attributes(attributes, &request);
212 }
213}
214
215fn push_llm_response_attributes(
216 attributes: &mut Vec<KeyValue>,
217 event: &Event,
218 normalized: Option<&AnnotatedLlmResponse>,
219) {
220 if let Some(response) = event.annotated_response() {
221 push_annotated_response_attributes(attributes, response);
222 return;
223 }
224
225 if let Some(output) = event.output().and_then(replay_llm_response) {
226 push_replay_response_attributes(attributes, output);
227 return;
228 }
229
230 if let Some(response) = normalized {
233 push_annotated_response_attributes(attributes, response);
234 }
235}
236
237fn push_annotated_request_attributes(
238 attributes: &mut Vec<KeyValue>,
239 request: &AnnotatedLlmRequest,
240) {
241 if let Some(params) = request.params.as_ref().and_then(to_json_string) {
242 attributes.push(KeyValue::new("llm.invocation_parameters", params));
243 }
244 let mut next_index = 0usize;
245 if let Some(instructions) = request.instructions.as_ref().and_then(message_content_text) {
246 push_message_role(attributes, "llm.input_messages", next_index, "system");
247 push_message_text_value(attributes, "llm.input_messages", next_index, instructions);
248 next_index += 1;
249 }
250 push_annotated_input_messages(attributes, &request.messages, next_index);
251 if let Some(tools) = request.tools.as_deref() {
252 push_annotated_tools(attributes, tools);
253 }
254}
255
256fn push_annotated_response_attributes(
257 attributes: &mut Vec<KeyValue>,
258 response: &AnnotatedLlmResponse,
259) {
260 if let Some(reason) = response.finish_reason.as_ref() {
261 attributes.push(KeyValue::new(
262 "llm.finish_reason",
263 finish_reason_value(reason),
264 ));
265 }
266
267 let has_message = response.message.is_some()
268 || response
269 .tool_calls
270 .as_ref()
271 .is_some_and(|tool_calls| !tool_calls.is_empty());
272 if has_message {
273 attributes.push(KeyValue::new(
274 "llm.output_messages.0.message.role",
275 "assistant",
276 ));
277 }
278 if let Some(content) = response.message.as_ref().and_then(message_content_text) {
279 attributes.push(KeyValue::new(
280 "llm.output_messages.0.message.content",
281 content,
282 ));
283 }
284 if let Some(tool_calls) = response.tool_calls.as_deref() {
285 push_response_tool_calls(attributes, 0, tool_calls);
286 }
287 if let Some(summary) = response.optimization_summary.as_ref() {
288 push_optimization_attributes(attributes, summary);
289 }
290}
291
292fn push_optimization_attributes(
293 attributes: &mut Vec<KeyValue>,
294 summary: &crate::codec::optimization::LlmOptimizationSummary,
295) {
296 crate::observability::push_common_optimization_attributes(attributes, summary);
297}
298
299fn push_annotated_input_messages(
300 attributes: &mut Vec<KeyValue>,
301 messages: &[Message],
302 start_index: usize,
303) {
304 for (offset, message) in messages.iter().enumerate() {
305 let index = start_index + offset;
306 let role = match message {
307 Message::System { .. } => "system",
308 Message::Developer { .. } => "developer",
309 Message::User { .. } => "user",
310 Message::Assistant { .. } => "assistant",
311 Message::Tool { .. } => "tool",
312 Message::Function { .. } => "function",
313 Message::ToolCallItem { .. } => "assistant",
314 Message::ToolResultItem { .. } => "tool",
315 Message::ProviderNative { value, .. } => value
316 .get("role")
317 .and_then(Json::as_str)
318 .unwrap_or("provider_native"),
319 };
320 push_message_role(attributes, "llm.input_messages", index, role);
321 let content = match message {
322 Message::System { content, .. }
323 | Message::Developer { content, .. }
324 | Message::User { content, .. }
325 | Message::Tool { content, .. } => message_content_text(content),
326 Message::Assistant { content, .. } => content.as_ref().and_then(message_content_text),
327 Message::Function { content, .. } => {
328 content.as_deref().and_then(display_text_from_string)
329 }
330 Message::ProviderNative { value, .. } => {
331 value.get("content").and_then(display_text_from_json)
332 }
333 Message::ToolCallItem { .. } | Message::ToolResultItem { .. } => None,
334 };
335 if let Some(content) = content {
336 push_message_text_value(attributes, "llm.input_messages", index, content);
337 }
338 }
339}
340
341fn push_annotated_tools(attributes: &mut Vec<KeyValue>, tools: &[ToolDefinition]) {
342 for (index, tool) in tools.iter().enumerate() {
343 if let Some(json) = to_json_string(tool) {
344 attributes.push(KeyValue::new(
345 format!("llm.tools.{index}.tool.json_schema"),
346 json,
347 ));
348 }
349 }
350}
351
352fn push_response_tool_calls(
353 attributes: &mut Vec<KeyValue>,
354 message_index: usize,
355 tool_calls: &[ResponseToolCall],
356) {
357 for (call_index, tool_call) in tool_calls.iter().enumerate() {
358 push_output_tool_call(
359 attributes,
360 message_index,
361 call_index,
362 Some(tool_call.id.as_str()),
363 Some(tool_call.name.as_str()),
364 to_json_string(&tool_call.arguments),
365 );
366 }
367}
368
369fn push_message_role(
370 attributes: &mut Vec<KeyValue>,
371 prefix: &'static str,
372 index: usize,
373 role: &str,
374) {
375 attributes.push(KeyValue::new(
376 format!("{prefix}.{index}.message.role"),
377 role.to_string(),
378 ));
379}
380
381fn push_message_text_value(
382 attributes: &mut Vec<KeyValue>,
383 prefix: &'static str,
384 index: usize,
385 text: String,
386) {
387 attributes.push(KeyValue::new(
388 format!("{prefix}.{index}.message.content"),
389 text,
390 ));
391}
392
393fn message_content_text(content: &MessageContent) -> Option<String> {
394 match content {
395 MessageContent::Text(text) => display_text_from_string(text),
396 MessageContent::Parts(parts) => {
397 let text = parts
398 .iter()
399 .filter_map(|part| match part {
400 ContentPart::Text { text, .. } => Some(text.as_str()),
401 ContentPart::Refusal { refusal, .. } => Some(refusal.as_str()),
402 ContentPart::ProviderNative { value, .. } => value
403 .get("text")
404 .and_then(Json::as_str)
405 .or_else(|| value.get("refusal").and_then(Json::as_str)),
406 _ => None,
407 })
408 .collect::<Vec<_>>()
409 .join("\n")
410 .trim()
411 .to_string();
412 if text.is_empty() { None } else { Some(text) }
413 }
414 }
415}
416
417fn replay_llm_payload(input: &Json) -> Option<&Json> {
418 let content = input.as_object().and_then(|object| object.get("content"))?;
419 let content_object = content.as_object()?;
420 is_openclaw_replay_payload(content_object).then_some(content)
421}
422
423fn replay_llm_response(output: &Json) -> Option<&Json> {
424 output
425 .as_object()
426 .and_then(|object| object.get("openclaw"))
427 .and_then(Json::as_object)
428 .map(|_| output)
429}
430
431fn is_openclaw_replay_payload(content: &serde_json::Map<String, Json>) -> bool {
432 content
433 .get("source")
434 .and_then(Json::as_str)
435 .is_some_and(|source| source.starts_with("openclaw."))
436 || content.contains_key("placeholderRequest")
437}
438
439fn push_replay_input_messages(attributes: &mut Vec<KeyValue>, input: &Json) {
440 let mut next_index = 0usize;
441 if let Some(system_prompt) = input.get("systemPrompt").and_then(display_text_from_json) {
442 push_message_role(attributes, "llm.input_messages", next_index, "system");
443 attributes.push(KeyValue::new(
444 format!("llm.input_messages.{next_index}.message.content"),
445 system_prompt,
446 ));
447 next_index += 1;
448 }
449 if let Some(messages) = input.get("messages").and_then(Json::as_array) {
450 let first_message_index = next_index;
451 for message in messages {
452 if push_replay_input_message(attributes, next_index, message) {
453 next_index += 1;
454 }
455 }
456 if next_index > first_message_index {
457 return;
458 }
459 }
460 if let Some(prompt) = input.get("prompt").and_then(display_text_from_json) {
461 push_message_role(attributes, "llm.input_messages", next_index, "user");
462 attributes.push(KeyValue::new(
463 format!("llm.input_messages.{next_index}.message.content"),
464 prompt,
465 ));
466 }
467}
468
469fn push_replay_input_message(attributes: &mut Vec<KeyValue>, index: usize, message: &Json) -> bool {
470 let Some(object) = message.as_object() else {
471 return false;
472 };
473 let Some(role) = object.get("role").and_then(Json::as_str) else {
474 return false;
475 };
476 let Some(text) = object.get("content").and_then(display_text_from_json) else {
477 return false;
478 };
479 push_message_role(attributes, "llm.input_messages", index, role);
480 attributes.push(KeyValue::new(
481 format!("llm.input_messages.{index}.message.content"),
482 text,
483 ));
484 true
485}
486
487fn push_replay_response_attributes(attributes: &mut Vec<KeyValue>, output: &Json) {
488 if output.get("role").is_none()
489 && output.get("content").is_none()
490 && output.get("tool_calls").is_none()
491 {
492 return;
493 }
494 let role = output
495 .get("role")
496 .and_then(Json::as_str)
497 .unwrap_or("assistant");
498 push_message_role(attributes, "llm.output_messages", 0, role);
499 if let Some(content) = output.get("content").and_then(display_text_from_json) {
500 attributes.push(KeyValue::new(
501 "llm.output_messages.0.message.content",
502 content,
503 ));
504 }
505 if let Some(tool_calls) = output.get("tool_calls").and_then(Json::as_array) {
506 push_raw_output_tool_calls(attributes, 0, tool_calls);
507 }
508}
509
510fn push_raw_output_tool_calls(
511 attributes: &mut Vec<KeyValue>,
512 message_index: usize,
513 tool_calls: &[Json],
514) {
515 for (call_index, tool_call) in tool_calls.iter().enumerate() {
516 push_output_tool_call(
517 attributes,
518 message_index,
519 call_index,
520 raw_tool_call_id(tool_call),
521 raw_tool_call_name(tool_call),
522 raw_tool_call_arguments(tool_call).and_then(|value| {
523 value
524 .as_str()
525 .map(str::to_string)
526 .or_else(|| to_json_string(value))
527 }),
528 );
529 }
530}
531
532fn raw_tool_call_id(tool_call: &Json) -> Option<&str> {
535 tool_call
536 .get("id")
537 .or_else(|| tool_call.get("tool_call_id"))
538 .or_else(|| tool_call.get("call_id"))
539 .and_then(Json::as_str)
540}
541
542fn raw_tool_call_name(tool_call: &Json) -> Option<&str> {
543 tool_call
544 .get("name")
545 .and_then(Json::as_str)
546 .or_else(|| tool_call.get("toolName").and_then(Json::as_str))
547 .or_else(|| tool_call.get("tool_name").and_then(Json::as_str))
548 .or_else(|| {
549 tool_call
550 .get("function")
551 .and_then(|function| function.get("name"))
552 .and_then(Json::as_str)
553 })
554 .or_else(|| tool_call.get("function_name").and_then(Json::as_str))
555}
556
557fn raw_tool_call_arguments(tool_call: &Json) -> Option<&Json> {
558 tool_call
559 .get("function")
560 .and_then(|function| function.get("arguments"))
561 .or_else(|| tool_call.get("arguments"))
562 .or_else(|| tool_call.get("args"))
563 .or_else(|| tool_call.get("input"))
564}
565
566fn push_output_tool_call(
567 attributes: &mut Vec<KeyValue>,
568 message_index: usize,
569 call_index: usize,
570 id: Option<&str>,
571 name: Option<&str>,
572 arguments: Option<String>,
573) {
574 if let Some(id) = id {
575 attributes.push(KeyValue::new(
576 format!(
577 "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.id"
578 ),
579 id.to_string(),
580 ));
581 }
582 if let Some(name) = name {
583 attributes.push(KeyValue::new(
584 format!(
585 "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.name"
586 ),
587 name.to_string(),
588 ));
589 }
590 if let Some(arguments) = arguments {
591 attributes.push(KeyValue::new(
592 format!(
593 "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.arguments"
594 ),
595 arguments,
596 ));
597 }
598}
599
600fn finish_reason_value(reason: &FinishReason) -> String {
601 match reason {
602 FinishReason::Complete => "complete".to_string(),
603 FinishReason::Length => "length".to_string(),
604 FinishReason::ToolUse => "tool_use".to_string(),
605 FinishReason::ContentFilter => "content_filter".to_string(),
606 FinishReason::Unknown(reason) => reason.clone(),
607 }
608}
609
610fn cost_total_from_llm_event(
611 event: &Event,
612 normalized_response: Option<&AnnotatedLlmResponse>,
613 fallback_usage: Option<&Usage>,
614) -> Option<f64> {
615 if let Some(response) = normalized_response
616 && let Some(usage) = response.usage.as_ref()
617 {
618 if let Some(cost) = usage.cost.as_ref() {
619 return cost.total_or_component_sum_for_currency("USD");
620 }
621 if let Some(cost) =
622 estimate_cost_for_response_or_requested_model(event, response.model.as_deref(), usage)
623 {
624 return cost.total_for_currency("USD");
625 }
626 }
627
628 if let Some(cost) =
629 manual::cost_from_manual_llm_output(event.output(), manual::ManualCostPolicy::UsdOnly)
630 .map(|(total, _)| total)
631 {
632 return Some(cost);
633 }
634
635 let usage = fallback_usage?;
636 estimate_cost_for_response_or_model(
637 Some(event.name()),
638 event.model_name(),
639 manual::model_name_from_manual_llm_output(event.output()),
640 usage,
641 )
642 .and_then(|cost| cost.total_for_currency("USD"))
643}
644
645pub(super) fn mark_attributes(event: &Event) -> Vec<KeyValue> {
646 let mut attributes = vec![
647 KeyValue::new("nemo_relay.mark.uuid", event.uuid().to_string()),
648 KeyValue::new(
649 "nemo_relay.mark.parent_uuid",
650 event
651 .parent_uuid()
652 .map(|uuid| uuid.to_string())
653 .unwrap_or_default(),
654 ),
655 ];
656 push_serialized_top_level_attributes(
657 &mut attributes,
658 "nemo_relay.mark.attributes",
659 event.attributes(),
660 );
661 push_top_level_json_attributes(&mut attributes, "nemo_relay.mark.data", event.data());
662 push_top_level_json_attributes(
663 &mut attributes,
664 "nemo_relay.mark.metadata",
665 event.metadata(),
666 );
667 if let Some(category) = event.category() {
668 attributes.push(KeyValue::new(
669 "nemo_relay.mark.category",
670 category.as_str().to_string(),
671 ));
672 }
673 push_serialized_top_level_attributes(
674 &mut attributes,
675 "nemo_relay.mark.category_profile",
676 event.category_profile(),
677 );
678 attributes
679}
680
681fn push_projected_mark_attributes(attributes: &mut Vec<KeyValue>, event: &Event) {
682 let mark_name = event.name().to_string();
683 attributes.push(KeyValue::new("tool.name", mark_name.clone()));
684 attributes.push(KeyValue::new("tool_call.function.name", mark_name));
685
686 if let Some(data) = event.data().and_then(to_json_string) {
687 attributes.push(KeyValue::new("output.value", data));
688 attributes.push(KeyValue::new("output.mime_type", "application/json"));
689 }
690 if let Some(metadata) = event.metadata().and_then(to_json_string) {
691 attributes.push(KeyValue::new("metadata", metadata));
692 }
693}
694
695pub(super) fn remove_start_model_name(attributes: &mut Vec<KeyValue>) {
696 attributes.retain(|attribute| attribute.key.as_str() != "llm.model_name");
697}
698
699pub(super) fn push_model_name(attributes: &mut Vec<KeyValue>, model_name: String) {
700 attributes.push(KeyValue::new("llm.model_name", model_name));
701}
702
703pub(super) fn push_orphan_mark_attributes(attributes: &mut Vec<KeyValue>) {
704 attributes.push(KeyValue::new("openinference.span.kind", "CHAIN"));
705 attributes.push(KeyValue::new("nemo_relay.mark.orphan", true));
706}
707
708pub(super) fn push_tool_mark_attributes(attributes: &mut Vec<KeyValue>, event: &Event) {
709 attributes.push(KeyValue::new("openinference.span.kind", "TOOL"));
710 push_projected_mark_attributes(attributes, event);
711}
712
713fn common_attributes(event: &Event) -> Vec<KeyValue> {
714 let mut attributes = vec![
715 KeyValue::new(
716 "openinference.span.kind",
717 openinference_span_kind(semantic_scope_type(event)),
718 ),
719 KeyValue::new("nemo_relay.uuid", event.uuid().to_string()),
720 KeyValue::new(
721 "nemo_relay.parent_uuid",
722 event
723 .parent_uuid()
724 .map(|uuid| uuid.to_string())
725 .unwrap_or_default(),
726 ),
727 KeyValue::new(
728 "nemo_relay.scope_type",
729 scope_type_name(semantic_scope_type(event)),
730 ),
731 ];
732
733 if let Some(model_name) = model_name_for_llm_event(event) {
734 attributes.push(KeyValue::new("llm.model_name", model_name));
735 }
736 if let Some(tool_call_id) = event.tool_call_id() {
737 attributes.push(KeyValue::new("tool_call.id", tool_call_id.to_string()));
738 }
739 if let Some(metadata) = event.metadata().and_then(to_json_string) {
740 attributes.push(KeyValue::new("metadata", metadata));
741 }
742 push_top_level_json_attributes(&mut attributes, "openinference.metadata", event.metadata());
743
744 attributes
745}
746
747fn openinference_span_kind(scope_type: Option<ScopeType>) -> &'static str {
748 match scope_type {
749 Some(ScopeType::Agent) => "AGENT",
750 Some(ScopeType::Tool) => "TOOL",
751 Some(ScopeType::Llm) => "LLM",
752 Some(ScopeType::Retriever) => "RETRIEVER",
753 Some(ScopeType::Embedder) => "EMBEDDING",
754 Some(ScopeType::Reranker) => "RERANKER",
755 Some(ScopeType::Guardrail) => "GUARDRAIL",
756 Some(ScopeType::Evaluator) => "EVALUATOR",
757 Some(ScopeType::Function | ScopeType::Custom | ScopeType::Unknown) | None => "CHAIN",
758 }
759}
760
761fn openinference_input_value(event: &Event) -> Option<(String, &'static str)> {
762 let input = event.input()?;
763
764 if event
765 .category()
766 .is_some_and(|category| category.as_str() == "llm")
767 {
768 return llm_input_display_value(input)
769 .map(|display| (display, "text/plain"))
770 .or_else(|| sanitized_llm_input_json(input).map(|json| (json, "application/json")));
771 }
772
773 to_json_string(input).map(|json| (json, "application/json"))
774}
775
776fn openinference_output_value(event: &Event) -> Option<(String, &'static str)> {
777 let output = event.output()?;
778 display_text_from_json(output)
779 .map(|display| (display, "text/plain"))
780 .or_else(|| to_json_string(output).map(|json| (json, "application/json")))
781}
782
783fn llm_input_display_value(input: &Json) -> Option<String> {
784 let content = match input {
785 Json::Object(object) => object.get("content").unwrap_or(input),
786 _ => input,
787 };
788
789 content
790 .get("messages")
791 .and_then(display_text_from_messages)
792 .or_else(|| display_text_from_json(content))
793}
794
795fn sanitized_llm_input_json(input: &Json) -> Option<String> {
796 match input {
797 Json::Object(object) => {
798 let mut sanitized = object.clone();
799 sanitized.remove("headers");
800 to_json_string(&Json::Object(sanitized))
801 }
802 _ => to_json_string(input),
803 }
804}
805
806fn display_text_from_json(value: &Json) -> Option<String> {
807 match value {
808 Json::String(text) => display_text_from_string(text),
809 Json::Object(object) => {
810 for key in ["content", "summary", "message", "text", "prompt"] {
811 if let Some(display) = object.get(key).and_then(display_text_from_json) {
812 return Some(display);
813 }
814 }
815 object
816 .get("output")
817 .and_then(display_text_from_openai_responses_output)
818 .or_else(|| {
819 object
820 .get("choices")
821 .and_then(display_text_from_chat_choices)
822 })
823 .or_else(|| {
824 object
825 .get("tool_calls")
826 .and_then(display_text_from_tool_calls)
827 })
828 }
829 Json::Array(items) => display_text_from_content_blocks(items),
830 _ => None,
831 }
832}
833
834fn display_text_from_openai_responses_output(value: &Json) -> Option<String> {
835 let items = value.as_array()?;
836 let mut entries = Vec::new();
837 let mut tool_names = Vec::new();
838 for item in items {
839 let Some(object) = item.as_object() else {
840 continue;
841 };
842 match object.get("type").and_then(Json::as_str) {
843 Some("message") => {
844 if let Some(content) = object
845 .get("content")
846 .and_then(display_text_from_openai_responses_content)
847 {
848 entries.push(content);
849 }
850 }
851 Some("function_call") => {
852 if let Some(name) = object.get("name").and_then(Json::as_str) {
853 tool_names.push(name.to_string());
854 }
855 }
856 _ => {}
857 }
858 }
859 if !tool_names.is_empty() {
860 entries.push(format!("Requested tools: {}", tool_names.join(", ")));
861 }
862 let text = entries.join("\n").trim().to_string();
863 if text.is_empty() { None } else { Some(text) }
864}
865
866fn display_text_from_openai_responses_content(value: &Json) -> Option<String> {
867 let content = value.as_array()?;
868 let text = content
869 .iter()
870 .filter_map(|part| {
871 let object = part.as_object()?;
872 match object.get("type").and_then(Json::as_str) {
873 Some("output_text" | "text") => object.get("text").and_then(Json::as_str),
874 _ => None,
875 }
876 })
877 .collect::<Vec<_>>()
878 .join("\n\n")
879 .trim()
880 .to_string();
881 if text.is_empty() { None } else { Some(text) }
882}
883
884fn display_text_from_messages(value: &Json) -> Option<String> {
885 let messages = value.as_array()?;
886 let text = messages
887 .iter()
888 .filter_map(display_text_from_message)
889 .collect::<Vec<_>>()
890 .join("\n\n")
891 .trim()
892 .to_string();
893 if text.is_empty() { None } else { Some(text) }
894}
895
896fn display_text_from_message(value: &Json) -> Option<String> {
897 let role = value
898 .get("role")
899 .and_then(Json::as_str)
900 .unwrap_or("message");
901 if role == "tool" {
902 return Some("tool: Tool result omitted".to_string());
903 }
904 let display = value
905 .get("content")
906 .and_then(display_text_from_json)
907 .or_else(|| {
908 value
909 .get("tool_calls")
910 .and_then(display_text_from_tool_calls)
911 })?;
912 Some(format!("{role}: {display}"))
913}
914
915fn display_text_from_string(text: &str) -> Option<String> {
916 let trimmed = text.trim();
917 if trimmed.is_empty() {
918 return None;
919 }
920 if let Ok(parsed) = serde_json::from_str::<Json>(trimmed)
921 && let Some(display) = display_text_from_json(&parsed)
922 {
923 return Some(display);
924 }
925 Some(trimmed.to_string())
926}
927
928fn display_text_from_chat_choices(value: &Json) -> Option<String> {
929 let choices = value.as_array()?;
930 for choice in choices {
931 let Some(message) = choice.get("message") else {
932 continue;
933 };
934 let content = message.get("content").and_then(display_text_from_json);
935 let tool_calls = message
936 .get("tool_calls")
937 .and_then(display_text_from_tool_calls);
938 match (content, tool_calls) {
939 (Some(content), Some(tool_calls)) => return Some(format!("{content}\n{tool_calls}")),
940 (Some(content), None) => return Some(content),
941 (None, Some(tool_calls)) => return Some(tool_calls),
942 (None, None) => {}
943 }
944 }
945 None
946}
947
948fn display_text_from_content_blocks(items: &[Json]) -> Option<String> {
949 let mut entries = items
950 .iter()
951 .filter_map(content_block_display_text)
952 .collect::<Vec<_>>();
953 let tool_calls = items.iter().filter_map(tool_call_name).collect::<Vec<_>>();
954 if !tool_calls.is_empty() {
955 entries.push(format!("Requested tools: {}", tool_calls.join(", ")));
956 }
957 let text = entries
958 .into_iter()
959 .filter(|item| !item.trim().is_empty())
960 .collect::<Vec<_>>()
961 .join("\n")
962 .trim()
963 .to_string();
964 if text.is_empty() { None } else { Some(text) }
965}
966
967fn content_block_display_text(item: &Json) -> Option<String> {
968 if let Some(text) = item.as_str() {
969 return Some(text.to_string());
970 }
971 if item.get("stripped").and_then(Json::as_bool) == Some(true) {
972 return None;
973 }
974 if let Some("thinking" | "reasoning" | "toolResult" | "tool_result") =
975 item.get("type").and_then(Json::as_str)
976 {
977 return None;
978 }
979 item.get("text").and_then(Json::as_str).map(str::to_string)
980}
981
982fn display_text_from_tool_calls(value: &Json) -> Option<String> {
983 let calls = value.as_array()?;
984 let names = calls.iter().filter_map(tool_call_name).collect::<Vec<_>>();
985 if names.is_empty() {
986 None
987 } else {
988 Some(format!("Requested tools: {}", names.join(", ")))
989 }
990}
991
992fn tool_call_name(value: &Json) -> Option<String> {
993 value
994 .get("name")
995 .and_then(Json::as_str)
996 .or_else(|| value.get("toolName").and_then(Json::as_str))
997 .or_else(|| {
998 value
999 .get("function")
1000 .and_then(|function| function.get("name"))
1001 .and_then(Json::as_str)
1002 })
1003 .map(str::to_string)
1004}
1005
1006fn to_json_string<T: Serialize>(value: &T) -> Option<String> {
1007 serde_json::to_string(value).ok()
1008}
1009
1010#[cfg(test)]
1011fn local_parent_span_context(span_context: &SpanContext) -> SpanContext {
1012 SpanContext::new(
1013 span_context.trace_id(),
1014 span_context.span_id(),
1015 span_context.trace_flags(),
1016 false,
1017 span_context.trace_state().clone(),
1018 )
1019}
1020
1021#[cfg(test)]
1022fn to_system_time(timestamp: DateTime<Utc>) -> SystemTime {
1023 let seconds = timestamp.timestamp();
1024 let nanos = timestamp.timestamp_subsec_nanos();
1025 if seconds >= 0 {
1026 UNIX_EPOCH + Duration::new(seconds as u64, nanos)
1027 } else if nanos == 0 {
1028 UNIX_EPOCH - Duration::new(seconds.unsigned_abs(), 0)
1029 } else {
1030 UNIX_EPOCH - Duration::new(seconds.unsigned_abs() - 1, 1_000_000_000 - nanos)
1031 }
1032}
1033
1034#[cfg(test)]
1035#[path = "../../tests/unit/observability/openinference_tests.rs"]
1036mod tests;