diff --git a/AGENTS.md b/AGENTS.md index ea3f1b9a..752981aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1022,6 +1022,7 @@ Adds a comment to an existing Azure DevOps work item. This is the ADO equivalent **Configuration options (front matter):** - `max` - Maximum number of comments per run (default: 1) +- `include-stats` - Whether to append agent execution stats to the comment body (default: true) - `target` - **Required** — scoping policy for which work items can be commented on: - `"*"` - Any work item in the project (unrestricted, must be explicit) - `12345` - A specific work item ID @@ -1053,6 +1054,7 @@ Creates an Azure DevOps work item. - `tags` - List of tags to apply - `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`) - `max` - Maximum number of create-work-item outputs allowed per run (default: 1) +- `include-stats` - Whether to append agent execution stats to the work item description (default: true) - `artifact-link` - Configuration for GitHub Copilot artifact linking: - `enabled` - Whether to add an artifact link (default: false) - `repository` - Repository name override (defaults to BUILD_REPOSITORY_NAME) @@ -1151,6 +1153,7 @@ Note: The source branch name is auto-generated from a sanitized version of the P - `labels` - List of labels to apply - `work-items` - List of work item IDs to link - `max` - Maximum number of create-pull-request outputs allowed per run (default: 1) +- `include-stats` - Whether to append agent execution stats (token usage, duration, model) to the PR description (default: true) **Multi-repository support:** When `workspace: root` and multiple repositories are checked out, agents can create PRs for any allowed repository: @@ -1204,6 +1207,7 @@ safe-outputs: comment-prefix: "[Agent Review] " # Optional — prepended to all comments allowed-repositories: [] # Optional — restrict which repos can be commented on max: 1 # Maximum per run (default: 1) + include-stats: true # Append agent stats to comment (default: true) ``` #### reply-to-pr-comment @@ -1417,6 +1421,7 @@ safe-outputs: title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Created by agent" # Optional — default commit comment when agent omits one max: 1 # Maximum number of create-wiki-page outputs allowed per run (default: 1) + include-stats: true # Append agent stats to wiki page content (default: true) ``` Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. @@ -1442,6 +1447,7 @@ safe-outputs: title-prefix: "[Agent] " # Optional — prepended to the last path segment (the page title) comment: "Updated by agent" # Optional — default commit comment when agent omits one max: 1 # Maximum number of update-wiki-page outputs allowed per run (default: 1) + include-stats: true # Append agent stats to wiki page content (default: true) ``` Note: `wiki-name` is required. If it is not set, execution fails with an explicit error message. diff --git a/src/agent_stats.rs b/src/agent_stats.rs new file mode 100644 index 00000000..686fe848 --- /dev/null +++ b/src/agent_stats.rs @@ -0,0 +1,409 @@ +//! Agent statistics extracted from Copilot CLI OpenTelemetry output. +//! +//! The Copilot CLI can write OTel spans and metrics to a JSONL file via +//! `COPILOT_OTEL_FILE_EXPORTER_PATH`. This module parses that file to +//! extract agent execution statistics (token usage, duration, model, +//! tool calls, turns) for inclusion in safe output write actions. + +use anyhow::{Context, Result}; +use serde_json::Value; +use std::path::Path; + +/// Agent execution statistics parsed from OTel JSONL. +#[derive(Debug, Clone)] +pub struct AgentStats { + /// Agent name from front matter. + pub agent_name: String, + /// AI model used (e.g., "claude-sonnet-4.5"). + pub model: Option, + /// Total input tokens across all LLM calls. + pub input_tokens: u64, + /// Total output tokens across all LLM calls. + pub output_tokens: u64, + /// Wall-clock duration in seconds. + pub duration_seconds: f64, + /// Number of tool invocations. + pub tool_calls: u64, + /// Number of LLM round-trips (turns). + pub turns: u64, +} + +/// OTel JSONL filename written by Copilot CLI. +pub const OTEL_FILENAME: &str = "otel.jsonl"; + +/// Copilot CLI internal tool names excluded from the tool call count. +/// These are administrative spans, not user-visible tool invocations. +/// Names must include the "execute_tool " prefix as emitted in the OTel span name. +const INTERNAL_TOOL_NAMES: &[&str] = &[ + "execute_tool report_intent", +]; + +impl AgentStats { + /// Parse agent stats from an OTel JSONL file. + /// + /// Uses [`crate::ndjson::read_ndjson_file`] for file I/O, then + /// extracts stats from the parsed entries. + pub async fn from_otel_file(path: &Path, agent_name: &str) -> Result { + let entries = crate::ndjson::read_ndjson_file(path) + .await + .with_context(|| format!("Failed to read OTel file: {}", path.display()))?; + Self::from_otel_entries(&entries, agent_name) + } + + /// Extract stats from pre-parsed OTel JSONL entries. + /// + /// Looks for: + /// - The last `invoke_agent` span for aggregated tokens, model, turns, duration + /// - `execute_tool` spans for tool call count (excluding internal tools) + pub fn from_otel_entries(entries: &[Value], agent_name: &str) -> Result { + let mut stats = AgentStats { + agent_name: agent_name.to_string(), + model: None, + input_tokens: 0, + output_tokens: 0, + duration_seconds: 0.0, + tool_calls: 0, + turns: 0, + }; + + // Find the last invoke_agent span (contains aggregated totals) + let last_agent_span = entries + .iter() + .filter(|e| { + e.get("type").and_then(|t| t.as_str()) == Some("span") + && e.get("name").and_then(|n| n.as_str()) == Some("invoke_agent") + }) + .last(); + + if let Some(span) = last_agent_span { + let attrs = span.get("attributes").cloned().unwrap_or(Value::Null); + + // Model + stats.model = attrs + .get("gen_ai.request.model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Tokens (aggregated across all chat spans) + stats.input_tokens = attrs + .get("gen_ai.usage.input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + stats.output_tokens = attrs + .get("gen_ai.usage.output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + // Turns + stats.turns = attrs + .get("github.copilot.turn_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + // Duration from startTime/endTime ([seconds, nanoseconds] arrays) + stats.duration_seconds = compute_duration(span); + } + + // Count execute_tool spans, excluding internal Copilot CLI tools + stats.tool_calls = entries + .iter() + .filter(|e| { + e.get("type").and_then(|t| t.as_str()) == Some("span") + && e.get("name") + .and_then(|n| n.as_str()) + .is_some_and(|n| { + n.starts_with("execute_tool") + && !INTERNAL_TOOL_NAMES.contains(&n) + }) + }) + .count() as u64; + + Ok(stats) + } + + /// Render as a compact markdown stats line. + /// + /// Uses middle-dot separators for a lightweight single-line format + /// that works across all ADO markdown surfaces. + pub fn to_markdown(&self) -> String { + let duration = format_duration(self.duration_seconds); + let model = sanitize_for_markdown( + self.model.as_deref().unwrap_or("unknown"), + ); + let name = sanitize_for_markdown(&self.agent_name); + + format!( + "\n---\n\ + \u{1F916} {name} \u{00B7} {model} \u{00B7} \ + {input} in / {output} out \u{00B7} \ + {tools} tool calls \u{00B7} {duration}\n", + name = name, + model = model, + input = format_number(self.input_tokens), + output = format_number(self.output_tokens), + tools = self.tool_calls, + duration = duration, + ) + } +} + +/// Default value for `include_stats` serde fields (true). +/// +/// Used by safe output config structs via `#[serde(default = "...")]`. +pub(crate) fn default_include_stats() -> bool { + true +} + +/// Sanitize a string for safe embedding in a single-line markdown format. +/// +/// Strips control characters (including newlines — the stats line is +/// single-line), neutralizes ADO pipeline commands (`##vso[`), and +/// escapes pipe characters that break markdown tables. +fn sanitize_for_markdown(s: &str) -> String { + s.chars() + .filter(|c| !c.is_control()) + .collect::() + .replace("##vso[", "[vso-filtered][") + .replace("##[", "[filtered][") + .replace('|', "\\|") +} + +/// Append agent stats markdown to a body string if stats are available +/// and stats are not opted out. +/// +/// Used by safe output executors after they read their typed config +/// (which contains the `include_stats` field). +pub fn append_stats_to_body( + body: &str, + ctx: &crate::safeoutputs::ExecutionContext, + include_stats: bool, +) -> String { + if !include_stats { + return body.to_string(); + } + + match &ctx.agent_stats { + Some(stats) => format!("{}{}", body, stats.to_markdown()), + None => body.to_string(), + } +} + +/// Compute the wall-clock duration of a span in seconds. +/// +/// Times are `[seconds, nanoseconds]` arrays. +fn compute_duration(span: &Value) -> f64 { + let start = parse_otel_time(span.get("startTime")); + let end = parse_otel_time(span.get("endTime")); + match (start, end) { + (Some(s), Some(e)) => (e - s).max(0.0), + _ => 0.0, + } +} + +/// Parse an OTel `[seconds, nanoseconds]` time array into seconds. +fn parse_otel_time(value: Option<&Value>) -> Option { + let arr = value?.as_array()?; + let secs = arr.first()?.as_f64()?; + let nanos = arr.get(1)?.as_f64().unwrap_or(0.0); + Some(secs + nanos / 1_000_000_000.0) +} + +/// Format seconds as human-readable duration (e.g., "4m 32s"). +fn format_duration(seconds: f64) -> String { + let total_secs = seconds.round() as u64; + if total_secs < 60 { + format!("{}s", total_secs) + } else if total_secs < 3600 { + format!("{}m {}s", total_secs / 60, total_secs % 60) + } else { + format!( + "{}h {}m {}s", + total_secs / 3600, + (total_secs % 3600) / 60, + total_secs % 60 + ) + } +} + +/// Format a number with comma separators (e.g., 45230 → "45,230"). +fn format_number(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration_seconds() { + assert_eq!(format_duration(0.0), "0s"); + assert_eq!(format_duration(45.0), "45s"); + assert_eq!(format_duration(59.4), "59s"); + } + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(60.0), "1m 0s"); + assert_eq!(format_duration(272.0), "4m 32s"); + assert_eq!(format_duration(3599.0), "59m 59s"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(3600.0), "1h 0m 0s"); + assert_eq!(format_duration(7384.0), "2h 3m 4s"); + } + + #[test] + fn test_format_number() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(999), "999"); + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(45230), "45,230"); + assert_eq!(format_number(1234567), "1,234,567"); + } + + #[test] + fn test_from_otel_entries_empty() { + let stats = AgentStats::from_otel_entries(&[], "test-agent").unwrap(); + assert_eq!(stats.agent_name, "test-agent"); + assert_eq!(stats.input_tokens, 0); + assert_eq!(stats.output_tokens, 0); + assert_eq!(stats.tool_calls, 0); + assert_eq!(stats.turns, 0); + assert!(stats.model.is_none()); + } + + #[test] + fn test_from_otel_entries_real_fixture() { + let content = include_str!("../tests/fixtures/copilot-otel.jsonl"); + let entries = crate::ndjson::parse_ndjson(content).unwrap(); + let stats = AgentStats::from_otel_entries(&entries, "test-agent").unwrap(); + + assert_eq!(stats.model.as_deref(), Some("claude-sonnet-4.5")); + assert_eq!(stats.input_tokens, 32949); + assert_eq!(stats.output_tokens, 236); + assert_eq!(stats.input_tokens + stats.output_tokens, 33185); + assert_eq!(stats.turns, 2); + // execute_tool spans: bash only (report_intent is filtered as internal) + assert_eq!(stats.tool_calls, 1); + // Duration should be ~8 seconds (from the last invoke_agent span) + assert!(stats.duration_seconds > 7.0 && stats.duration_seconds < 10.0); + } + + #[test] + fn test_to_markdown_contains_key_elements() { + let stats = AgentStats { + agent_name: "Daily Code Review".to_string(), + model: Some("claude-opus-4.5".to_string()), + input_tokens: 45230, + output_tokens: 12450, + duration_seconds: 272.0, + tool_calls: 23, + turns: 8, + }; + let md = stats.to_markdown(); + assert!(md.contains("Daily Code Review")); + assert!(md.contains("claude-opus-4.5")); + assert!(md.contains("45,230 in")); + assert!(md.contains("12,450 out")); + assert!(md.contains("23 tool calls")); + assert!(md.contains("4m 32s")); + assert!(md.contains("\u{00B7}"), "should use middle-dot separators"); + assert!(!md.contains("turns"), "turns should not be in output"); + } + + #[test] + fn test_parse_otel_time() { + // [1776287701, 726000000] = epoch seconds + nanoseconds + let val = serde_json::json!([1776287701, 726000000]); + let t = parse_otel_time(Some(&val)).unwrap(); + assert!((t - 1776287701.726).abs() < 0.001); + } + + #[test] + fn test_compute_duration_from_span() { + let span = serde_json::json!({ + "startTime": [1776287701, 726000000], + "endTime": [1776287710, 8631000] + }); + let d = compute_duration(&span); + assert!((d - 8.282631).abs() < 0.01); + } + + #[test] + fn test_sanitize_for_markdown_strips_vso_commands() { + assert_eq!( + sanitize_for_markdown("normal text"), + "normal text" + ); + assert_eq!( + sanitize_for_markdown("##vso[task.setvariable]evil"), + "[vso-filtered][task.setvariable]evil" + ); + assert_eq!( + sanitize_for_markdown("model|name"), + "model\\|name" + ); + } + + #[test] + fn test_internal_tools_excluded_from_count() { + let entries = vec![ + serde_json::json!({"type": "span", "name": "execute_tool report_intent"}), + serde_json::json!({"type": "span", "name": "execute_tool bash"}), + serde_json::json!({"type": "span", "name": "execute_tool grep"}), + // "permission" span has no "execute_tool" prefix so is already excluded + serde_json::json!({"type": "span", "name": "permission"}), + ]; + let stats = AgentStats::from_otel_entries(&entries, "test").unwrap(); + assert_eq!(stats.tool_calls, 2); // bash + grep only + } + + #[test] + fn test_append_stats_to_body_opt_out() { + let mut ctx = crate::safeoutputs::ExecutionContext::default(); + ctx.agent_stats = Some(AgentStats { + agent_name: "test".to_string(), + model: Some("model".to_string()), + input_tokens: 100, + output_tokens: 50, + duration_seconds: 10.0, + tool_calls: 1, + turns: 1, + }); + assert_eq!(append_stats_to_body("body", &ctx, false), "body"); + } + + #[test] + fn test_append_stats_to_body_no_stats() { + let ctx = crate::safeoutputs::ExecutionContext::default(); // agent_stats: None + assert_eq!(append_stats_to_body("body", &ctx, true), "body"); + } + + #[test] + fn test_append_stats_to_body_with_stats() { + let mut ctx = crate::safeoutputs::ExecutionContext::default(); + ctx.agent_stats = Some(AgentStats { + agent_name: "test".to_string(), + model: Some("model".to_string()), + input_tokens: 100, + output_tokens: 50, + duration_seconds: 10.0, + tool_calls: 1, + turns: 1, + }); + let result = append_stats_to_body("body", &ctx, true); + assert!(result.starts_with("body")); + assert!(result.contains("test")); + assert!(result.contains("model")); + } +} diff --git a/src/execute.rs b/src/execute.rs index 295aab7e..80a8fc95 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -514,6 +514,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let result = execute_safe_output(&entry, &ctx).await; @@ -546,6 +547,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let result = execute_safe_output(&entry, &ctx).await; @@ -694,6 +696,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let result = execute_safe_output(&entry, &ctx).await; @@ -736,6 +739,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let result = execute_safe_output(&entry, &ctx).await; @@ -778,6 +782,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let result = execute_safe_output(&entry, &ctx).await; @@ -825,6 +830,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await; @@ -1031,6 +1037,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await; @@ -1074,6 +1081,7 @@ mod tests { repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let results = execute_safe_outputs(temp_dir.path(), &ctx).await.unwrap(); diff --git a/src/main.rs b/src/main.rs index a03518b7..7f8c2204 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod agent_stats; mod allowed_hosts; mod compile; mod configure; @@ -216,6 +217,28 @@ async fn main() -> Result<()> { ctx.tool_configs = front_matter.safe_outputs.clone(); ctx.allowed_repositories = allowed_repositories; + // Load agent stats from OTel JSONL if available + let otel_path = safe_output_dir.join(agent_stats::OTEL_FILENAME); + if otel_path.exists() { + match agent_stats::AgentStats::from_otel_file(&otel_path, &front_matter.name) + .await + { + Ok(stats) => { + log::info!( + "Agent stats: {} input / {} output tokens, {}s duration, {} tool calls, {} turns", + stats.input_tokens, stats.output_tokens, + stats.duration_seconds as u64, stats.tool_calls, stats.turns + ); + ctx.agent_stats = Some(stats); + } + Err(e) => { + log::warn!("Failed to parse OTel stats file: {}", e); + } + } + } else { + log::debug!("No OTel stats file found at {}", otel_path.display()); + } + let results = execute::execute_safe_outputs(&safe_output_dir, &ctx).await?; // Process agent memory if cache-memory tool is enabled diff --git a/src/safeoutputs/add_pr_comment.rs b/src/safeoutputs/add_pr_comment.rs index 52e93e2c..e45f4ae0 100644 --- a/src/safeoutputs/add_pr_comment.rs +++ b/src/safeoutputs/add_pr_comment.rs @@ -150,6 +150,9 @@ pub struct AddPrCommentConfig { /// If empty, all valid statuses are allowed. #[serde(default, rename = "allowed-statuses")] pub allowed_statuses: Vec, + /// Whether to include agent execution stats in the output (default: true). + #[serde(default = "crate::agent_stats::default_include_stats", rename = "include-stats")] + pub include_stats: bool, } impl Default for AddPrCommentConfig { @@ -158,6 +161,7 @@ impl Default for AddPrCommentConfig { comment_prefix: None, allowed_repositories: Vec::new(), allowed_statuses: Vec::new(), + include_stats: true, } } } @@ -286,6 +290,11 @@ impl Executor for AddPrCommentResult { Some(prefix) => format!("{}{}", prefix, self.content), None => self.content.clone(), }; + let comment_body = crate::agent_stats::append_stats_to_body( + &comment_body, + ctx, + config.include_stats, + ); // Build the API URL let url = format!( @@ -578,6 +587,7 @@ allowed-statuses: comment_prefix: None, allowed_repositories: Vec::new(), allowed_statuses: vec!["Active".to_string(), "Closed".to_string()], + include_stats: true, }; // Test the exact comparison logic extracted from execute_impl let status = "active"; @@ -598,6 +608,7 @@ allowed-statuses: comment_prefix: None, allowed_repositories: Vec::new(), allowed_statuses: vec!["active".to_string()], + include_stats: true, }; let status = "Active"; let matched = config @@ -610,3 +621,4 @@ allowed-statuses: ); } } + diff --git a/src/safeoutputs/comment_on_work_item.rs b/src/safeoutputs/comment_on_work_item.rs index f53cf0f5..f9263a40 100644 --- a/src/safeoutputs/comment_on_work_item.rs +++ b/src/safeoutputs/comment_on_work_item.rs @@ -95,11 +95,24 @@ impl CommentTarget { /// max: 5 /// target: "*" /// ``` -#[derive(Debug, Clone, SanitizeConfig, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] pub struct CommentOnWorkItemConfig { /// Target scope — which work items can be commented on. /// `None` means no target was configured; execution must reject this. pub target: Option, + + /// Whether to include agent execution stats in the comment (default: true). + #[serde(default = "crate::agent_stats::default_include_stats", rename = "include-stats")] + pub include_stats: bool, +} + +impl Default for CommentOnWorkItemConfig { + fn default() -> Self { + Self { + target: None, + include_stats: true, + } + } } /// Fetch a work item's area path from the ADO API @@ -258,8 +271,13 @@ impl Executor for CommentOnWorkItemResult { ); debug!("API URL: {}", url); + let body_with_stats = crate::agent_stats::append_stats_to_body( + &self.body, + ctx, + config.include_stats, + ); let comment_body = serde_json::json!({ - "text": self.body, + "text": body_with_stats, }); info!("Sending comment to work item #{}", self.work_item_id); @@ -472,3 +490,4 @@ target: "*" assert!(config.target.is_some()); } } + diff --git a/src/safeoutputs/create_pr.rs b/src/safeoutputs/create_pr.rs index 7729548f..3a4e0034 100644 --- a/src/safeoutputs/create_pr.rs +++ b/src/safeoutputs/create_pr.rs @@ -456,6 +456,10 @@ pub struct CreatePrConfig { /// so operators can manually create the PR. No work item is created automatically. #[serde(default = "default_true", rename = "fallback-record-branch")] pub fallback_record_branch: bool, + + /// Whether to include agent execution stats in the PR description (default: true). + #[serde(default = "default_true", rename = "include-stats")] + pub include_stats: bool, } fn default_target_branch() -> String { @@ -500,6 +504,7 @@ impl Default for CreatePrConfig { labels: Vec::new(), work_items: Vec::new(), fallback_record_branch: true, + include_stats: true, } } } @@ -1255,8 +1260,14 @@ impl Executor for CreatePrResult { } debug!("Changes pushed successfully"); - // Append provenance footer to description - let description_with_footer = format!("{}{}", self.description, generate_pr_footer()); + // Append agent stats then provenance footer to description. + // Footer goes last as the final unambiguous provenance marker. + let description_with_stats = crate::agent_stats::append_stats_to_body( + &self.description, + ctx, + config.include_stats, + ); + let description_final = format!("{}{}", description_with_stats, generate_pr_footer()); // Create the pull request via REST API info!("Creating pull request"); @@ -1270,7 +1281,7 @@ impl Executor for CreatePrResult { "sourceRefName": source_ref, "targetRefName": target_ref, "title": effective_title, - "description": description_with_footer, + "description": description_final, "isDraft": config.draft, }); diff --git a/src/safeoutputs/create_wiki_page.rs b/src/safeoutputs/create_wiki_page.rs index 6de38513..d1e8cd59 100644 --- a/src/safeoutputs/create_wiki_page.rs +++ b/src/safeoutputs/create_wiki_page.rs @@ -97,7 +97,7 @@ impl SanitizeContent for CreateWikiPageResult { /// title-prefix: "[Agent] " /// comment: "Created by agent" /// ``` -#[derive(Debug, Clone, SanitizeConfig, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] pub struct CreateWikiPageConfig { /// Wiki identifier (name or ID). Required — execution fails without this. /// @@ -132,9 +132,25 @@ pub struct CreateWikiPageConfig { /// Default commit comment used when the agent does not supply one. #[serde(default)] pub comment: Option, + + /// Whether to include agent execution stats in the output (default: true). + #[serde(default = "crate::agent_stats::default_include_stats", rename = "include-stats")] + pub include_stats: bool, } -// ============================================================================ +impl Default for CreateWikiPageConfig { + fn default() -> Self { + Self { + wiki_name: None, + wiki_project: None, + branch: None, + path_prefix: None, + title_prefix: None, + comment: None, + include_stats: true, + } + } +} // Path helpers // ============================================================================ @@ -321,7 +337,13 @@ impl Executor for CreateWikiPageResult { .header("Content-Type", "application/json") .header("If-Match", "") .basic_auth("", Some(token)) - .json(&serde_json::json!({ "content": self.content })) + .json(&serde_json::json!({ + "content": crate::agent_stats::append_stats_to_body( + &self.content, + ctx, + config.include_stats, + ) + })) .send() .await .context("Failed to create wiki page")?; @@ -668,6 +690,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: std::collections::HashMap::new(), + agent_stats: None, }; // wiki-name not in config → should return Err @@ -731,6 +754,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -769,6 +793,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -807,6 +832,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; // The GET will fail (network unreachable with a fake host), so the @@ -888,3 +914,4 @@ wiki-name: "MyProject.wiki" assert_eq!(encoded, "MyProject.wiki"); } } + diff --git a/src/safeoutputs/create_work_item.rs b/src/safeoutputs/create_work_item.rs index 02c8e1b8..b14be4b2 100644 --- a/src/safeoutputs/create_work_item.rs +++ b/src/safeoutputs/create_work_item.rs @@ -98,6 +98,10 @@ pub struct CreateWorkItemConfig { #[serde(default, rename = "artifact-link")] #[sanitize_config(nested)] pub artifact_link: ArtifactLinkConfig, + + /// Whether to include agent execution stats in the output (default: true). + #[serde(default = "crate::agent_stats::default_include_stats", rename = "include-stats")] + pub include_stats: bool, } /// Configuration for artifact links (repository linking for GitHub Copilot) @@ -141,6 +145,7 @@ impl Default for CreateWorkItemConfig { tags: Vec::new(), custom_fields: std::collections::HashMap::new(), artifact_link: ArtifactLinkConfig::default(), + include_stats: true, } } } @@ -269,9 +274,14 @@ impl Executor for CreateWorkItemResult { debug!("API URL: {}", url); // Build the patch document for work item creation + let description_with_stats = crate::agent_stats::append_stats_to_body( + &self.description, + ctx, + config.include_stats, + ); let mut patch_doc = vec![ field_op("System.Title", &self.title), - field_op("System.Description", &self.description), + field_op("System.Description", &description_with_stats), // Tell Azure DevOps the description is markdown serde_json::json!({ "op": "add", @@ -524,3 +534,4 @@ tags: assert_eq!(config.tags, vec!["my-tag"]); } } + diff --git a/src/safeoutputs/result.rs b/src/safeoutputs/result.rs index a1d11753..5a09ab14 100644 --- a/src/safeoutputs/result.rs +++ b/src/safeoutputs/result.rs @@ -56,6 +56,8 @@ pub struct ExecutionContext { /// Allowed repositories for PRs: "self" + checkout list aliases /// Maps alias to ADO repo name (e.g., "other-repo" -> "org/other-repo") pub allowed_repositories: HashMap, + /// Agent execution statistics parsed from OTel JSONL + pub agent_stats: Option, } impl ExecutionContext { @@ -109,6 +111,7 @@ impl Default for ExecutionContext { repository_id: std::env::var("BUILD_REPOSITORY_ID").ok(), repository_name: std::env::var("BUILD_REPOSITORY_NAME").ok(), allowed_repositories: HashMap::new(), + agent_stats: None, } } } diff --git a/src/safeoutputs/update_wiki_page.rs b/src/safeoutputs/update_wiki_page.rs index ae44093c..980a12c6 100644 --- a/src/safeoutputs/update_wiki_page.rs +++ b/src/safeoutputs/update_wiki_page.rs @@ -93,7 +93,7 @@ impl SanitizeContent for UpdateWikiPageResult { /// title-prefix: "[Agent] " /// comment: "Updated by agent" /// ``` -#[derive(Debug, Clone, SanitizeConfig, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] pub struct UpdateWikiPageConfig { /// Wiki identifier (name or ID). Required — execution fails without this. /// @@ -128,6 +128,24 @@ pub struct UpdateWikiPageConfig { /// Default commit comment used when the agent does not supply one. #[serde(default)] pub comment: Option, + + /// Whether to include agent execution stats in the output (default: true). + #[serde(default = "crate::agent_stats::default_include_stats", rename = "include-stats")] + pub include_stats: bool, +} + +impl Default for UpdateWikiPageConfig { + fn default() -> Self { + Self { + wiki_name: None, + wiki_project: None, + branch: None, + path_prefix: None, + title_prefix: None, + comment: None, + include_stats: true, + } + } } // ============================================================================ @@ -316,7 +334,13 @@ impl Executor for UpdateWikiPageResult { .query(&put_query) .header("Content-Type", "application/json") .basic_auth("", Some(token)) - .json(&serde_json::json!({ "content": self.content })); + .json(&serde_json::json!({ + "content": crate::agent_stats::append_stats_to_body( + &self.content, + ctx, + config.include_stats, + ) + })); // Provide the ETag for optimistic concurrency when updating an existing page. if let Some(etag) = &etag { @@ -638,6 +662,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: std::collections::HashMap::new(), + agent_stats: None, }; // wiki-name not in config → should return Err @@ -701,6 +726,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -739,6 +765,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let outcome = result.execute_impl(&ctx).await.unwrap(); @@ -777,6 +804,7 @@ wiki-name: "MyProject.wiki" repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; // The GET will fail (network unreachable with a fake host), so the @@ -849,3 +877,4 @@ wiki-name: "MyProject.wiki" assert_eq!(encoded, "MyProject.wiki"); } } + diff --git a/src/safeoutputs/update_work_item.rs b/src/safeoutputs/update_work_item.rs index de0d939b..59e2977b 100644 --- a/src/safeoutputs/update_work_item.rs +++ b/src/safeoutputs/update_work_item.rs @@ -716,6 +716,7 @@ target: 42 repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let exec_result = result.execute_sanitized(&ctx).await; @@ -765,6 +766,7 @@ target: 42 repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let exec_result = result.execute_sanitized(&ctx).await.unwrap(); @@ -810,6 +812,7 @@ target: 42 repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let exec_result = result.execute_sanitized(&ctx).await.unwrap(); @@ -857,6 +860,7 @@ target: 42 repository_id: None, repository_name: None, allowed_repositories: HashMap::new(), + agent_stats: None, }; let exec_result = result.execute_sanitized(&ctx).await.unwrap(); diff --git a/templates/base.yml b/templates/base.yml index f57576ab..1aefbdb0 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -336,6 +336,9 @@ jobs: {{ copilot_ado_env }} GITHUB_TOKEN: $(GITHUB_TOKEN) GITHUB_READ_ONLY: 1 + COPILOT_OTEL_ENABLED: "true" + COPILOT_OTEL_EXPORTER_TYPE: "file" + COPILOT_OTEL_FILE_EXPORTER_PATH: "/tmp/awf-tools/staging/otel.jsonl" - bash: | # Copy safe outputs from /tmp back to staging for artifact publish diff --git a/tests/fixtures/copilot-otel.jsonl b/tests/fixtures/copilot-otel.jsonl new file mode 100644 index 00000000..41a78807 --- /dev/null +++ b/tests/fixtures/copilot-otel.jsonl @@ -0,0 +1,16 @@ +{"type":"span","traceId":"088a20d78c1d07c3865e2a0530513674","spanId":"c9c2eeffb602d29f","parentSpanId":"063d303f655b34f7","name":"chat claude-sonnet-4.5","kind":2,"startTime":[1776287660,403000000],"endTime":[1776287669,85879708],"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.conversation.id":"2704d56d-7d43-4a77-aecc-f53503afe2e9","gen_ai.response.finish_reasons":["stop"],"gen_ai.response.model":"claude-sonnet-4.5","gen_ai.response.id":"msg_01HgmSHweE6ojTyqT5gWZhKT","gen_ai.usage.input_tokens":16335,"gen_ai.usage.output_tokens":241,"github.copilot.cost":1,"github.copilot.server_duration":8318,"github.copilot.initiator":"user","github.copilot.turn_id":"0","github.copilot.interaction_id":"00414b60-105c-4696-ab8e-b8cfa57cf160"},"status":{"code":0},"events":[{"name":"github.copilot.session.usage_info","attributes":{"github.copilot.token_limit":168000,"github.copilot.current_tokens":15209,"github.copilot.messages_length":2},"time":[1776287660,750748750],"droppedAttributesCount":0}],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"088a20d78c1d07c3865e2a0530513674","spanId":"063d303f655b34f7","name":"invoke_agent","kind":2,"startTime":[1776287660,398000000],"endTime":[1776287669,91529792],"attributes":{"gen_ai.operation.name":"invoke_agent","gen_ai.provider.name":"github","gen_ai.agent.id":"2704d56d-7d43-4a77-aecc-f53503afe2e9","gen_ai.conversation.id":"2704d56d-7d43-4a77-aecc-f53503afe2e9","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.agent.version":"1.0.27","gen_ai.response.finish_reasons":["stop"],"gen_ai.usage.input_tokens":16335,"gen_ai.usage.output_tokens":241,"github.copilot.cost":1,"github.copilot.turn_count":1},"status":{"code":0},"events":[{"name":"github.copilot.user.message","attributes":{"github.copilot.user.message.source":"user","github.copilot.user.message.interaction_id":"00414b60-105c-4696-ab8e-b8cfa57cf160"},"time":[1776287660,398170792],"droppedAttributesCount":0}],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"metric","name":"gen_ai.client.operation.duration","description":"GenAI operation duration.","unit":"s","dataPoints":[{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5"},"startTime":[1776287669,85000000],"endTime":[1776287669,164000000],"value":{"min":8.682295250000001,"max":8.682295250000001,"sum":8.682295250000001,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0]},"count":1}},{"attributes":{"gen_ai.operation.name":"invoke_agent","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5"},"startTime":[1776287669,91000000],"endTime":[1776287669,164000000],"value":{"min":8.693246542,"max":8.693246542,"sum":8.693246542,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0]},"count":1}}]} +{"type":"metric","name":"gen_ai.client.token.usage","description":"Number of input and output tokens used.","unit":"{token}","dataPoints":[{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5","gen_ai.token.type":"input"},"startTime":[1776287669,85000000],"endTime":[1776287669,164000000],"value":{"min":16335,"max":16335,"sum":16335,"buckets":{"boundaries":[1,4,16,64,256,1024,4096,16384,65536,262144,1048576,4194304,16777216,67108864],"counts":[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0]},"count":1}},{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5","gen_ai.token.type":"output"},"startTime":[1776287669,85000000],"endTime":[1776287669,164000000],"value":{"min":241,"max":241,"sum":241,"buckets":{"boundaries":[1,4,16,64,256,1024,4096,16384,65536,262144,1048576,4194304,16777216,67108864],"counts":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0]},"count":1}}]} +{"type":"metric","name":"github.copilot.agent.turn.count","description":"Number of LLM round-trips per agent invocation.","unit":"{turn}","dataPoints":[{"attributes":{"gen_ai.operation.name":"invoke_agent"},"startTime":[1776287669,92000000],"endTime":[1776287669,164000000],"value":{"min":1,"max":1,"sum":1,"buckets":{"boundaries":[0,5,10,25,50,75,100,250,500,750,1000,2500,5000,7500,10000],"counts":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"count":1}}]} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"f1b63480c6f78981","parentSpanId":"c6b41964c101ae32","name":"execute_tool report_intent","kind":0,"startTime":[1776287707,467000000],"endTime":[1776287707,479244083],"attributes":{"gen_ai.operation.name":"execute_tool","gen_ai.tool.name":"report_intent","gen_ai.tool.call.id":"toolu_01Dz6cwy1p1jixbLe8tUgLMP","gen_ai.tool.type":"function","gen_ai.provider.name":"github"},"status":{"code":0},"events":[],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"9ee8976761e23b73","parentSpanId":"6bf31ab946b8a3d7","name":"permission","kind":0,"startTime":[1776287707,514000000],"endTime":[1776287707,514134792],"attributes":{"github.copilot.permission.kind":"shell","github.copilot.permission.result":"approved"},"status":{"code":0},"events":[],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"6bf31ab946b8a3d7","parentSpanId":"c6b41964c101ae32","name":"execute_tool bash","kind":0,"startTime":[1776287707,467000000],"endTime":[1776287707,935016625],"attributes":{"gen_ai.operation.name":"execute_tool","gen_ai.tool.name":"bash","gen_ai.tool.call.id":"toolu_01FQwme51sxxwfEEHhvYMCHV","gen_ai.tool.type":"function","gen_ai.provider.name":"github"},"status":{"code":0},"events":[],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"292baa9046013b62","parentSpanId":"c6b41964c101ae32","name":"chat claude-sonnet-4.5","kind":2,"startTime":[1776287701,730000000],"endTime":[1776287707,972193125],"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.conversation.id":"f02e2d21-f332-484f-96f3-b25e345372e2","gen_ai.response.finish_reasons":["stop"],"gen_ai.response.model":"claude-sonnet-4.5","gen_ai.response.id":"msg_017RrqSn4b2pvdYbbkuPvEby","gen_ai.usage.input_tokens":16331,"gen_ai.usage.output_tokens":216,"gen_ai.usage.cache_read.input_tokens":6966,"github.copilot.cost":1,"github.copilot.server_duration":5397,"github.copilot.initiator":"user","github.copilot.turn_id":"0","github.copilot.interaction_id":"37dff89d-552c-4b8e-bc95-8d7941b93dff"},"status":{"code":0},"events":[{"name":"github.copilot.session.usage_info","attributes":{"github.copilot.token_limit":168000,"github.copilot.current_tokens":15208,"github.copilot.messages_length":2},"time":[1776287702,60098541],"droppedAttributesCount":0},{"name":"github.copilot.hook.start","attributes":{"github.copilot.hook.type":"postToolUse","github.copilot.hook.invocation_id":"5c9e9898-6ee7-4800-a3b2-bd6cc9acebc2"},"time":[1776287707,476934541],"droppedAttributesCount":0},{"name":"github.copilot.hook.end","attributes":{"github.copilot.hook.type":"postToolUse","github.copilot.hook.invocation_id":"5c9e9898-6ee7-4800-a3b2-bd6cc9acebc2"},"time":[1776287707,477132666],"droppedAttributesCount":0},{"name":"github.copilot.hook.start","attributes":{"github.copilot.hook.type":"postToolUse","github.copilot.hook.invocation_id":"3117cb04-3b45-455e-a4bf-a5558c6f6c22"},"time":[1776287707,931591625],"droppedAttributesCount":0},{"name":"github.copilot.hook.end","attributes":{"github.copilot.hook.type":"postToolUse","github.copilot.hook.invocation_id":"3117cb04-3b45-455e-a4bf-a5558c6f6c22"},"time":[1776287707,932060000],"droppedAttributesCount":0}],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"122dedda8e545dfb","parentSpanId":"c6b41964c101ae32","name":"chat claude-sonnet-4.5","kind":2,"startTime":[1776287707,973000000],"endTime":[1776287710,3925667],"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.conversation.id":"f02e2d21-f332-484f-96f3-b25e345372e2","gen_ai.response.finish_reasons":["stop"],"gen_ai.response.model":"claude-sonnet-4.5","gen_ai.response.id":"msg_015TKLGnQbj3wLPMse5ktzaM","gen_ai.usage.input_tokens":16618,"gen_ai.usage.output_tokens":20,"gen_ai.usage.cache_read.input_tokens":16321,"github.copilot.cost":1,"github.copilot.server_duration":1984,"github.copilot.initiator":"agent","github.copilot.turn_id":"1","github.copilot.interaction_id":"37dff89d-552c-4b8e-bc95-8d7941b93dff"},"status":{"code":0},"events":[{"name":"github.copilot.session.usage_info","attributes":{"github.copilot.token_limit":168000,"github.copilot.current_tokens":15492,"github.copilot.messages_length":5},"time":[1776287708,13043958],"droppedAttributesCount":0}],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"span","traceId":"3184a26c641a6b29bf1f03d5de674e0a","spanId":"c6b41964c101ae32","name":"invoke_agent","kind":2,"startTime":[1776287701,726000000],"endTime":[1776287710,8631000],"attributes":{"gen_ai.operation.name":"invoke_agent","gen_ai.provider.name":"github","gen_ai.agent.id":"f02e2d21-f332-484f-96f3-b25e345372e2","gen_ai.conversation.id":"f02e2d21-f332-484f-96f3-b25e345372e2","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.agent.version":"1.0.27","gen_ai.response.finish_reasons":["stop"],"gen_ai.usage.input_tokens":32949,"gen_ai.usage.output_tokens":236,"gen_ai.usage.cache_read.input_tokens":23287,"github.copilot.cost":2,"github.copilot.turn_count":2},"status":{"code":0},"events":[{"name":"github.copilot.user.message","attributes":{"github.copilot.user.message.source":"user","github.copilot.user.message.interaction_id":"37dff89d-552c-4b8e-bc95-8d7941b93dff"},"time":[1776287701,726176750],"droppedAttributesCount":0}],"resource":{"attributes":{"service.name":"github-copilot","service.version":"1.0.27"},"schemaUrl":"https://opentelemetry.io/schemas/1.40.0"},"instrumentationScope":{"name":"github.copilot","version":"1.0.27"}} +{"type":"metric","name":"gen_ai.client.operation.duration","description":"GenAI operation duration.","unit":"s","dataPoints":[{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5"},"startTime":[1776287707,972000000],"endTime":[1776287710,65000000],"value":{"min":2.030883290999999,"max":6.2421425,"sum":8.273025790999998,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,0,0,0,0,0,0,0,1,0,1,0,0,0,0]},"count":2}},{"attributes":{"gen_ai.operation.name":"invoke_agent","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5"},"startTime":[1776287710,8000000],"endTime":[1776287710,65000000],"value":{"min":8.282399042000002,"max":8.282399042000002,"sum":8.282399042000002,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0]},"count":1}}]} +{"type":"metric","name":"gen_ai.client.token.usage","description":"Number of input and output tokens used.","unit":"{token}","dataPoints":[{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5","gen_ai.token.type":"input"},"startTime":[1776287707,972000000],"endTime":[1776287710,65000000],"value":{"min":16331,"max":16618,"sum":32949,"buckets":{"boundaries":[1,4,16,64,256,1024,4096,16384,65536,262144,1048576,4194304,16777216,67108864],"counts":[0,0,0,0,0,0,0,1,1,0,0,0,0,0,0]},"count":2}},{"attributes":{"gen_ai.operation.name":"chat","gen_ai.provider.name":"github","gen_ai.request.model":"claude-sonnet-4.5","gen_ai.response.model":"claude-sonnet-4.5","gen_ai.token.type":"output"},"startTime":[1776287707,972000000],"endTime":[1776287710,65000000],"value":{"min":20,"max":216,"sum":236,"buckets":{"boundaries":[1,4,16,64,256,1024,4096,16384,65536,262144,1048576,4194304,16777216,67108864],"counts":[0,0,0,1,1,0,0,0,0,0,0,0,0,0,0]},"count":2}}]} +{"type":"metric","name":"github.copilot.tool.call.count","description":"Number of tool invocations by tool name and outcome.","unit":"{call}","dataPoints":[{"attributes":{"gen_ai.tool.name":"report_intent","success":true},"startTime":[1776287707,481000000],"endTime":[1776287710,65000000],"value":1},{"attributes":{"gen_ai.tool.name":"bash","success":true},"startTime":[1776287707,936000000],"endTime":[1776287710,65000000],"value":1}]} +{"type":"metric","name":"github.copilot.tool.call.duration","description":"Tool execution latency.","unit":"s","dataPoints":[{"attributes":{"gen_ai.tool.name":"report_intent"},"startTime":[1776287707,481000000],"endTime":[1776287710,65000000],"value":{"min":0.013458832999999686,"max":0.013458832999999686,"sum":0.013458832999999686,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0]},"count":1}},{"attributes":{"gen_ai.tool.name":"bash"},"startTime":[1776287707,936000000],"endTime":[1776287710,65000000],"value":{"min":0.46841050000000084,"max":0.46841050000000084,"sum":0.46841050000000084,"buckets":{"boundaries":[0.01,0.02,0.04,0.08,0.16,0.32,0.64,1.28,2.56,5.12,10.24,20.48,40.96,81.92],"counts":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0]},"count":1}}]} +{"type":"metric","name":"github.copilot.agent.turn.count","description":"Number of LLM round-trips per agent invocation.","unit":"{turn}","dataPoints":[{"attributes":{"gen_ai.operation.name":"invoke_agent"},"startTime":[1776287710,8000000],"endTime":[1776287710,65000000],"value":{"min":2,"max":2,"sum":2,"buckets":{"boundaries":[0,5,10,25,50,75,100,250,500,750,1000,2500,5000,7500,10000],"counts":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"count":1}}]}