feat(otel): emit GenAI semantic convention attributes (#10700)
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user