use std::sync::Arc;
use async_trait::async_trait;
use github_copilot_sdk::handler::ApproveAllHandler;
use github_copilot_sdk::tool::ToolHandler;
use github_copilot_sdk::{
Client, Error, OtelExporterType, SessionConfig, TelemetryConfig, Tool, ToolInvocation,
ToolResult,
};
use serde_json::json;
use super::support::{assistant_message_content, with_e2e_context};
#[tokio::test]
async fn should_export_file_telemetry_for_sdk_interactions() {
if super::support::skip_inprocess("telemetry configuration is not honored in-process") {
return;
}
with_e2e_context(
"telemetry",
"should_export_file_telemetry_for_sdk_interactions",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let telemetry_path = ctx.work_dir().join("rust-telemetry-e2e.jsonl");
let source_name = "rust-sdk-telemetry-e2e";
let tool_name = "echo_telemetry_marker";
let marker = "copilot-sdk-telemetry-e2e";
let prompt = format!(
"Use the {tool_name} tool with value '{marker}', then respond with TELEMETRY_E2E_DONE."
);
let client = Client::start(ctx.client_options().with_telemetry(
TelemetryConfig::new()
.with_file_path(&telemetry_path)
.with_exporter_type(OtelExporterType::File)
.with_source_name(source_name)
.with_capture_content(true),
))
.await
.expect("start client");
let echo_tool = Tool::new(tool_name)
.with_description("Echoes a marker string for telemetry validation.")
.with_parameters(json!({
"type": "object",
"properties": {
"value": { "type": "string" }
},
"required": ["value"]
}))
.with_handler(Arc::new(EchoTelemetryTool));
let session = client
.create_session(
SessionConfig::default()
.with_github_token(super::support::DEFAULT_TEST_TOKEN)
.with_permission_handler(Arc::new(ApproveAllHandler))
.with_tools(vec![echo_tool]),
)
.await
.expect("create session");
let answer = session
.send_and_wait(prompt.as_str())
.await
.expect("send")
.expect("assistant message");
assert!(assistant_message_content(&answer).contains("TELEMETRY_E2E_DONE"));
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
let entries = read_telemetry_entries(&telemetry_path);
let spans: Vec<_> = entries
.iter()
.filter(|entry| string_property(entry, "type") == Some("span"))
.collect();
assert!(!spans.is_empty(), "expected telemetry spans in {entries:?}");
assert!(spans.iter().all(|span| {
span.get("instrumentationScope")
.and_then(|scope| string_property(scope, "name"))
== Some(source_name)
}));
assert!(spans.iter().all(|span| status_code(span) != Some(2)));
let invoke_agent = find_span(&spans, "invoke_agent");
assert_eq!(
string_attribute(invoke_agent, "gen_ai.conversation.id").as_deref(),
Some(session.id().as_str())
);
let invoke_agent_span_id =
string_property(invoke_agent, "spanId").expect("invoke_agent span id");
let invoke_agent_trace_id =
string_property(invoke_agent, "traceId").expect("invoke_agent trace id");
assert!(is_root_span(invoke_agent));
let chat_spans: Vec<_> = spans
.iter()
.copied()
.filter(|span| {
string_attribute(span, "gen_ai.operation.name").as_deref() == Some("chat")
})
.collect();
assert!(!chat_spans.is_empty());
assert!(chat_spans.iter().all(|span| {
string_property(span, "parentSpanId") == Some(invoke_agent_span_id)
&& string_property(span, "traceId") == Some(invoke_agent_trace_id)
}));
assert!(chat_spans.iter().any(|span| string_attribute(
span,
"gen_ai.input.messages"
)
.is_some_and(|messages| messages.contains(&prompt))));
assert!(chat_spans.iter().any(|span| string_attribute(
span,
"gen_ai.output.messages"
)
.is_some_and(|messages| messages.contains("TELEMETRY_E2E_DONE"))));
let tool_span = find_span(&spans, "execute_tool");
assert_eq!(
string_property(tool_span, "parentSpanId"),
Some(invoke_agent_span_id)
);
assert_eq!(
string_property(tool_span, "traceId"),
Some(invoke_agent_trace_id)
);
assert_eq!(
string_attribute(tool_span, "gen_ai.tool.name").as_deref(),
Some(tool_name)
);
assert_eq!(
string_attribute(tool_span, "gen_ai.tool.call.arguments").as_deref(),
Some(format!("{{\"value\":\"{marker}\"}}").as_str())
);
assert_eq!(
string_attribute(tool_span, "gen_ai.tool.call.result").as_deref(),
Some(marker)
);
})
},
)
.await;
}
#[tokio::test]
async fn should_export_per_request_subagent_chat_spans() {
if super::support::skip_inprocess("telemetry configuration is not honored in-process") {
return;
}
with_e2e_context(
"telemetry",
"should_export_per_request_subagent_chat_spans",
|ctx| {
Box::pin(async move {
ctx.set_default_copilot_user();
let telemetry_path = ctx.work_dir().join("rust-subagent-telemetry.jsonl");
let source_name = "rust-sdk-subagent-telemetry-e2e";
let prompt = "Use the task tool in sync mode to ask a task agent to read subagent-otel.txt with the view tool. Then reply with SUBAGENT_OTEL_DONE.";
std::fs::write(ctx.work_dir().join("subagent-otel.txt"), "SUBAGENT_OTEL_FILE_CONTENT")
.expect("write child file");
let client = Client::start(ctx.client_options().with_telemetry(
TelemetryConfig::new()
.with_file_path(&telemetry_path)
.with_exporter_type(OtelExporterType::File)
.with_source_name(source_name)
.with_capture_content(true),
))
.await
.expect("start client");
let session = client
.create_session(
SessionConfig::default()
.with_github_token(super::support::DEFAULT_TEST_TOKEN)
.with_permission_handler(Arc::new(ApproveAllHandler)),
)
.await
.expect("create session");
let answer = session
.send_and_wait(prompt)
.await
.expect("send")
.expect("assistant message");
assert!(assistant_message_content(&answer).contains("SUBAGENT_OTEL_DONE"));
session.disconnect().await.expect("disconnect session");
client.stop().await.expect("stop client");
let entries = read_telemetry_entries(&telemetry_path);
let spans: Vec<_> = entries
.iter()
.filter(|entry| string_property(entry, "type") == Some("span"))
.collect();
assert!(spans.iter().all(|span| {
span.get("instrumentationScope")
.and_then(|scope| string_property(scope, "name"))
== Some(source_name)
&& status_code(span) != Some(2)
}));
let invocations: Vec<_> = spans
.iter()
.copied()
.filter(|span| {
string_attribute(span, "gen_ai.operation.name").as_deref()
== Some("invoke_agent")
})
.collect();
assert_eq!(invocations.len(), 2, "invocations: {invocations:?}");
let roots: Vec<_> = invocations
.iter()
.copied()
.filter(|span| is_root_span(span))
.collect();
assert_eq!(roots.len(), 1, "roots: {roots:?}");
let root = roots[0];
assert_eq!(
string_attribute(root, "gen_ai.conversation.id").as_deref(),
Some(session.id().as_str())
);
let root_id = string_property(root, "spanId").expect("root span id");
let trace_id = string_property(root, "traceId").expect("root trace id");
let tasks: Vec<_> = spans
.iter()
.copied()
.filter(|span| {
string_attribute(span, "gen_ai.operation.name").as_deref()
== Some("execute_tool")
&& string_attribute(span, "gen_ai.tool.name").as_deref() == Some("task")
})
.collect();
assert_eq!(tasks.len(), 1, "task spans: {tasks:?}");
let task = tasks[0];
assert_eq!(string_property(task, "parentSpanId"), Some(root_id));
let children: Vec<_> = invocations
.iter()
.copied()
.filter(|span| string_property(span, "parentSpanId") == string_property(task, "spanId"))
.collect();
assert_eq!(children.len(), 1, "child spans: {children:?}");
let child = children[0];
assert_eq!(string_property(task, "traceId"), Some(trace_id));
assert_eq!(string_property(child, "traceId"), Some(trace_id));
let chats: Vec<_> = spans
.iter()
.copied()
.filter(|span| {
string_attribute(span, "gen_ai.operation.name").as_deref() == Some("chat")
})
.collect();
assert_eq!(chats.len(), 4, "chats: {chats:?}");
let parent_chats: Vec<_> = chats
.iter()
.copied()
.filter(|span| string_property(span, "parentSpanId") == Some(root_id))
.collect();
assert_eq!(parent_chats.len(), 2, "parent chats: {parent_chats:?}");
assert!(parent_chats.iter().all(|span| string_property(span, "traceId") == Some(trace_id)));
let child_chats: Vec<_> = chats
.iter()
.copied()
.filter(|span| string_property(span, "parentSpanId") == string_property(child, "spanId"))
.collect();
assert_eq!(child_chats.len(), 2, "child chats: {child_chats:?}");
assert!(child_chats.iter().all(|span| {
string_property(span, "traceId") == Some(trace_id)
&& string_attribute(span, "github.copilot.initiator").as_deref()
== Some("sub-agent")
}));
let view_chats: Vec<_> = child_chats
.iter()
.copied()
.filter(|span| string_attribute(span, "gen_ai.output.messages").is_some_and(|output| output.contains(r#""view""#)))
.collect();
assert_eq!(view_chats.len(), 1, "view chats: {view_chats:?}");
assert!(!string_attribute(view_chats[0], "gen_ai.input.messages")
.is_some_and(|input| input.contains("SUBAGENT_OTEL_FILE_CONTENT")));
let final_chats: Vec<_> = child_chats
.iter()
.copied()
.filter(|span| string_attribute(span, "gen_ai.output.messages").is_some_and(|output| output.contains("SUBAGENT_OTEL_CHILD_DONE")))
.collect();
assert_eq!(final_chats.len(), 1, "final chats: {final_chats:?}");
assert!(string_attribute(final_chats[0], "gen_ai.input.messages")
.is_some_and(|input| input.contains("SUBAGENT_OTEL_FILE_CONTENT")));
})
},
)
.await;
}
struct EchoTelemetryTool;
#[async_trait]
impl ToolHandler for EchoTelemetryTool {
async fn call(&self, invocation: ToolInvocation) -> Result<ToolResult, Error> {
Ok(ToolResult::Text(
invocation
.arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
))
}
}
fn read_telemetry_entries(path: &std::path::Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(path)
.expect("read telemetry entries")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("telemetry JSON line"))
.collect()
}
fn find_span<'a>(spans: &'a [&'a serde_json::Value], operation: &str) -> &'a serde_json::Value {
spans
.iter()
.copied()
.find(|span| string_attribute(span, "gen_ai.operation.name").as_deref() == Some(operation))
.unwrap_or_else(|| panic!("span {operation} not found in {spans:?}"))
}
fn string_property<'a>(value: &'a serde_json::Value, name: &str) -> Option<&'a str> {
value.get(name).and_then(serde_json::Value::as_str)
}
fn string_attribute(value: &serde_json::Value, name: &str) -> Option<String> {
value
.get("attributes")
.and_then(|attributes| attributes.get(name))
.map(|value| match value {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Number(_) | serde_json::Value::Bool(_) => value.to_string(),
serde_json::Value::Array(_) | serde_json::Value::Object(_) => value.to_string(),
serde_json::Value::Null => String::new(),
})
}
fn status_code(value: &serde_json::Value) -> Option<i64> {
value
.get("status")
.and_then(|status| status.get("code"))
.and_then(serde_json::Value::as_i64)
}
fn is_root_span(value: &serde_json::Value) -> bool {
string_property(value, "parentSpanId")
.is_none_or(|parent| parent.is_empty() || parent == "0000000000000000")
}