feat(otel): emit GenAI semantic convention attributes (#10700)

This commit is contained in:
tlysanhuo
2026-07-30 08:08:41 +08:00
committed by GitHub
parent 7f1666abb8
commit dd34084726
5 changed files with 598 additions and 4 deletions
+137 -1
View File
@@ -11,6 +11,7 @@ use tracing_futures::Instrument;
use super::container::Container;
use super::final_output_tool::FinalOutputTool;
use super::gen_ai_telemetry;
use super::mcp_client::GooseMcpHostInfo;
use super::platform_tools;
use super::tool_confirmation_router::ToolConfirmationRouter;
@@ -644,10 +645,21 @@ impl Agent {
.as_ref()
.map(|a| serde_json::Value::Object(a.clone()));
let category = categorize_tool(&tool_name);
let span = tracing::Span::current();
let capture_message_content = gen_ai_telemetry::capture_message_content();
let fut = async move {
let processed_result =
super::large_response_handler::process_tool_response(result.result.await);
if capture_message_content {
let output = gen_ai_telemetry::tool_result_json(&processed_result);
span.record("output", output.as_str());
if let Some(result) =
gen_ai_telemetry::successful_tool_result_json(&processed_result)
{
span.record("gen_ai.tool.call.result", result.as_str());
}
}
let event = match &processed_result {
Ok(call_result) if call_result.is_error != Some(true) => {
crate::hooks::HookEvent::PostToolUse
@@ -1098,7 +1110,20 @@ impl Agent {
}
/// Dispatch a single tool call to the appropriate client
#[instrument(skip(self, tool_call, request_id, cancellation_token, session), fields(input, output, session.id = %session.id))]
#[instrument(
skip(self, tool_call, request_id, cancellation_token, session),
fields(
input,
output,
session.id = %session.id,
gen_ai.conversation.id = %session.id,
gen_ai.operation.name = "execute_tool",
gen_ai.tool.name = %tool_call.name,
gen_ai.tool.call.id = %request_id,
gen_ai.tool.call.arguments = tracing::field::Empty,
gen_ai.tool.call.result = tracing::field::Empty,
)
)]
pub async fn dispatch_tool_call(
&self,
tool_call: CallToolRequestParams,
@@ -1111,6 +1136,17 @@ impl Agent {
"arguments": tool_call.arguments,
});
tracing::Span::current().record("input", tracing::field::display(&input_summary));
if gen_ai_telemetry::capture_message_content() {
let arguments = tool_call
.arguments
.as_ref()
.map(|arguments| Value::Object(arguments.clone()))
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
tracing::Span::current().record(
"gen_ai.tool.call.arguments",
tracing::field::display(arguments),
);
}
self.prompt_manager
.lock()
@@ -3618,6 +3654,7 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::gen_ai_telemetry::{self, test_support::SpanFieldCapture};
use crate::permission::permission_confirmation::PrincipalType;
use crate::plugins::discovery::{DiscoveredPlugin, PluginScope};
use crate::providers::base::{stream_from_single_message, MessageStream, PermissionRouting};
@@ -3629,6 +3666,105 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir;
async fn tracing_test_agent_and_session() -> (Agent, Session, TempDir) {
let data_dir = TempDir::new().unwrap();
let data_path = data_dir.path().to_path_buf();
let session_manager = Arc::new(SessionManager::new(data_path.clone()));
let agent = Agent::with_config(AgentConfig::new(
Arc::clone(&session_manager),
Arc::new(PermissionManager::new(data_path)),
None,
GooseMode::default(),
false,
GoosePlatform::GooseCli,
));
let session = session_manager
.create_session(
std::env::current_dir().unwrap(),
"otel-tool-span".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await
.unwrap();
(agent, session, data_dir)
}
#[tokio::test]
async fn tool_dispatch_records_gen_ai_span_attributes() {
use goose_test_support::otel::clear_otel_env;
use rmcp::object;
let _env = clear_otel_env(&[(gen_ai_telemetry::CAPTURE_MESSAGE_CONTENT_ENV, "true")]);
let capture = SpanFieldCapture::new("dispatch_tool_call");
let _subscriber = capture.clone().set_default();
let (agent, session, _data_dir) = tracing_test_agent_and_session().await;
let tool_call = CallToolRequestParams::new(PLATFORM_MANAGE_SCHEDULE_TOOL_NAME)
.with_arguments(object!({ "action": "list" }));
let (request_id, result) = agent
.dispatch_tool_call(
tool_call,
"call-42".to_string(),
Some(CancellationToken::new()),
&session,
)
.await;
assert_eq!(request_id, "call-42");
let result = result.unwrap();
assert!(result.result.await.is_err());
let fields = capture.fields();
assert_eq!(fields["gen_ai.operation.name"], "execute_tool");
assert_eq!(
fields["gen_ai.tool.name"],
PLATFORM_MANAGE_SCHEDULE_TOOL_NAME
);
assert_eq!(fields["gen_ai.tool.call.id"], "call-42");
assert_eq!(fields["gen_ai.conversation.id"], session.id);
let arguments: Value =
serde_json::from_str(fields["gen_ai.tool.call.arguments"].as_str().unwrap()).unwrap();
assert_eq!(arguments["action"], "list");
let output: Value = serde_json::from_str(fields["output"].as_str().unwrap()).unwrap();
assert_eq!(output["status"], "error");
assert!(!fields.contains_key("gen_ai.tool.call.result"));
}
#[tokio::test]
async fn successful_tool_result_is_recorded_after_execution() {
use goose_test_support::otel::clear_otel_env;
use rmcp::model::ContentBlock;
let _env = clear_otel_env(&[(gen_ai_telemetry::CAPTURE_MESSAGE_CONTENT_ENV, "true")]);
let capture = SpanFieldCapture::new("successful_tool");
let _subscriber = capture.clone().set_default();
let (agent, session, _data_dir) = tracing_test_agent_and_session().await;
let tool_call = CallToolRequestParams::new("test_tool");
let span = tracing::info_span!(
"successful_tool",
output = tracing::field::Empty,
gen_ai.tool.call.result = tracing::field::Empty,
);
let entered = span.enter();
let result = agent.with_post_tool_hook(
ToolCallResult::from(Ok(CallToolResult::success(vec![ContentBlock::text(
"done",
)]))),
&tool_call,
&session,
);
drop(entered);
drop(span);
assert!(result.result.await.is_ok());
let fields = capture.fields();
let result: Value =
serde_json::from_str(fields["gen_ai.tool.call.result"].as_str().unwrap()).unwrap();
assert_eq!(result["content"][0]["text"], "done");
let output: Value = serde_json::from_str(fields["output"].as_str().unwrap()).unwrap();
assert_eq!(output["status"], "success");
}
#[test]
fn ensure_message_event_id_assigns_missing_ids_and_preserves_existing_ids() {
let generated =
+328
View File
@@ -0,0 +1,328 @@
use crate::conversation::message::{Message, MessageContent, ToolResult};
use goose_providers::conversation::token_usage::ProviderUsage;
use rmcp::model::{CallToolRequestParams, CallToolResult, Role};
use serde_json::{json, Value};
use tracing::Span;
pub(super) const CAPTURE_MESSAGE_CONTENT_ENV: &str =
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT";
pub(super) fn capture_message_content() -> bool {
std::env::var(CAPTURE_MESSAGE_CONTENT_ENV).is_ok_and(|value| value.eq_ignore_ascii_case("true"))
}
pub(super) fn input_messages_json(messages: &[Message]) -> String {
Value::Array(messages.iter().map(message_json).collect()).to_string()
}
pub(super) fn output_message_json(message: &Message) -> String {
// Message does not retain provider finish reasons; tool requests are the only
// distinct completion signal available after streaming.
let finish_reason = if message.content.iter().any(|content| {
matches!(
content,
MessageContent::ToolRequest(_) | MessageContent::FrontendToolRequest(_)
)
}) {
"tool_call"
} else {
"stop"
};
let mut value = message_json(message);
value["finish_reason"] = Value::String(finish_reason.to_string());
Value::Array(vec![value]).to_string()
}
pub(super) fn append_message(accumulated: &mut Option<Message>, message: &Message) {
match accumulated {
Some(accumulated) => accumulated.content.extend(message.content.iter().cloned()),
None => *accumulated = Some(message.clone()),
}
}
pub(super) fn record_provider_usage(span: &Span, usage: &ProviderUsage) {
span.record("gen_ai.response.model", usage.model.as_str());
if let Some(tokens) = usage.usage.input_tokens {
span.record("gen_ai.usage.input_tokens", tokens);
}
if let Some(tokens) = usage.usage.output_tokens {
span.record("gen_ai.usage.output_tokens", tokens);
}
if let Some(tokens) = usage.usage.cache_read_input_tokens {
span.record("gen_ai.usage.cache_read.input_tokens", tokens);
}
if let Some(tokens) = usage.usage.cache_write_input_tokens {
span.record("gen_ai.usage.cache_creation.input_tokens", tokens);
}
}
pub(super) fn tool_result_json(result: &ToolResult<CallToolResult>) -> String {
match result {
Ok(result) if result.is_error != Some(true) => json!({
"status": "success",
"value": result,
}),
Ok(result) => json!({
"status": "error",
"value": result,
}),
Err(error) => json!({
"status": "error",
"error": error.to_string(),
}),
}
.to_string()
}
pub(super) fn successful_tool_result_json(result: &ToolResult<CallToolResult>) -> Option<String> {
match result {
Ok(result) if result.is_error != Some(true) => {
Some(serde_json::to_string(result).expect("CallToolResult must serialize"))
}
_ => None,
}
}
fn message_json(message: &Message) -> Value {
let role = if !message.content.is_empty()
&& message
.content
.iter()
.all(|content| matches!(content, MessageContent::ToolResponse(_)))
{
"tool"
} else {
match message.role {
Role::User => "user",
Role::Assistant => "assistant",
}
};
let parts: Vec<Value> = message.content.iter().map(message_part_json).collect();
json!({
"role": role,
"parts": parts,
})
}
fn tool_call_part(id: &str, tool_call: &ToolResult<CallToolRequestParams>) -> Value {
match tool_call {
Ok(tool_call) => json!({
"type": "tool_call",
"id": id,
"name": tool_call.name,
"arguments": tool_call
.arguments
.as_ref()
.map(|arguments| Value::Object(arguments.clone()))
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
}),
Err(error) => json!({
"type": "tool_call_error",
"id": id,
"error": error.to_string(),
}),
}
}
fn message_part_json(content: &MessageContent) -> Value {
match content {
MessageContent::Text(text) => json!({
"type": "text",
"content": text.text,
}),
MessageContent::Image(image) => json!({
"type": "blob",
"modality": "image",
"mime_type": image.mime_type,
"content": image.data,
}),
MessageContent::ToolRequest(request) => tool_call_part(&request.id, &request.tool_call),
MessageContent::ToolResponse(response) => json!({
"type": "tool_call_response",
"id": response.id,
"response": match &response.tool_result {
Ok(result) => serde_json::to_value(result)
.expect("CallToolResult must serialize"),
Err(error) => json!({ "error": error.to_string() }),
},
}),
MessageContent::FrontendToolRequest(request) => {
tool_call_part(&request.id, &request.tool_call)
}
MessageContent::Thinking(thinking) => json!({
"type": "reasoning",
"content": thinking.thinking,
}),
MessageContent::RedactedThinking(_) => json!({
"type": "redacted_reasoning",
}),
MessageContent::ToolConfirmationRequest(request) => json!({
"type": "tool_confirmation",
"id": request.id,
"name": request.tool_name,
"arguments": request.arguments,
}),
MessageContent::ActionRequired(action) => json!({
"type": "action_required",
"data": action.data,
}),
MessageContent::SystemNotification(notification) => json!({
"type": "system_notification",
"content": notification.msg,
}),
}
}
#[cfg(test)]
pub(super) mod test_support {
use serde_json::{Map, Number, Value};
use std::fmt;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{subscriber::DefaultGuard, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
#[derive(Clone)]
pub struct SpanFieldCapture {
span_name: &'static str,
fields: Arc<Mutex<Map<String, Value>>>,
}
impl SpanFieldCapture {
pub fn new(span_name: &'static str) -> Self {
Self {
span_name,
fields: Arc::new(Mutex::new(Map::new())),
}
}
pub fn fields(&self) -> Map<String, Value> {
self.fields.lock().unwrap().clone()
}
pub fn set_default(self) -> DefaultGuard {
use tracing_subscriber::prelude::*;
tracing::subscriber::set_default(tracing_subscriber::registry().with(self))
}
}
impl<S> Layer<S> for SpanFieldCapture
where
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: Context<'_, S>) {
if attrs.metadata().name() == self.span_name {
attrs.record(&mut FieldVisitor {
fields: &self.fields,
});
}
}
fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) {
if ctx
.span(id)
.is_some_and(|span| span.metadata().name() == self.span_name)
{
values.record(&mut FieldVisitor {
fields: &self.fields,
});
}
}
}
struct FieldVisitor<'a> {
fields: &'a Mutex<Map<String, Value>>,
}
impl FieldVisitor<'_> {
fn insert(&self, field: &Field, value: Value) {
self.fields
.lock()
.unwrap()
.insert(field.name().to_string(), value);
}
}
impl Visit for FieldVisitor<'_> {
fn record_i64(&mut self, field: &Field, value: i64) {
self.insert(field, Value::Number(value.into()));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.insert(field, Value::Number(value.into()));
}
fn record_f64(&mut self, field: &Field, value: f64) {
if let Some(value) = Number::from_f64(value) {
self.insert(field, Value::Number(value));
}
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.insert(field, Value::Bool(value));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.insert(field, Value::String(value.to_string()));
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.insert(field, Value::String(format!("{value:?}")));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use goose_test_support::otel::clear_otel_env;
use rmcp::{model::CallToolRequestParams, object};
#[test]
fn content_capture_requires_explicit_opt_in() {
let _env = clear_otel_env(&[]);
assert!(!capture_message_content());
drop(_env);
let _env = clear_otel_env(&[(CAPTURE_MESSAGE_CONTENT_ENV, "TrUe")]);
assert!(capture_message_content());
}
#[test]
fn messages_follow_gen_ai_semantic_convention_shape() {
let request = CallToolRequestParams::new("get_weather")
.with_arguments(object!({ "location": "Paris" }));
let messages = vec![
Message::user().with_text("Weather?"),
Message::assistant().with_tool_request("call-1", Ok(request)),
];
let value: Value = serde_json::from_str(&input_messages_json(&messages)).unwrap();
assert_eq!(value[0]["role"], "user");
assert_eq!(value[0]["parts"][0]["type"], "text");
assert_eq!(value[0]["parts"][0]["content"], "Weather?");
assert_eq!(value[1]["parts"][0]["type"], "tool_call");
assert_eq!(value[1]["parts"][0]["name"], "get_weather");
assert_eq!(value[1]["parts"][0]["arguments"]["location"], "Paris");
}
#[test]
fn output_messages_include_finish_reason() {
let message = Message::assistant().with_text("Sunny");
let value: Value = serde_json::from_str(&output_message_json(&message)).unwrap();
assert_eq!(value[0]["role"], "assistant");
assert_eq!(value[0]["finish_reason"], "stop");
assert_eq!(value[0]["parts"][0]["content"], "Sunny");
let request = CallToolRequestParams::new("get_weather");
let message = Message::assistant().with_tool_request("call-1", Ok(request));
let value: Value = serde_json::from_str(&output_message_json(&message)).unwrap();
assert_eq!(value[0]["finish_reason"], "tool_call");
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod extension;
pub mod extension_malware_check;
pub mod extension_manager;
pub mod final_output_tool;
mod gen_ai_telemetry;
mod large_response_handler;
pub mod mcp_client;
pub mod moim;
+120 -3
View File
@@ -9,6 +9,7 @@ use serde_json::{json, Value};
use tracing::debug;
use super::super::agents::Agent;
use super::gen_ai_telemetry;
#[cfg(feature = "code-mode")]
use crate::agents::platform_extensions::code_execution;
use crate::config::{Config, GooseMode};
@@ -295,7 +296,21 @@ impl Agent {
#[tracing::instrument(
skip(provider, model_config, session_id, system_prompt, messages, tools, toolshim_tools),
fields(session.id = %session_id)
fields(
session.id = %session_id,
gen_ai.conversation.id = %session_id,
gen_ai.operation.name = "chat",
gen_ai.provider.name = %provider.get_name(),
gen_ai.request.model = %model_config.model_name,
gen_ai.request.stream = true,
gen_ai.response.model = tracing::field::Empty,
gen_ai.usage.input_tokens = tracing::field::Empty,
gen_ai.usage.output_tokens = tracing::field::Empty,
gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
gen_ai.input.messages = tracing::field::Empty,
gen_ai.output.messages = tracing::field::Empty,
)
)]
pub(crate) async fn stream_response_from_provider(
provider: Arc<dyn Provider>,
@@ -319,6 +334,13 @@ impl Agent {
} else {
filtered_messages
};
let span = tracing::Span::current();
let capture_message_content = gen_ai_telemetry::capture_message_content();
if capture_message_content {
let input_messages =
gen_ai_telemetry::input_messages_json(messages_for_provider.messages());
span.record("gen_ai.input.messages", input_messages.as_str());
}
// Clone owned data to move into the async stream
let system_prompt = system_prompt.to_owned();
@@ -405,11 +427,18 @@ impl Agent {
// The toolshim interpreter call below must not count toward elapsed time.
if let Some(usage) = final_usage.as_mut() {
fill_stream_timing(usage, request_started, first_content_at);
gen_ai_telemetry::record_provider_usage(&span, usage);
}
if let Some(msg) = accumulated_message {
let processed = toolshim_postprocess(msg, &toolshim_tools).await?;
yield (Some(processed.with_generated_id_if_missing()), final_usage);
let processed = toolshim_postprocess(msg, &toolshim_tools)
.await?
.with_generated_id_if_missing();
if capture_message_content {
let output_messages = gen_ai_telemetry::output_message_json(&processed);
span.record("gen_ai.output.messages", output_messages.as_str());
}
yield (Some(processed), final_usage);
} else if final_usage.is_some() {
// Preserve usage-only responses (no message content)
yield (None, final_usage);
@@ -417,6 +446,7 @@ impl Agent {
} else {
let mut first_content_at: Option<std::time::Instant> = None;
let mut active_mergeable_assistant_id: Option<String> = None;
let mut output_message: Option<Message> = None;
while let Some(result) = stream.next().await {
let (message, mut usage) = result?;
@@ -427,6 +457,12 @@ impl Agent {
}
if let Some(usage) = usage.as_mut() {
fill_stream_timing(usage, request_started, first_content_at);
gen_ai_telemetry::record_provider_usage(&span, usage);
}
if capture_message_content {
if let Some(message) = message.as_ref() {
gen_ai_telemetry::append_message(&mut output_message, message);
}
}
let message = message.map(|message| {
@@ -446,6 +482,10 @@ impl Agent {
yield (message, usage);
}
if let Some(output_message) = output_message {
let output_messages = gen_ai_telemetry::output_message_json(&output_message);
span.record("gen_ai.output.messages", output_messages.as_str());
}
}
}))
}
@@ -705,6 +745,7 @@ pub fn is_tool_visible_to_model(tool: &Tool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::gen_ai_telemetry::{self, test_support::SpanFieldCapture};
use crate::agents::{AgentConfig, GoosePlatform};
use crate::config::permission::PermissionLevel;
use crate::config::{GooseMode, PermissionManager};
@@ -741,6 +782,33 @@ mod tests {
}
}
#[derive(Clone)]
struct GenAiTracingProvider;
#[async_trait]
impl Provider for GenAiTracingProvider {
fn get_name(&self) -> &str {
"test-provider"
}
async fn stream(
&self,
_model_config: &ModelConfig,
_system: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let usage = ProviderUsage::new(
"resolved-model".to_string(),
Usage::new(Some(11), Some(7), Some(18)).with_cache_tokens(Some(3), Some(2)),
);
Ok(Box::pin(futures::stream::iter(vec![
Ok((Some(Message::assistant().with_text("hello ")), None)),
Ok((Some(Message::assistant().with_text("world")), Some(usage))),
])))
}
}
#[derive(Clone)]
struct CapturingProvider {
messages: Arc<Mutex<Vec<Message>>>,
@@ -766,6 +834,55 @@ mod tests {
}
}
#[tokio::test]
async fn provider_stream_records_gen_ai_span_attributes() {
use futures::StreamExt;
use goose_test_support::otel::clear_otel_env;
let _env = clear_otel_env(&[(gen_ai_telemetry::CAPTURE_MESSAGE_CONTENT_ENV, "true")]);
let capture = SpanFieldCapture::new("stream_response_from_provider");
let _subscriber = capture.clone().set_default();
let messages = vec![Message::user().with_text("Say hello")];
let mut stream = Agent::stream_response_from_provider(
Arc::new(GenAiTracingProvider),
ModelConfig::new("requested-model"),
"test-session",
"system",
&messages,
&[],
&[],
)
.await
.unwrap();
while let Some(item) = stream.next().await {
item.unwrap();
}
drop(stream);
let fields = capture.fields();
assert_eq!(fields["gen_ai.operation.name"], "chat");
assert_eq!(fields["gen_ai.provider.name"], "test-provider");
assert_eq!(fields["gen_ai.request.model"], "requested-model");
assert_eq!(fields["gen_ai.request.stream"], true);
assert_eq!(fields["gen_ai.conversation.id"], "test-session");
assert_eq!(fields["gen_ai.response.model"], "resolved-model");
assert_eq!(fields["gen_ai.usage.input_tokens"], 11);
assert_eq!(fields["gen_ai.usage.output_tokens"], 7);
assert_eq!(fields["gen_ai.usage.cache_read.input_tokens"], 3);
assert_eq!(fields["gen_ai.usage.cache_creation.input_tokens"], 2);
let input: Value =
serde_json::from_str(fields["gen_ai.input.messages"].as_str().unwrap()).unwrap();
assert_eq!(input[0]["parts"][0]["content"], "Say hello");
let output: Value =
serde_json::from_str(fields["gen_ai.output.messages"].as_str().unwrap()).unwrap();
assert_eq!(output[0]["finish_reason"], "stop");
assert_eq!(output[0]["parts"][0]["content"], "hello ");
assert_eq!(output[0]["parts"][1]["content"], "world");
}
#[tokio::test]
async fn provider_input_drops_rows_empty_after_agent_projection() {
let user_only = TextContent::new("user-only ACP output")
+12
View File
@@ -58,6 +58,18 @@ export OTEL_METRICS_EXPORTER=none
export OTEL_LOGS_EXPORTER=none
```
To include model messages and tool arguments/results in traces, explicitly enable
GenAI message content capture:
```bash
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
```
:::warning
Message content can contain sensitive data and produce large traces. Leave this
setting disabled unless your telemetry storage is appropriate for that data.
:::
## Run goose with MLflow enabled
Start goose normally. With the OTLP environment variables set, goose will automatically export traces to MLflow: