From 97ef7ed8025629196018087e2ee950755fc7107e Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Wed, 3 Jun 2026 08:52:43 -0700 Subject: [PATCH 1/2] fix: flatten OpenInference LLM attributes for annotations and OpenClaw replay Signed-off-by: mnajafian-nv --- .../core/src/observability/openinference.rs | 342 ++++++++++++++++- .../unit/observability/openinference_tests.rs | 346 +++++++++++++++++- docs/observability-plugin/openinference.mdx | 14 +- 3 files changed, 693 insertions(+), 9 deletions(-) diff --git a/crates/core/src/observability/openinference.rs b/crates/core/src/observability/openinference.rs index cb3264b0..deb1b55c 100644 --- a/crates/core/src/observability/openinference.rs +++ b/crates/core/src/observability/openinference.rs @@ -24,7 +24,10 @@ use crate::api::event::{Event, ScopeCategory}; use crate::api::runtime::EventSubscriberFn; use crate::api::scope::ScopeType; use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; -use crate::codec::response::Usage; +use crate::codec::request::{ + AnnotatedLlmRequest, ContentPart, Message, MessageContent, ToolDefinition, +}; +use crate::codec::response::{AnnotatedLlmResponse, FinishReason, ResponseToolCall, Usage}; use crate::error::FlowError; use crate::json::Json; use chrono::{DateTime, Utc}; @@ -641,6 +644,9 @@ fn scope_type_name(scope_type: Option) -> &'static str { fn start_attributes(event: &Event) -> Vec { let mut attributes = common_attributes(event); + let is_llm = event + .category() + .is_some_and(|category| category.as_str() == "llm"); let handle_attributes = event.attributes(); if handle_attributes.is_some_and(|attributes| !attributes.is_empty()) { push_serialized( @@ -682,6 +688,9 @@ fn start_attributes(event: &Event) -> Vec { attributes.push(KeyValue::new(oi::tool_call::function::ARGUMENTS, input)); } } + if is_llm { + push_llm_request_attributes(&mut attributes, event); + } attributes } @@ -735,9 +744,340 @@ fn end_attributes(event: &Event) -> Vec { if is_llm && let Some(cost_total) = cost_total_from_manual_llm_output(event.output()) { attributes.push(KeyValue::new(oi::llm::cost::TOTAL, cost_total)); } + if is_llm { + push_llm_response_attributes(&mut attributes, event); + } attributes } +fn push_llm_request_attributes(attributes: &mut Vec, event: &Event) { + if let Some(request) = event.annotated_request() { + push_annotated_request_attributes(attributes, request); + return; + } + + let Some(input) = event.input().and_then(replay_llm_payload) else { + return; + }; + if let Some(provider) = input.get("provider").and_then(Json::as_str) { + attributes.push(KeyValue::new(oi::llm::PROVIDER, provider.to_string())); + } + if let Some(system) = input.get("systemPrompt").and_then(display_text_from_json) { + attributes.push(KeyValue::new(oi::llm::SYSTEM, system)); + } + push_replay_input_messages(attributes, input); +} + +fn push_llm_response_attributes(attributes: &mut Vec, event: &Event) { + if let Some(response) = event.annotated_response() { + push_annotated_response_attributes(attributes, response); + return; + } + + let Some(output) = event.output().and_then(replay_llm_response) else { + return; + }; + push_replay_response_attributes(attributes, output); +} + +fn push_annotated_request_attributes( + attributes: &mut Vec, + request: &AnnotatedLlmRequest, +) { + if let Some(system) = request.system_prompt() { + attributes.push(KeyValue::new(oi::llm::SYSTEM, system.to_string())); + } + if let Some(params) = request.params.as_ref().and_then(to_json_string) { + attributes.push(KeyValue::new(oi::llm::INVOCATION_PARAMETERS, params)); + } + push_annotated_input_messages(attributes, &request.messages); + if let Some(tools) = request.tools.as_deref() { + push_annotated_tools(attributes, tools); + } +} + +fn push_annotated_response_attributes( + attributes: &mut Vec, + response: &AnnotatedLlmResponse, +) { + if let Some(reason) = response.finish_reason.as_ref() { + attributes.push(KeyValue::new( + "llm.finish_reason", + finish_reason_value(reason), + )); + } + + let has_message = response.message.is_some() + || response + .tool_calls + .as_ref() + .is_some_and(|tool_calls| !tool_calls.is_empty()); + if has_message { + attributes.push(KeyValue::new( + "llm.output_messages.0.message.role", + "assistant", + )); + } + if let Some(content) = response.message.as_ref().and_then(message_content_text) { + attributes.push(KeyValue::new( + "llm.output_messages.0.message.content", + content, + )); + } + if let Some(tool_calls) = response.tool_calls.as_deref() { + push_response_tool_calls(attributes, 0, tool_calls); + } +} + +fn push_annotated_input_messages(attributes: &mut Vec, messages: &[Message]) { + for (index, message) in messages.iter().enumerate() { + let (role, content) = match message { + Message::System { content, .. } => ("system", Some(content)), + Message::User { content, .. } => ("user", Some(content)), + Message::Assistant { content, .. } => ("assistant", content.as_ref()), + Message::Tool { content, .. } => ("tool", Some(content)), + }; + push_message_role(attributes, "llm.input_messages", index, role); + if let Some(content) = content { + push_message_text_content(attributes, "llm.input_messages", index, content); + } + } +} + +fn push_annotated_tools(attributes: &mut Vec, tools: &[ToolDefinition]) { + for (index, tool) in tools.iter().enumerate() { + if let Some(json) = to_json_string(tool) { + attributes.push(KeyValue::new( + format!("llm.tools.{index}.tool.json_schema"), + json, + )); + } + } +} + +fn push_response_tool_calls( + attributes: &mut Vec, + message_index: usize, + tool_calls: &[ResponseToolCall], +) { + for (call_index, tool_call) in tool_calls.iter().enumerate() { + push_output_tool_call( + attributes, + message_index, + call_index, + Some(tool_call.id.as_str()), + Some(tool_call.name.as_str()), + to_json_string(&tool_call.arguments), + ); + } +} + +fn push_message_role( + attributes: &mut Vec, + prefix: &'static str, + index: usize, + role: &str, +) { + attributes.push(KeyValue::new( + format!("{prefix}.{index}.message.role"), + role.to_string(), + )); +} + +fn push_message_text_content( + attributes: &mut Vec, + prefix: &'static str, + index: usize, + content: &MessageContent, +) { + if let Some(text) = message_content_text(content) { + attributes.push(KeyValue::new( + format!("{prefix}.{index}.message.content"), + text, + )); + } +} + +fn message_content_text(content: &MessageContent) -> Option { + match content { + MessageContent::Text(text) => display_text_from_string(text), + MessageContent::Parts(parts) => { + let text = parts + .iter() + .filter_map(|part| match part { + ContentPart::Text { text } => Some(text.as_str()), + ContentPart::ImageUrl { .. } => None, + }) + .collect::>() + .join("\n") + .trim() + .to_string(); + if text.is_empty() { None } else { Some(text) } + } + } +} + +fn replay_llm_payload(input: &Json) -> Option<&Json> { + let content = input.as_object().and_then(|object| object.get("content"))?; + let content_object = content.as_object()?; + is_openclaw_replay_payload(content_object).then_some(content) +} + +fn replay_llm_response(output: &Json) -> Option<&Json> { + output + .as_object() + .and_then(|object| object.get("openclaw")) + .and_then(Json::as_object) + .map(|_| output) +} + +fn is_openclaw_replay_payload(content: &serde_json::Map) -> bool { + content + .get("source") + .and_then(Json::as_str) + .is_some_and(|source| source.starts_with("openclaw.")) + || content.contains_key("placeholderRequest") +} + +fn push_replay_input_messages(attributes: &mut Vec, input: &Json) { + if let Some(messages) = input.get("messages").and_then(Json::as_array) { + for (index, message) in messages.iter().enumerate() { + push_replay_input_message(attributes, index, message); + } + return; + } + if let Some(prompt) = input.get("prompt").and_then(display_text_from_json) { + push_message_role(attributes, "llm.input_messages", 0, "user"); + attributes.push(KeyValue::new( + "llm.input_messages.0.message.content", + prompt, + )); + } +} + +fn push_replay_input_message(attributes: &mut Vec, index: usize, message: &Json) { + let Some(object) = message.as_object() else { + return; + }; + if !object.contains_key("role") && !object.contains_key("content") { + return; + } + let role = object.get("role").and_then(Json::as_str).unwrap_or("user"); + push_message_role(attributes, "llm.input_messages", index, role); + if let Some(text) = object.get("content").and_then(display_text_from_json) { + attributes.push(KeyValue::new( + format!("llm.input_messages.{index}.message.content"), + text, + )); + } +} + +fn push_replay_response_attributes(attributes: &mut Vec, output: &Json) { + if output.get("role").is_none() + && output.get("content").is_none() + && output.get("tool_calls").is_none() + { + return; + } + let role = output + .get("role") + .and_then(Json::as_str) + .unwrap_or("assistant"); + push_message_role(attributes, "llm.output_messages", 0, role); + if let Some(content) = output.get("content").and_then(display_text_from_json) { + attributes.push(KeyValue::new( + "llm.output_messages.0.message.content", + content, + )); + } + if let Some(tool_calls) = output.get("tool_calls").and_then(Json::as_array) { + push_raw_output_tool_calls(attributes, 0, tool_calls); + } +} + +fn push_raw_output_tool_calls( + attributes: &mut Vec, + message_index: usize, + tool_calls: &[Json], +) { + for (call_index, tool_call) in tool_calls.iter().enumerate() { + push_output_tool_call( + attributes, + message_index, + call_index, + tool_call.get("id").and_then(Json::as_str), + raw_tool_call_name(tool_call), + raw_tool_call_arguments(tool_call).and_then(|value| { + value + .as_str() + .map(str::to_string) + .or_else(|| to_json_string(value)) + }), + ); + } +} + +fn raw_tool_call_name(tool_call: &Json) -> Option<&str> { + tool_call + .get("function") + .and_then(|function| function.get("name")) + .and_then(Json::as_str) + .or_else(|| tool_call.get("name").and_then(Json::as_str)) + .or_else(|| tool_call.get("toolName").and_then(Json::as_str)) +} + +fn raw_tool_call_arguments(tool_call: &Json) -> Option<&Json> { + tool_call + .get("function") + .and_then(|function| function.get("arguments")) + .or_else(|| tool_call.get("arguments")) + .or_else(|| tool_call.get("input")) +} + +fn push_output_tool_call( + attributes: &mut Vec, + message_index: usize, + call_index: usize, + id: Option<&str>, + name: Option<&str>, + arguments: Option, +) { + if let Some(id) = id { + attributes.push(KeyValue::new( + format!( + "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.id" + ), + id.to_string(), + )); + } + if let Some(name) = name { + attributes.push(KeyValue::new( + format!( + "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.name" + ), + name.to_string(), + )); + } + if let Some(arguments) = arguments { + attributes.push(KeyValue::new( + format!( + "llm.output_messages.{message_index}.message.tool_calls.{call_index}.tool_call.function.arguments" + ), + arguments, + )); + } +} + +fn finish_reason_value(reason: &FinishReason) -> String { + match reason { + FinishReason::Complete => "complete".to_string(), + FinishReason::Length => "length".to_string(), + FinishReason::ToolUse => "tool_use".to_string(), + FinishReason::ContentFilter => "content_filter".to_string(), + FinishReason::Unknown(reason) => reason.clone(), + } +} + fn cost_total_from_manual_llm_output(output: Option<&Json>) -> Option { let object = output?.as_object()?; let usage = object.get("usage").and_then(Json::as_object); diff --git a/crates/core/tests/unit/observability/openinference_tests.rs b/crates/core/tests/unit/observability/openinference_tests.rs index 129bae4f..603910dc 100644 --- a/crates/core/tests/unit/observability/openinference_tests.rs +++ b/crates/core/tests/unit/observability/openinference_tests.rs @@ -13,7 +13,11 @@ use crate::api::runtime::global_context; use crate::api::scope::ScopeType; use crate::api::scope::{event, pop_scope, push_scope}; use crate::api::tool::ToolAttributes; -use crate::codec::response::{AnnotatedLlmResponse, Usage}; +use crate::codec::request::{ + AnnotatedLlmRequest, FunctionDefinition, GenerationParams, Message, MessageContent, + ToolDefinition, +}; +use crate::codec::response::{AnnotatedLlmResponse, FinishReason, ResponseToolCall, Usage}; use crate::json::Json; use crate::observability::atif::{AtifAgentInfo, AtifExporter, AtifStepExtra}; use opentelemetry_sdk::trace::InMemorySpanExporterBuilder; @@ -54,6 +58,113 @@ fn attr_map(attributes: &[KeyValue]) -> HashMap { .collect() } +fn assert_attr(attributes: &HashMap, key: &str, value: &str) { + assert_eq!(attributes.get(key).map(String::as_str), Some(value)); +} + +fn assert_attr_contains(attributes: &HashMap, key: &str, expected: &str) { + let value = attributes + .get(key) + .unwrap_or_else(|| panic!("missing attribute {key}")); + assert!( + value.contains(expected), + "attribute {key} value {value:?} did not contain {expected:?}" + ); +} + +fn assert_no_attr_contains(attributes: &HashMap, expected: &str) { + assert!( + !attributes + .iter() + .any(|(key, value)| key.contains(expected) || value.contains(expected)), + "attribute map unexpectedly contained {expected:?}" + ); +} + +fn empty_annotated_request() -> AnnotatedLlmRequest { + AnnotatedLlmRequest { + messages: Vec::new(), + model: None, + params: None, + tools: None, + tool_choice: None, + store: None, + previous_response_id: None, + truncation: None, + reasoning: None, + include: None, + user: None, + metadata: None, + service_tier: None, + parallel_tool_calls: None, + max_output_tokens: None, + max_tool_calls: None, + top_logprobs: None, + stream: None, + extra: serde_json::Map::new(), + } +} + +fn empty_annotated_response() -> AnnotatedLlmResponse { + AnnotatedLlmResponse { + id: None, + model: None, + message: None, + tool_calls: None, + finish_reason: None, + usage: None, + api_specific: None, + extra: serde_json::Map::new(), + } +} + +fn sample_openinference_annotated_request() -> AnnotatedLlmRequest { + AnnotatedLlmRequest { + messages: vec![ + Message::System { + content: MessageContent::Text("Use concise answers.".to_string()), + name: None, + }, + Message::User { + content: MessageContent::Text("Search docs.".to_string()), + name: None, + }, + ], + model: Some("gpt-4o".to_string()), + params: Some(GenerationParams { + temperature: Some(0.2), + max_tokens: Some(128), + top_p: None, + stop: None, + }), + tools: Some(vec![ToolDefinition { + tool_type: "function".to_string(), + function: FunctionDefinition { + name: "search_docs".to_string(), + description: Some("Search the docs corpus.".to_string()), + parameters: Some(json!({ + "type": "object", + "properties": {"query": {"type": "string"}} + })), + }, + }]), + ..empty_annotated_request() + } +} + +fn sample_openinference_annotated_response() -> AnnotatedLlmResponse { + AnnotatedLlmResponse { + message: Some(MessageContent::Text("I will search docs.".to_string())), + tool_calls: Some(vec![ResponseToolCall { + id: "call-search-docs".to_string(), + name: "search_docs".to_string(), + arguments: json!({"query": "docs"}), + }]), + finish_reason: Some(FinishReason::ToolUse), + ..empty_annotated_response() + } +} + fn make_start_event( uuid: Uuid, parent_uuid: Option, @@ -547,14 +658,151 @@ fn llm_input_value_omits_request_headers() { let spans = exporter.get_finished_spans().unwrap(); assert_eq!(spans.len(), 1); let attributes = attr_map(&spans[0].attributes); - assert_eq!(attributes.get("input.value"), Some(&"user: hi".to_string())); - assert_eq!( - attributes.get("input.mime_type"), - Some(&"text/plain".to_string()) - ); + assert_attr(&attributes, "input.value", "user: hi"); + assert_attr(&attributes, "input.mime_type", "text/plain"); assert!(!attributes.contains_key("nemo_relay.start.input_json")); assert!(!attributes["input.value"].contains("authorization")); assert!(!attributes["input.value"].contains("secret-token")); + assert!(!attributes.contains_key("llm.input_messages.0.message.role")); + assert_no_attr_contains(&attributes, "headers"); + assert_no_attr_contains(&attributes, "secret-token"); +} + +#[test] +fn openclaw_replay_payloads_emit_flattened_openinference_llm_attributes() { + let (provider, exporter) = make_provider(); + let mut processor = + OpenInferenceEventProcessor::new(provider.clone(), "test-scope".to_string()); + let uuid = Uuid::now_v7(); + + processor.process(&make_start_event( + uuid, + None, + "openclaw-model-call", + ScopeType::Llm, + Some(json!({ + "headers": {"authorization": "Bearer secret-token"}, + "content": { + "provider": "nvidia-inference", + "model": "claude-sonnet-4", + "systemPrompt": "Use reliable sources.", + "prompt": "Find the answer.", + "messages": [ + {"role": "user", "content": "Find the answer."} + ], + "placeholderRequest": false, + "source": "openclaw.llm_output" + } + })), + )); + processor.process(&make_end_event( + uuid, + None, + "openclaw-model-call", + ScopeType::Llm, + Some(json!({ + "role": "assistant", + "content": "I will search.", + "tool_calls": [{ + "id": "toolu_search", + "name": "tavily_search", + "input": {"query": "answer"} + }], + "openclaw": { + "duration_ms": 42, + "assistant_tool_call_names": ["tavily_search"] + } + })), + )); + + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + let attributes = attr_map(&spans[0].attributes); + assert_attr(&attributes, "llm.provider", "nvidia-inference"); + assert_attr(&attributes, "llm.system", "Use reliable sources."); + assert_attr(&attributes, "llm.input_messages.0.message.role", "user"); + assert_attr( + &attributes, + "llm.input_messages.0.message.content", + "Find the answer.", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.role", + "assistant", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.content", + "I will search.", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.id", + "toolu_search", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.function.name", + "tavily_search", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments", + "{\"query\":\"answer\"}", + ); + assert!(!attributes.contains_key("llm.invocation_parameters")); + assert!(!attributes.contains_key("llm.finish_reason")); + assert_no_attr_contains(&attributes, "headers"); + assert_no_attr_contains(&attributes, "secret-token"); +} + +#[test] +fn generic_unannotated_llm_output_does_not_emit_flattened_output_message_attrs() { + let (provider, exporter) = make_provider(); + let mut processor = + OpenInferenceEventProcessor::new(provider.clone(), "test-scope".to_string()); + let uuid = Uuid::now_v7(); + + processor.process(&make_start_event( + uuid, + None, + "generic-model-call", + ScopeType::Llm, + Some(json!({ + "headers": {}, + "content": {"messages": [{"role": "user", "content": "hi"}], "model": "demo-model"} + })), + )); + processor.process(&make_end_event( + uuid, + None, + "generic-model-call", + ScopeType::Llm, + Some(json!({ + "role": "assistant", + "content": "hello", + "tool_calls": [{ + "id": "tool-call-1", + "name": "demo_tool", + "input": {"query": "hi"} + }] + })), + )); + + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + let attributes = attr_map(&spans[0].attributes); + assert!(!attributes.contains_key("llm.output_messages.0.message.role")); + assert!(!attributes.contains_key("llm.output_messages.0.message.content")); + assert!( + !attributes + .contains_key("llm.output_messages.0.message.tool_calls.0.tool_call.function.name") + ); } #[test] @@ -1444,6 +1692,7 @@ fn llm_end_with_usage_emits_token_count_attributes() { attributes.get("llm.token_count.prompt_details.cache_write"), Some(&"10".to_string()) ); + assert!(!attributes.contains_key("llm.output_messages.0.message.role")); } #[test] @@ -1605,6 +1854,91 @@ fn anthropic_messages_output_emits_openinference_text_tool_and_usage_attributes( ); } +#[test] +fn annotated_llm_payloads_emit_flattened_openinference_message_and_tool_attributes() { + let (provider, exporter) = make_provider(); + let mut processor = + OpenInferenceEventProcessor::new(provider.clone(), "test-scope".to_string()); + let uuid = Uuid::now_v7(); + + processor.process(&make_scope_event_with_profile( + ScopeCategory::Start, + uuid, + None, + "annotated-chat", + ScopeType::Llm, + None, + Some( + CategoryProfile::builder() + .annotated_request(Arc::new(sample_openinference_annotated_request())) + .build(), + ), + )); + processor.process(&make_scope_event_with_profile( + ScopeCategory::End, + uuid, + None, + "annotated-chat", + ScopeType::Llm, + None, + Some( + CategoryProfile::builder() + .annotated_response(Arc::new(sample_openinference_annotated_response())) + .build(), + ), + )); + + processor.force_flush().unwrap(); + + let spans = exporter.get_finished_spans().unwrap(); + assert_eq!(spans.len(), 1); + let attributes = attr_map(&spans[0].attributes); + assert_attr(&attributes, "llm.system", "Use concise answers."); + assert_attr(&attributes, "llm.input_messages.0.message.role", "system"); + assert_attr(&attributes, "llm.input_messages.1.message.role", "user"); + assert_attr( + &attributes, + "llm.input_messages.1.message.content", + "Search docs.", + ); + assert_attr_contains( + &attributes, + "llm.invocation_parameters", + "\"temperature\":0.2", + ); + assert_attr_contains( + &attributes, + "llm.tools.0.tool.json_schema", + "\"name\":\"search_docs\"", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.role", + "assistant", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.content", + "I will search docs.", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.id", + "call-search-docs", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.function.name", + "search_docs", + ); + assert_attr( + &attributes, + "llm.output_messages.0.message.tool_calls.0.tool_call.function.arguments", + "{\"query\":\"docs\"}", + ); + assert_attr(&attributes, "llm.finish_reason", "tool_use"); +} + #[test] fn llm_end_with_inconsistent_manual_usage_omits_invalid_total_tokens() { let (provider, exporter) = make_provider(); diff --git a/docs/observability-plugin/openinference.mdx b/docs/observability-plugin/openinference.mdx index 7d72b46a..897d3f3e 100644 --- a/docs/observability-plugin/openinference.mdx +++ b/docs/observability-plugin/openinference.mdx @@ -13,7 +13,11 @@ exported as OTLP trace spans with OpenInference-oriented semantics. OpenInference export maps model-centric payloads directly into trace attributes. Scope, tool, and LLM start inputs become `input.value`; end outputs become `output.value`; LLM usage metadata maps to token-count attributes when -the provider response includes usage information. +the provider response includes usage information. For LLM spans, NeMo Relay +emits flattened request and response message attributes from typed codec +annotations and OpenClaw hook replay request/response payloads. Typed codec +annotations also provide tool schema, finish-reason, and invocation-parameter +attributes when those fields are available. ## `plugins.toml` Example @@ -69,7 +73,13 @@ OpenInference uses the same OTLP section shape as The backend should show OpenInference-oriented spans for scopes, tools, and LLM calls grouped by root scope. LLM usage metadata appears as token counters when -provider responses include usage information. +provider responses include usage information. LLM request and response +messages, system prompts, and model-emitted tool calls are emitted as +flattened OpenInference attributes when available from codec annotations or +OpenClaw hook replay request/response payloads. Tool schemas, finish reasons, +and invocation parameters are emitted when typed codec annotations supply +them. Exported LLM attributes exclude request headers and other non-observable +transport metadata. Each lifecycle span includes `nemo_relay.uuid` and `nemo_relay.parent_uuid` attributes. These values match ATIF `step.extra.ancestry.function_id` and From 52411dbd2c59649aafd622ba5d7a200707817dc7 Mon Sep 17 00:00:00 2001 From: mnajafian-nv Date: Wed, 3 Jun 2026 10:51:27 -0700 Subject: [PATCH 2/2] docs: generalize replay wording in OpenInference docs Signed-off-by: mnajafian-nv --- docs/observability-plugin/openinference.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/observability-plugin/openinference.mdx b/docs/observability-plugin/openinference.mdx index 897d3f3e..3bad1487 100644 --- a/docs/observability-plugin/openinference.mdx +++ b/docs/observability-plugin/openinference.mdx @@ -15,7 +15,7 @@ attributes. Scope, tool, and LLM start inputs become `input.value`; end outputs become `output.value`; LLM usage metadata maps to token-count attributes when the provider response includes usage information. For LLM spans, NeMo Relay emits flattened request and response message attributes from typed codec -annotations and OpenClaw hook replay request/response payloads. Typed codec +annotations and supported replay request/response payloads. Typed codec annotations also provide tool schema, finish-reason, and invocation-parameter attributes when those fields are available. @@ -76,7 +76,7 @@ calls grouped by root scope. LLM usage metadata appears as token counters when provider responses include usage information. LLM request and response messages, system prompts, and model-emitted tool calls are emitted as flattened OpenInference attributes when available from codec annotations or -OpenClaw hook replay request/response payloads. Tool schemas, finish reasons, +supported replay request/response payloads. Tool schemas, finish reasons, and invocation parameters are emitted when typed codec annotations supply them. Exported LLM attributes exclude request headers and other non-observable transport metadata.