diff --git a/.github/actions/spelling/allow/apis.txt b/.github/actions/spelling/allow/apis.txt index 2afc566e1..c069ab651 100644 --- a/.github/actions/spelling/allow/apis.txt +++ b/.github/actions/spelling/allow/apis.txt @@ -36,6 +36,7 @@ endfor ENDSESSION enumset environstrings +etw EXACTSIZEONLY EXECUTEDEFAULT EXECUTEONLYONCE diff --git a/tools/wta/Cargo.lock b/tools/wta/Cargo.lock index 293d58f04..9f4bd778c 100644 --- a/tools/wta/Cargo.lock +++ b/tools/wta/Cargo.lock @@ -1695,6 +1695,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2275,6 +2281,7 @@ dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "sha1_smol", "wasm-bindgen", ] @@ -2496,6 +2503,47 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "win_etw_macros" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ceb3b134affad802f6509077e40a4fc4982981a4687ce020d0b03779b589642" +dependencies = [ + "proc-macro2", + "quote", + "sha1_smol", + "syn 2.0.117", + "uuid", + "win_etw_metadata", +] + +[[package]] +name = "win_etw_metadata" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5113ede807966842887ba64b2bba9e1079e19efaea612b6e83449af29b4b194" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "win_etw_provider" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d57b25caee8f665da0fb8c9b47b3b0d34752c90436ea9c6c97030e3bf80ca527" +dependencies = [ + "widestring", + "win_etw_metadata", + "windows-sys", + "zerocopy", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2668,9 +2716,31 @@ dependencies = [ "tracing-subscriber", "unicode-width", "which", + "win_etw_macros", + "win_etw_provider", "windows-sys", ] +[[package]] +name = "zerocopy" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/tools/wta/Cargo.toml b/tools/wta/Cargo.toml index c64bfc413..c49ab7f7d 100644 --- a/tools/wta/Cargo.toml +++ b/tools/wta/Cargo.toml @@ -33,5 +33,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-appender = "0.2" which = "7" +win_etw_macros = "0.1.8" +win_etw_provider = "0.1.8" rust-i18n = "3" sys-locale = "0.3" diff --git a/tools/wta/build.rs b/tools/wta/build.rs index 72bbeccf0..9fa62b15f 100644 --- a/tools/wta/build.rs +++ b/tools/wta/build.rs @@ -15,4 +15,33 @@ fn main() { // (including the .NET CLR) default to 4–8 MB stacks. #[cfg(all(debug_assertions, target_os = "windows", target_env = "msvc"))] println!("cargo:rustc-link-arg=/STACK:8388608"); + + // ETW telemetry provider-group GUID injection. + // + // `telemetry_template.rs` contains a placeholder provider_group_guid + // ("ffffffff-ffff-…"). When the `MAGIC_TRACING_GUID` env var is set + // (internal builds), we replace the placeholder with the real GUID so + // events route to the Microsoft telemetry pipeline. OSS builds keep + // the placeholder — the provider still registers, but events land in + // an unrouted group. + // + // This pattern is borrowed from microsoft/sudo (sudo_events/build.rs). + let template = std::fs::read_to_string("src/telemetry_template.rs") + .expect("failed to read src/telemetry_template.rs"); + let output = match std::env::var("MAGIC_TRACING_GUID") { + Ok(guid) => template.replace("ffffffff-ffff-ffff-ffff-ffffffffffff", &guid), + Err(_) => template, + }; + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest = std::path::Path::new(&out_dir).join("telemetry_generated.rs"); + // Only write when contents differ to avoid unnecessary recompiles. + let needs_write = std::fs::read_to_string(&dest) + .map(|existing| existing != output) + .unwrap_or(true); + if needs_write { + std::fs::write(&dest, output).expect("failed to write telemetry_generated.rs"); + } + + println!("cargo:rerun-if-changed=src/telemetry_template.rs"); + println!("cargo:rerun-if-env-changed=MAGIC_TRACING_GUID"); } diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 43e19fa6c..9eba86721 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -6278,14 +6278,17 @@ impl App { fn turn_close_finalize_autofix(&mut self, session_id: &str, buf: &str) { match parse_autofix_response(buf) { AutofixDecision::Fix(recommendations) => { + crate::telemetry::error_fix_resolved("fix_suggested"); self.turn_surface_fix(session_id, recommendations, "autofix_fix"); self.turn_release_end_pending(session_id); } AutofixDecision::Explain { title, explanation } => { + crate::telemetry::error_fix_resolved("explained"); self.turn_surface_explain(session_id, title, explanation, "autofix_explain"); self.turn_release_end_pending(session_id); } AutofixDecision::Ignore => { + crate::telemetry::error_fix_resolved("ignored"); let target_tab = self.tab_for_session(session_id); let pane_id = self.session_tab(session_id).autofix.pane_id.clone(); self.log_selection_phase_for( @@ -6450,10 +6453,24 @@ impl App { .prompt() .and_then(|p| p.autofix.as_ref()) .map(|a| a.target_pane_id.clone()); + let is_autofix = armed_pane.is_some(); + + // ETW: record the action type before `choice` is moved. + let action_type = match choice.actions.first() { + Some(crate::coordinator::RecommendedAction::Send { .. }) => "send", + Some(crate::coordinator::RecommendedAction::OpenAndSend { .. }) => "open_and_send", + Some(crate::coordinator::RecommendedAction::Open { .. }) => "open", + None => "none", + }; + crate::telemetry::agent_response_action(action_type, is_autofix); + if is_autofix { + crate::telemetry::error_fix_resolved("applied"); + } + let _ = self .recommendation_tx .send(crate::coordinator::ChoiceExecution { choice, insert_only }); - if armed_pane.is_some() { + if is_autofix { self.emit_autofix_state_cleared(&target_tab); } self.session_tab_mut(session_id).autofix.pane_id = None; diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 3a65234ec..c056d5183 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -20,6 +20,7 @@ mod runtime_paths; mod rtl; mod pane_context; mod shell; +mod telemetry; #[cfg(test)] mod test_support; mod theme; @@ -1303,6 +1304,7 @@ async fn delegate_with_context( async fn run_default_tui(cli: Cli) -> Result<()> { let _guard = logging::init("main"); + telemetry::init(); tracing::info!("=== run_default_tui started ==="); // Debug channel for TUI debug panel (WT protocol traffic viewer) @@ -1346,6 +1348,7 @@ async fn run_default_tui(cli: Cli) -> Result<()> { /// supplied named pipe and forwards ACP traffic over it. pub(crate) async fn run_default_tui_over_pipe(cli: Cli, pipe_name: String) -> Result<()> { let _guard = logging::init("main_helper"); + telemetry::init(); tracing::info!(target: "helper", pipe = %pipe_name, "=== wta-helper starting (TUI) ==="); // Debug channel — same wiring as run_default_tui. diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 28960c27a..4f5328d48 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -18,7 +18,7 @@ use crate::coordinator::default_supported_delegate_agents; use crate::pane_context::PaneContext; use crate::shell::{ShellManager, TerminalConfig}; -const ACTIVE_PANE_CONTEXT_MAX_CHARS: usize = 4000; +const ACTIVE_PANE_CONTEXT_MAX_CHARS: usize = 16000; /// Which prompt template was last shipped on a given ACP session. /// Used by [`TemplateMemo`] to decide whether the next turn needs to @@ -729,7 +729,11 @@ async fn complete_prompt_request( prompt_timing: &PromptTimingState, event_tx: &mpsc::UnboundedSender, session_id: String, + submitted_at_unix_s: f64, ) { + let success = result.is_ok(); + let duration_ms = ((now_unix_s() - submitted_at_unix_s) * 1000.0).max(0.0) as u64; + match result { Ok(_) => { let timing_note = prompt_timing.complete(&session_id, true, None); @@ -771,6 +775,9 @@ async fn complete_prompt_request( }); } } + + // ETW: record that the agent's response completed. + crate::telemetry::agent_response_received(success, duration_ms); } fn humanize_model_name(model: &str) -> String { @@ -2017,6 +2024,11 @@ pub async fn run_acp_client_over_pipe( load_session_supported, }); + // Record the agent identity for ETW telemetry events. + if let Some(info) = init_resp.agent_info.as_ref() { + crate::telemetry::set_agent_id(&info.name); + } + // Per-tab session cache. Same semantics as in `run_inner`. let tab_to_session: Arc>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); @@ -2789,6 +2801,12 @@ async fn run_inner( load_session_supported, }); + // Record the agent identity for ETW telemetry events. + // Use the registry-sanitized id (e.g. "copilot", "claude") rather than + // the raw command path to avoid leaking local filesystem paths. + let telemetry_agent_id = crate::agent_registry::lookup_profile(raw_program).id; + crate::telemetry::set_agent_id(telemetry_agent_id); + // Per-tab session cache, shared across all in-flight prompt tasks. // The startup session is bound to the owner tab GUID passed in by WT // (via --owner-tab-id) so the first prompt on that tab reuses the @@ -3390,6 +3408,9 @@ async fn dispatch_prompt_body( }); prompt_timing_task.mark_prompt_sent(&prompt_session_id_str); + // ETW: record that a prompt was dispatched to the agent. + crate::telemetry::agent_prompt_sent(prompt.is_autofix); + // Register a cancel oneshot for this prompt. The cancel // listener picks the sender out by session_id and signals it // when the user presses Ctrl+C. @@ -3412,6 +3433,7 @@ async fn dispatch_prompt_body( &prompt_timing_task, &event_tx_task, prompt_session_id_str.clone(), + prompt.submitted_at_unix_s, ) .await; false @@ -3514,6 +3536,7 @@ mod tests { &prompt_timing, &event_tx, "test-session".to_string(), + now_unix_s(), ) .await; @@ -3537,6 +3560,7 @@ mod tests { &prompt_timing, &event_tx, "test-session".to_string(), + now_unix_s(), ) .await; diff --git a/tools/wta/src/telemetry.rs b/tools/wta/src/telemetry.rs new file mode 100644 index 000000000..ef1286cef --- /dev/null +++ b/tools/wta/src/telemetry.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! ETW telemetry helpers for WTA. +//! +//! Provides thin convenience wrappers around the generated +//! [`WtaTelemetryEvents`] provider so call-sites don't need to pass +//! boilerplate fields (`PartA_PrivTags`, agent id) manually. +//! +//! # Usage +//! +//! ```ignore +//! // At startup: +//! telemetry::init(); +//! +//! // Once agent identity is known: +//! telemetry::set_agent_id("copilot"); +//! +//! // At each instrumented site: +//! telemetry::agent_prompt_sent(is_autofix); +//! ``` + +// Include the build-time generated trait impl (GUID-injected copy of +// telemetry_template.rs). +#[allow(non_snake_case)] +mod generated { + include!(concat!(env!("OUT_DIR"), "/telemetry_generated.rs")); +} +use generated::WtaTelemetryEvents; + +use std::sync::{Mutex, OnceLock}; + +/// OSS placeholder — internal builds compile in the real value via +/// build-system injection, mirroring `PDT_ProductAndServiceUsage` on the +/// C++ side (`dep/telemetry/ProjectTelemetry.h`). +const PDT_PRODUCT_AND_SERVICE_USAGE: u64 = 0; + +/// Singleton provider instance. +static PROVIDER: OnceLock = OnceLock::new(); + +/// Current agent identifier (e.g. `"copilot"`, `"gemini"`). Set once +/// at connection time by the ACP client; read by every event wrapper. +static AGENT_ID: Mutex = Mutex::new(String::new()); + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// Register the ETW provider. Safe to call more than once (idempotent via +/// `OnceLock`). +pub fn init() { + let _ = PROVIDER.get_or_init(WtaTelemetryEvents::new); +} + +// --------------------------------------------------------------------------- +// Agent identity +// --------------------------------------------------------------------------- + +/// Record the current agent id for subsequent telemetry events. +pub fn set_agent_id(id: &str) { + if let Ok(mut guard) = AGENT_ID.lock() { + guard.clear(); + guard.push_str(id); + } +} + +fn current_agent_id() -> String { + AGENT_ID + .lock() + .map(|g| g.clone()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Convenience wrappers — one per event +// --------------------------------------------------------------------------- + +fn provider() -> &'static WtaTelemetryEvents { + PROVIDER.get_or_init(WtaTelemetryEvents::new) +} + +/// A user prompt was dispatched to the agent. +pub fn agent_prompt_sent(is_autofix: bool) { + let id = current_agent_id(); + provider().agent_prompt_sent(None, &id, is_autofix, PDT_PRODUCT_AND_SERVICE_USAGE); +} + +/// The agent's response completed (success **or** error). +pub fn agent_response_received(success: bool, duration_ms: u64) { + let id = current_agent_id(); + provider().agent_response_received(None, &id, success, duration_ms, PDT_PRODUCT_AND_SERVICE_USAGE); +} + +/// The user executed a recommended action from the agent. +pub fn agent_response_action(action_type: &str, is_autofix: bool) { + let id = current_agent_id(); + provider().agent_response_action(None, &id, action_type, is_autofix, PDT_PRODUCT_AND_SERVICE_USAGE); +} + +/// An autofix cycle resolved. +pub fn error_fix_resolved(resolution: &str) { + let id = current_agent_id(); + provider().error_fix_resolved(None, &id, resolution, PDT_PRODUCT_AND_SERVICE_USAGE); +} diff --git a/tools/wta/src/telemetry_template.rs b/tools/wta/src/telemetry_template.rs new file mode 100644 index 000000000..b232bfc39 --- /dev/null +++ b/tools/wta/src/telemetry_template.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// ETW telemetry event definitions for the Windows Terminal Agent (WTA). +// +// This file is a *template*: `build.rs` copies it to `$OUT_DIR` with the +// placeholder `provider_group_guid` replaced by the real MS telemetry group +// GUID when the `MAGIC_TRACING_GUID` environment variable is set (internal +// builds). OSS builds keep the placeholder, which means the events are +// still emitted but land in an unrouted provider group. +// +// The macro generates a struct `WtaTelemetryEvents` with one method per +// event. All events carry `PartA_PrivTags` for privacy classification +// and use `keyword = 0x0` (OSS placeholder — internal builds override via +// the crate feature / build-system injection). + +use win_etw_macros::trace_logging_provider; + +#[trace_logging_provider( + name = "Microsoft.Windows.Terminal.Wta", + guid = "ae1d39f0-4cbd-4c6d-b13b-494bf80d07e3", + provider_group_guid = "ffffffff-ffff-ffff-ffff-ffffffffffff" +)] +pub trait WtaTelemetryEvents { + /// Emitted when a user prompt is dispatched to the agent. + #[event(keyword = 0x0)] + fn agent_prompt_sent(agent_id: &str, is_autofix: bool, PartA_PrivTags: u64); + + /// Emitted when the agent's response completes (success or error). + #[event(keyword = 0x0)] + fn agent_response_received( + agent_id: &str, + success: bool, + duration_ms: u64, + PartA_PrivTags: u64, + ); + + /// Emitted when the user executes a recommended action from the agent. + #[event(keyword = 0x0)] + fn agent_response_action( + agent_id: &str, + action_type: &str, + is_autofix: bool, + PartA_PrivTags: u64, + ); + + /// Emitted when an autofix cycle resolves (fix suggested, explained, + /// ignored, or applied by the user). + #[event(keyword = 0x0)] + fn error_fix_resolved(agent_id: &str, resolution: &str, PartA_PrivTags: u64); +}