diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 304e268fc..9f9b581c8 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -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 = diff --git a/crates/goose/src/agents/gen_ai_telemetry.rs b/crates/goose/src/agents/gen_ai_telemetry.rs new file mode 100644 index 000000000..ddd23ede3 --- /dev/null +++ b/crates/goose/src/agents/gen_ai_telemetry.rs @@ -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) { + 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) -> 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) -> Option { + 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 = message.content.iter().map(message_part_json).collect(); + json!({ + "role": role, + "parts": parts, + }) +} + +fn tool_call_part(id: &str, tool_call: &ToolResult) -> 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>>, + } + + 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 { + 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 Layer 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>, + } + + 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"); + } +} diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 799021ab3..46c22519e 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -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; diff --git a/crates/goose/src/agents/reply_parts.rs b/crates/goose/src/agents/reply_parts.rs index 3a6f29170..4e4867e89 100644 --- a/crates/goose/src/agents/reply_parts.rs +++ b/crates/goose/src/agents/reply_parts.rs @@ -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, @@ -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 = None; let mut active_mergeable_assistant_id: Option = None; + let mut output_message: Option = 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 { + 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>>, @@ -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") diff --git a/documentation/docs/tutorials/mlflow.md b/documentation/docs/tutorials/mlflow.md index 907ba3f93..52aabf9c3 100644 --- a/documentation/docs/tutorials/mlflow.md +++ b/documentation/docs/tutorials/mlflow.md @@ -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: