diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3a81d2b7d..6fbf269d0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1756,6 +1756,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-login", + "codex-otel", "codex-protocol", "codex-utils-absolute-path", "codex-utils-cargo-bin", @@ -1831,6 +1832,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-login", + "codex-otel", "codex-protocol", "codex-tui", "codex-utils-absolute-path", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9b7782edf..1d2054236 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -39,6 +39,7 @@ codex-core = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } +codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } color-eyre = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4378ec602..88ffdc2b4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -29,6 +29,7 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEdit; @@ -47,6 +48,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -70,6 +72,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; +use std::time::Instant; use tokio::select; use tokio::sync::broadcast; use tokio::sync::mpsc::unbounded_channel; @@ -326,6 +329,7 @@ async fn handle_model_migration_prompt_if_needed( pub(crate) struct App { pub(crate) server: Arc, + pub(crate) otel_manager: OtelManager, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, @@ -362,8 +366,7 @@ pub(crate) struct App { /// stopping a thread (e.g., before starting a new one). suppress_shutdown_complete: bool, - // One-shot suppression of the next world-writable scan after user confirmation. - skip_world_writable_scan_once: bool, + windows_sandbox: WindowsSandboxState, // TODO(jif) drop once new UX is here. // Track external agent approvals spawned via AgentControl. @@ -373,6 +376,13 @@ pub(crate) struct App { paused_codex_events: VecDeque, } +#[derive(Default)] +struct WindowsSandboxState { + setup_started_at: Option, + // One-shot suppression of the next world-writable scan after user confirmation. + skip_world_writable_scan_once: bool, +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -391,6 +401,7 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, model: Some(self.chat_widget.current_model().to_string()), + otel_manager: self.otel_manager.clone(), } } @@ -450,6 +461,24 @@ impl App { model = updated_model; } + let auth = auth_manager.auth().await; + let auth_ref = auth.as_ref(); + let model_info = thread_manager + .get_models_manager() + .get_model_info(model.as_str(), &config) + .await; + let otel_manager = OtelManager::new( + ThreadId::new(), + model.as_str(), + model_info.slug.as_str(), + auth_ref.and_then(CodexAuth::get_account_id), + auth_ref.and_then(CodexAuth::get_account_email), + auth_ref.map(|auth| auth.mode), + config.otel.log_user_prompt, + codex_core::terminal::user_agent(), + SessionSource::Cli, + ); + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { @@ -469,6 +498,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: Some(model.clone()), + otel_manager: otel_manager.clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -496,6 +526,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -523,6 +554,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -536,6 +568,7 @@ impl App { let mut app = Self { server: thread_manager.clone(), + otel_manager: otel_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), @@ -553,7 +586,7 @@ impl App { feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), }; @@ -723,6 +756,7 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, model: Some(model), + otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1060,7 +1094,16 @@ impl App { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { + self.otel_manager + .counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]); self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "failure")], + ); + } self.chat_widget .open_windows_sandbox_fallback_prompt(preset, reason); } @@ -1087,6 +1130,8 @@ impl App { } self.chat_widget.show_windows_sandbox_setup_status(); + self.windows_sandbox.setup_started_at = Some(Instant::now()); + let otel_manager = self.otel_manager.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( &policy, @@ -1096,11 +1141,23 @@ impl App { codex_home.as_path(), ); let event = match result { - Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }, + Ok(()) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_success", + 1, + &[], + ); + AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + } + } Err(err) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_failure", + 1, + &[], + ); tracing::error!( error = %err, "failed to run elevated Windows sandbox setup" @@ -1123,6 +1180,13 @@ impl App { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "success")], + ); + } let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); let elevated_key = Feature::WindowsSandboxElevated.key(); @@ -1272,8 +1336,8 @@ impl App { #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. - if self.skip_world_writable_scan_once { - self.skip_world_writable_scan_once = false; + if self.windows_sandbox.skip_world_writable_scan_once { + self.windows_sandbox.skip_world_writable_scan_once = false; return Ok(AppRunControl::Continue); } @@ -1334,7 +1398,7 @@ impl App { } } AppEvent::SkipNextWorldWritableScan => { - self.skip_world_writable_scan_once = true; + self.windows_sandbox.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); @@ -1766,11 +1830,14 @@ mod tests { use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; + use codex_core::protocol::SessionSource; + use codex_otel::OtelManager; use codex_protocol::ThreadId; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -1790,9 +1857,12 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, @@ -1810,7 +1880,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), } @@ -1830,10 +1900,13 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); ( App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, @@ -1851,7 +1924,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), external_approval_routes: HashMap::new(), paused_codex_events: VecDeque::new(), }, @@ -1860,6 +1933,21 @@ mod tests { ) } + fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) + } + fn all_model_presets() -> Vec { codex_core::models_manager::model_presets::all_model_presets().clone() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ff6db40bf..1ff2466f6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -89,6 +89,7 @@ use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; @@ -359,6 +360,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) model: Option, + pub(crate) otel_manager: OtelManager, } #[derive(Default)] @@ -413,6 +415,7 @@ pub(crate) struct ChatWidget { stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, + otel_manager: OtelManager, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -1816,6 +1819,7 @@ impl ChatWidget { feedback, is_first_run, model, + otel_manager, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -1867,6 +1871,7 @@ impl ChatWidget { stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(model_for_header), initial_user_message, token_info: None, @@ -1934,6 +1939,7 @@ impl ChatWidget { models_manager, feedback, model, + otel_manager, .. } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -1981,6 +1987,7 @@ impl ChatWidget { stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, @@ -2296,11 +2303,17 @@ impl ChatWidget { return; } + self.otel_manager.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + 1, + &[], + ); self.app_event_tx .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { + let _ = &self.otel_manager; // Not supported; on non-Windows this command should never be reachable. }; } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap index ab889de71..ff6bbec55 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -1,6 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1980 +assertion_line: 2654 expression: popup --- Select Approval Mode diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a056cb290..2d0ae5ef0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SessionSource; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenCountEvent; @@ -59,6 +60,7 @@ use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; @@ -700,6 +702,7 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), @@ -716,12 +719,28 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model: Some(resolved_model), + otel_manager, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. let _ = &mut w; } +fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) +} + // --- Helpers for tests that need direct construction and event draining --- async fn make_chatwidget_manual( model_override: Option<&str>, @@ -740,6 +759,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -782,6 +802,7 @@ async fn make_chatwidget_manual( stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None, diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index 391d21699..2abb5adb4 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -40,6 +40,7 @@ codex-core = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } +codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-tui = { workspace = true } diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 97e4c195e..b7e323b73 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -45,6 +45,7 @@ use crate::tui::scrolling::TranscriptScroll; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_core::AuthManager; +use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEdit; @@ -63,6 +64,7 @@ use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::terminal::terminal_info; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; @@ -89,6 +91,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; +use std::time::Instant; use tokio::select; use tokio::sync::mpsc::unbounded_channel; @@ -356,6 +359,7 @@ async fn handle_model_migration_prompt_if_needed( pub(crate) struct App { pub(crate) server: Arc, + pub(crate) otel_manager: OtelManager, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, @@ -418,6 +422,12 @@ pub(crate) struct App { /// stopping a conversation (e.g., before starting a new one). suppress_shutdown_complete: bool, + windows_sandbox: WindowsSandboxState, +} + +#[derive(Default)] +struct WindowsSandboxState { + setup_started_at: Option, // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, } @@ -439,6 +449,7 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, model: Some(self.chat_widget.current_model().to_string()), + otel_manager: self.otel_manager.clone(), } } @@ -498,6 +509,24 @@ impl App { model = updated_model; } + let auth = auth_manager.auth().await; + let auth_ref = auth.as_ref(); + let model_info = thread_manager + .get_models_manager() + .get_model_info(model.as_str(), &config) + .await; + let otel_manager = OtelManager::new( + ThreadId::new(), + model.as_str(), + model_info.slug.as_str(), + auth_ref.and_then(CodexAuth::get_account_id), + auth_ref.and_then(CodexAuth::get_account_email), + auth_ref.map(|auth| auth.mode), + config.otel.log_user_prompt, + codex_core::terminal::user_agent(), + SessionSource::Cli, + ); + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match session_selection { SessionSelection::StartFresh | SessionSelection::Exit => { @@ -517,6 +546,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: Some(model.clone()), + otel_manager: otel_manager.clone(), }; ChatWidget::new(init, thread_manager.clone()) } @@ -544,6 +574,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured) } @@ -571,6 +602,7 @@ impl App { feedback: feedback.clone(), is_first_run, model: config.model.clone(), + otel_manager: otel_manager.clone(), }; ChatWidget::new_from_existing(init, forked.thread, forked.session_configured) } @@ -600,6 +632,7 @@ impl App { let mut app = Self { server: thread_manager.clone(), + otel_manager: otel_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), @@ -628,7 +661,7 @@ impl App { feedback: feedback.clone(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -1495,6 +1528,7 @@ impl App { feedback: self.feedback.clone(), is_first_run: false, model: Some(model), + otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { @@ -1755,7 +1789,16 @@ impl App { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { + self.otel_manager + .counter("codex.windows_sandbox.fallback_prompt_shown", 1, &[]); self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "failure")], + ); + } self.chat_widget .open_windows_sandbox_fallback_prompt(preset, reason); } @@ -1782,6 +1825,8 @@ impl App { } self.chat_widget.show_windows_sandbox_setup_status(); + self.windows_sandbox.setup_started_at = Some(Instant::now()); + let otel_manager = self.otel_manager.clone(); tokio::task::spawn_blocking(move || { let result = codex_core::windows_sandbox::run_elevated_setup( &policy, @@ -1791,11 +1836,23 @@ impl App { codex_home.as_path(), ); let event = match result { - Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }, + Ok(()) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_success", + 1, + &[], + ); + AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + } + } Err(err) => { + otel_manager.counter( + "codex.windows_sandbox.elevated_setup_failure", + 1, + &[], + ); tracing::error!( error = %err, "failed to run elevated Windows sandbox setup" @@ -1818,6 +1875,13 @@ impl App { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); + if let Some(started_at) = self.windows_sandbox.setup_started_at.take() { + self.otel_manager.record_duration( + "codex.windows_sandbox.elevated_setup_duration_ms", + started_at.elapsed(), + &[("result", "success")], + ); + } let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); let elevated_key = Feature::WindowsSandboxElevated.key(); @@ -1967,8 +2031,8 @@ impl App { #[cfg(target_os = "windows")] { // One-shot suppression if the user just confirmed continue. - if self.skip_world_writable_scan_once { - self.skip_world_writable_scan_once = false; + if self.windows_sandbox.skip_world_writable_scan_once { + self.windows_sandbox.skip_world_writable_scan_once = false; return Ok(AppRunControl::Continue); } @@ -2026,7 +2090,7 @@ impl App { } } AppEvent::SkipNextWorldWritableScan => { - self.skip_world_writable_scan_once = true; + self.windows_sandbox.skip_world_writable_scan_once = true; } AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { self.chat_widget.set_full_access_warning_acknowledged(ack); @@ -2383,11 +2447,14 @@ mod tests { use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; + use codex_core::protocol::SessionSource; + use codex_otel::OtelManager; use codex_protocol::ThreadId; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -2407,9 +2474,12 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, @@ -2440,7 +2510,7 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), } } @@ -2458,10 +2528,13 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let model = ModelsManager::get_model_offline(config.model.as_deref()); + let otel_manager = test_otel_manager(&config, model.as_str()); ( App { server, + otel_manager, app_event_tx, chat_widget, auth_manager, @@ -2492,13 +2565,28 @@ mod tests { feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, suppress_shutdown_complete: false, - skip_world_writable_scan_once: false, + windows_sandbox: WindowsSandboxState::default(), }, rx, op_rx, ) } + fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) + } + fn all_model_presets() -> Vec { codex_core::models_manager::model_presets::all_model_presets().clone() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 9888128ba..ec1f06297 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -88,6 +88,7 @@ use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; use codex_core::skills::model::SkillInterface; use codex_core::skills::model::SkillMetadata; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; @@ -314,6 +315,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) model: Option, + pub(crate) otel_manager: OtelManager, } #[derive(Default)] @@ -360,6 +362,7 @@ pub(crate) struct ChatWidget { stored_collaboration_mode: CollaborationMode, auth_manager: Arc, models_manager: Arc, + otel_manager: OtelManager, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -1604,6 +1607,7 @@ impl ChatWidget { feedback, is_first_run, model, + otel_manager, } = common; let model = model.filter(|m| !m.trim().is_empty()); let mut config = config; @@ -1654,6 +1658,7 @@ impl ChatWidget { stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(model_for_header), initial_user_message, token_info: None, @@ -1718,6 +1723,7 @@ impl ChatWidget { models_manager, feedback, model, + otel_manager, .. } = common; let model = model.filter(|m| !m.trim().is_empty()); @@ -1766,6 +1772,7 @@ impl ChatWidget { stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, @@ -2053,11 +2060,17 @@ impl ChatWidget { return; } + self.otel_manager.counter( + "codex.windows_sandbox.setup_elevated_sandbox_command", + 1, + &[], + ); self.app_event_tx .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { + let _ = &self.otel_manager; // Not supported; on non-Windows this command should never be reachable. }; } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap index 5024b90a6..467c56a0f 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows.snap @@ -1,5 +1,6 @@ --- source: tui2/src/chatwidget/tests.rs +assertion_line: 2370 expression: popup --- Select Approval Mode @@ -11,4 +12,3 @@ expression: popup using. Press enter to confirm or esc to go back - diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 005b015a6..916ab6086 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -46,6 +46,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; +use codex_core::protocol::SessionSource; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TokenCountEvent; use codex_core::protocol::TokenUsage; @@ -56,6 +57,7 @@ use codex_core::protocol::UndoCompletedEvent; use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; +use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; @@ -689,6 +691,8 @@ async fn helpers_are_available_and_do_not_panic() { let tx = AppEventSender::new(tx_raw); let cfg = test_config().await; let model = cfg.model.clone(); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let thread_manager = Arc::new(ThreadManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), @@ -705,12 +709,28 @@ async fn helpers_are_available_and_do_not_panic() { feedback: codex_feedback::CodexFeedback::new(), is_first_run: true, model, + otel_manager, }; let mut w = ChatWidget::new(init, thread_manager); // Basic construction sanity. let _ = &mut w; } +fn test_otel_manager(config: &Config, model: &str) -> OtelManager { + let model_info = ModelsManager::construct_model_info_offline(model, config); + OtelManager::new( + ThreadId::new(), + model, + model_info.slug.as_str(), + None, + None, + None, + false, + "test".to_string(), + SessionSource::Cli, + ) +} + // --- Helpers for tests that need direct construction and event draining --- async fn make_chatwidget_manual( model_override: Option<&str>, @@ -729,6 +749,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), @@ -771,6 +792,7 @@ async fn make_chatwidget_manual( stored_collaboration_mode, auth_manager, models_manager, + otel_manager, session_header: SessionHeader::new(resolved_model), initial_user_message: None, token_info: None,