Skip to main content

nemo_relay/observability/
openinference.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenInference subscriber support for NeMo Relay.
5//!
6//! Projection functions used by the unified OpenTelemetry subscriber.
7
8use 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        // Final span metadata should reflect the completed event, especially for mixed-fidelity
72        // Hermes flows where the request can be exact but the terminal error is lossy.
73        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    // Combine codec-normalized usage (which carries provider-derived fields such
139    // as Anthropic's computed total) with the manual scraper, preferring codec
140    // values per field so neither source's coverage is lost.
141    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    // Match replay before codec detection: replay content can look
201    // provider-shaped (carry `messages`) and would otherwise be misrouted.
202    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    // Reuse the response decoded once in `end_attributes` (annotation-first;
231    // falls through to codec detection) instead of decoding the payload again.
232    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
532// Raw replay payloads are an OpenInference-local fallback. Provider-shaped
533// responses should use codec-normalized response tool calls instead.
534fn 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;