From 804f23073233ccc39144549bb19880ff17d68756 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 13:43:59 -0700 Subject: [PATCH 1/7] Add hook run analytics event --- .../analytics/src/analytics_client_tests.rs | 134 ++++++++++++++++++ codex-rs/analytics/src/client.rs | 8 ++ codex-rs/analytics/src/events.rs | 92 ++++++++++++ codex-rs/analytics/src/facts.rs | 16 +++ codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 14 ++ codex-rs/core/src/hook_runtime.rs | 105 ++++++++++++++ 7 files changed, 370 insertions(+) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index eb46674156d..82ce30785c2 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -4,6 +4,7 @@ use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -12,6 +13,7 @@ use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; +use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; @@ -28,6 +30,8 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -81,6 +85,8 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -1282,6 +1288,100 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn hook_run_event_serializes_expected_shape() { + let home = std::env::var("HOME").expect("HOME should be set for analytics tests"); + + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-3".to_string(), + turn_id: "turn-3".to_string(), + }; + let event = TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::PreToolUse, + source_path: PathBuf::from(&home).join(".codex/hooks.json"), + cwd: PathBuf::from(&home).join("worktree"), + status: HookRunStatus::Completed, + duration_ms: Some(42), + }, + ), + }); + + let payload = serde_json::to_value(&event).expect("serialize hook run event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_hook_run", + "event_params": { + "thread_id": "thread-3", + "turn_id": "turn-3", + "model_slug": "gpt-5", + "hook_name": "pre_tool_use", + "hook_source": "user", + "status": "success", + "source_path": format!("{home}/.codex/hooks.json"), + "duration_ms": 42 + } + }) + ); +} + +#[test] +fn hook_run_metadata_maps_sources_and_statuses() { + let home = std::env::var("HOME").expect("HOME should be set for analytics tests"); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let system = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::SessionStart, + source_path: PathBuf::from("/etc/codex/hooks.json"), + cwd: PathBuf::from(&home).join("worktree"), + status: HookRunStatus::Completed, + duration_ms: None, + }, + )) + .expect("serialize system hook"); + let project = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + source_path: PathBuf::from(&home).join("worktree/.codex/hooks.json"), + cwd: PathBuf::from(&home).join("worktree/src"), + status: HookRunStatus::Blocked, + duration_ms: None, + }, + )) + .expect("serialize project hook"); + let unknown = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::UserPromptSubmit, + source_path: PathBuf::from("/tmp/hooks.json"), + cwd: PathBuf::from(&home).join("worktree"), + status: HookRunStatus::Failed, + duration_ms: None, + }, + )) + .expect("serialize unknown hook"); + + assert_eq!(system["hook_source"], "system"); + assert_eq!(system["status"], "success"); + assert_eq!(project["hook_source"], "project"); + assert_eq!(project["status"], "blocked"); + assert_eq!(unknown["hook_source"], "unknown"); + assert_eq!(unknown["status"], "error"); +} + #[test] fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() { let (sender, _receiver) = mpsc::channel(1); @@ -1359,6 +1459,40 @@ async fn reducer_ingests_skill_invoked_fact() { ); } +#[tokio::test] +async fn reducer_ingests_hook_run_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(HookRunInput { + tracking: TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }, + hook: HookRunFact { + event_name: HookEventName::PostToolUse, + source_path: PathBuf::from("/tmp/hooks.json"), + cwd: PathBuf::from("/tmp/project"), + status: HookRunStatus::Failed, + duration_ms: Some(7), + }, + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().expect("events array").len(), 1); + assert_eq!(payload[0]["event_type"], "codex_hook_run"); + assert_eq!(payload[0]["event_params"]["hook_name"], "post_tool_use"); + assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); + assert_eq!(payload[0]["event_params"]["status"], "error"); + assert_eq!(payload[0]["event_params"]["duration_ms"], 7); +} + #[tokio::test] async fn reducer_ingests_app_and_plugin_facts() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 5ba60e3ef44..1a4b5defe9c 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -9,6 +9,8 @@ use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunFact; +use crate::facts::HookRunInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -191,6 +193,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun( + HookRunInput { tracking, hook }, + ))); + } + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { if !self.queue.should_enqueue_plugin_used(&tracking, &plugin) { return; diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eb312da1405..3eeaac899e8 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,6 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::HookRunFact; use crate::facts::InvocationType; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; @@ -15,8 +16,11 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; +use std::path::Path; #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] @@ -39,6 +43,7 @@ pub(crate) enum TrackEventRequest { GuardianReview(Box), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), + HookRun(CodexHookRunEventRequest), Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), @@ -300,6 +305,41 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexHookSource { + System, + User, + Project, + Unknown, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CodexHookStatus { + Success, + Error, + Blocked, +} + +#[derive(Serialize)] +pub(crate) struct CodexHookRunMetadata { + pub(crate) thread_id: Option, + pub(crate) turn_id: Option, + pub(crate) model_slug: Option, + pub(crate) hook_name: Option, + pub(crate) hook_source: Option, + pub(crate) status: Option, + pub(crate) source_path: Option, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexHookRunEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexHookRunMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexCompactionEventParams { pub(crate) thread_id: String, @@ -529,6 +569,25 @@ pub(crate) fn codex_plugin_used_metadata( } } +pub(crate) fn codex_hook_run_metadata( + tracking: &TrackEventsContext, + hook: HookRunFact, +) -> CodexHookRunMetadata { + CodexHookRunMetadata { + thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_id.clone()), + model_slug: Some(tracking.model_slug.clone()), + hook_name: Some(hook.event_name), + hook_source: Some(hook_source_for_path( + hook.source_path.as_path(), + hook.cwd.as_path(), + )), + status: Some(hook_status(hook.status)), + source_path: Some(hook.source_path.display().to_string()), + duration_ms: hook.duration_ms, + } +} + pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata { let os_info = os_info::get(); CodexRuntimeMetadata { @@ -586,3 +645,36 @@ pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Opt _ => None, } } + +fn hook_source_for_path(source_path: &Path, cwd: &Path) -> CodexHookSource { + if source_path.starts_with("/etc/codex") { + return CodexHookSource::System; + } + + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if let Some(home) = home + && source_path.starts_with(home.join(".codex")) + { + return CodexHookSource::User; + } + + if source_path.ends_with(".codex/hooks.json") + && cwd + .ancestors() + .any(|ancestor| source_path.starts_with(ancestor.join(".codex"))) + { + return CodexHookSource::Project; + } + + CodexHookSource::Unknown +} + +fn hook_status(status: HookRunStatus) -> CodexHookStatus { + match status { + HookRunStatus::Completed => CodexHookStatus::Success, + HookRunStatus::Blocked => CodexHookStatus::Blocked, + HookRunStatus::Running | HookRunStatus::Failed | HookRunStatus::Stopped => { + CodexHookStatus::Error + } + } +} diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 5590fbfa6dc..e1a62f85d7a 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -15,6 +15,8 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; @@ -298,6 +300,7 @@ pub(crate) enum CustomAnalyticsFact { SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), AppUsed(AppUsedInput), + HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), } @@ -317,6 +320,19 @@ pub(crate) struct AppUsedInput { pub app: AppInvocation, } +pub(crate) struct HookRunInput { + pub tracking: TrackEventsContext, + pub hook: HookRunFact, +} + +pub struct HookRunFact { + pub event_name: HookEventName, + pub source_path: PathBuf, + pub cwd: PathBuf, + pub status: HookRunStatus, + pub duration_ms: Option, +} + pub(crate) struct PluginUsedInput { pub tracking: TrackEventsContext, pub plugin: PluginTelemetryMetadata, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 1a1a1231523..03f485a1c19 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -29,6 +29,7 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::HookRunFact; pub use facts::InputError; pub use facts::InvocationType; pub use facts::SkillInvocation; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index a5e5165bd2b..a6ce3fc831d 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -3,6 +3,7 @@ use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexRuntimeMetadata; @@ -20,6 +21,7 @@ use crate::events::ThreadInitializedEventParams; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; +use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::plugin_state_event_type; @@ -32,6 +34,7 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::HookRunInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -217,6 +220,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::AppUsed(input) => { self.ingest_app_used(input, out); } + CustomAnalyticsFact::HookRun(input) => { + self.ingest_hook_run(input, out); + } CustomAnalyticsFact::PluginUsed(input) => { self.ingest_plugin_used(input, out); } @@ -442,6 +448,14 @@ impl AnalyticsReducer { })); } + fn ingest_hook_run(&mut self, input: HookRunInput, out: &mut Vec) { + let HookRunInput { tracking, hook } = input; + out.push(TrackEventRequest::HookRun(CodexHookRunEventRequest { + event_type: "codex_hook_run", + event_params: codex_hook_run_metadata(&tracking, hook), + })); + } + fn ingest_plugin_used(&mut self, input: PluginUsedInput, out: &mut Vec) { let PluginUsedInput { tracking, plugin } = input; out.push(TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index d2d672fcd48..4d036e1fea0 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; use std::sync::Arc; +use codex_analytics::HookRunFact; +use codex_analytics::build_track_events_context; use codex_hooks::PostToolUseOutcome; use codex_hooks::PostToolUseRequest; use codex_hooks::PreToolUseOutcome; @@ -322,11 +324,48 @@ async fn emit_hook_completed_events( completed_events: Vec, ) { for completed in completed_events { + track_hook_completed_analytics(sess, turn_context, &completed); sess.send_event(turn_context, EventMsg::HookCompleted(completed)) .await; } } +fn track_hook_completed_analytics( + sess: &Arc, + turn_context: &Arc, + completed: &HookCompletedEvent, +) { + let (tracking, hook) = + hook_run_analytics_payload(sess.conversation_id.to_string(), turn_context, completed); + sess.services + .analytics_events_client + .track_hook_run(tracking, hook); +} + +fn hook_run_analytics_payload( + thread_id: String, + turn_context: &TurnContext, + completed: &HookCompletedEvent, +) -> (codex_analytics::TrackEventsContext, HookRunFact) { + ( + build_track_events_context( + turn_context.model_info.slug.clone(), + thread_id, + completed + .turn_id + .clone() + .unwrap_or_else(|| turn_context.sub_id.clone()), + ), + HookRunFact { + event_name: completed.run.event_name, + source_path: completed.run.source_path.to_path_buf(), + cwd: turn_context.cwd.to_path_buf(), + status: completed.run.status, + duration_ms: completed.run.duration_ms, + }, + ) +} + fn hook_permission_mode(turn_context: &TurnContext) -> String { match turn_context.approval_policy.value() { AskForApproval::Never => "bypassPermissions", @@ -341,9 +380,21 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { #[cfg(test)] mod tests { use codex_protocol::models::ContentItem; + use codex_protocol::protocol::HookEventName; + use codex_protocol::protocol::HookExecutionMode; + use codex_protocol::protocol::HookHandlerType; + use codex_protocol::protocol::HookRunStatus; + use codex_protocol::protocol::HookScope; use pretty_assertions::assert_eq; + use std::path::PathBuf; use super::additional_context_messages; + use super::hook_run_analytics_payload; + use crate::codex::make_session_and_context; + use codex_protocol::protocol::HookCompletedEvent; + use codex_protocol::protocol::HookRunSummary; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; #[test] fn additional_context_messages_stay_separate_and_ordered() { @@ -378,4 +429,58 @@ mod tests { ], ); } + + #[tokio::test] + async fn hook_run_analytics_payload_uses_completed_turn_id() { + let (_session, turn_context) = make_session_and_context().await; + let completed = HookCompletedEvent { + turn_id: Some("turn-from-hook".to_string()), + run: sample_hook_run(HookRunStatus::Blocked), + }; + + let (tracking, hook) = + hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); + + assert_eq!(tracking.thread_id, "thread-123"); + assert_eq!(tracking.turn_id, "turn-from-hook"); + assert_eq!(tracking.model_slug, turn_context.model_info.slug); + assert_eq!(hook.event_name, HookEventName::Stop); + assert_eq!(hook.source_path, PathBuf::from("/tmp/hooks.json")); + assert_eq!(hook.cwd, turn_context.cwd.to_path_buf()); + assert_eq!(hook.status, HookRunStatus::Blocked); + assert_eq!(hook.duration_ms, Some(27)); + } + + #[tokio::test] + async fn hook_run_analytics_payload_falls_back_to_turn_context_id() { + let (_session, turn_context) = make_session_and_context().await; + let completed = HookCompletedEvent { + turn_id: None, + run: sample_hook_run(HookRunStatus::Failed), + }; + + let (tracking, hook) = + hook_run_analytics_payload("thread-123".to_string(), &turn_context, &completed); + + assert_eq!(tracking.turn_id, turn_context.sub_id); + assert_eq!(hook.status, HookRunStatus::Failed); + } + + fn sample_hook_run(status: HookRunStatus) -> HookRunSummary { + HookRunSummary { + id: "stop:0:/tmp/hooks.json".to_string(), + event_name: HookEventName::Stop, + handler_type: HookHandlerType::Command, + execution_mode: HookExecutionMode::Sync, + scope: HookScope::Turn, + source_path: test_path_buf("/tmp/hooks.json").abs(), + display_order: 0, + status, + status_message: None, + started_at: 10, + completed_at: Some(37), + duration_ms: Some(27), + entries: Vec::new(), + } + } } From 4ba49ceddd3bc0e1cff4df4e53d4fa85ef3c7037 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 13:55:37 -0700 Subject: [PATCH 2/7] Refine hook run analytics payload --- codex-rs/Cargo.lock | 1 + codex-rs/analytics/Cargo.toml | 1 + codex-rs/analytics/src/analytics_client_tests.rs | 9 +++------ codex-rs/analytics/src/events.rs | 8 +++----- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 68e0ec092f1..f0141155e41 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-utils-absolute-path", + "dirs", "os_info", "pretty_assertions", "serde", diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml index f706814d419..e1a962e7f34 100644 --- a/codex-rs/analytics/Cargo.toml +++ b/codex-rs/analytics/Cargo.toml @@ -18,6 +18,7 @@ codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } +dirs = { workspace = true } os_info = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 82ce30785c2..b25aee8c50b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1290,7 +1290,7 @@ fn plugin_management_event_serializes_expected_shape() { #[test] fn hook_run_event_serializes_expected_shape() { - let home = std::env::var("HOME").expect("HOME should be set for analytics tests"); + let home = dirs::home_dir().expect("home dir should be available for analytics tests"); let tracking = TrackEventsContext { model_slug: "gpt-5".to_string(), @@ -1323,9 +1323,7 @@ fn hook_run_event_serializes_expected_shape() { "model_slug": "gpt-5", "hook_name": "pre_tool_use", "hook_source": "user", - "status": "success", - "source_path": format!("{home}/.codex/hooks.json"), - "duration_ms": 42 + "status": "success" } }) ); @@ -1333,7 +1331,7 @@ fn hook_run_event_serializes_expected_shape() { #[test] fn hook_run_metadata_maps_sources_and_statuses() { - let home = std::env::var("HOME").expect("HOME should be set for analytics tests"); + let home = dirs::home_dir().expect("home dir should be available for analytics tests"); let tracking = TrackEventsContext { model_slug: "gpt-5".to_string(), thread_id: "thread-1".to_string(), @@ -1490,7 +1488,6 @@ async fn reducer_ingests_hook_run_fact() { assert_eq!(payload[0]["event_params"]["hook_name"], "post_tool_use"); assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); assert_eq!(payload[0]["event_params"]["status"], "error"); - assert_eq!(payload[0]["event_params"]["duration_ms"], 7); } #[tokio::test] diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 3eeaac899e8..5adde3d08ad 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -330,8 +330,6 @@ pub(crate) struct CodexHookRunMetadata { pub(crate) hook_name: Option, pub(crate) hook_source: Option, pub(crate) status: Option, - pub(crate) source_path: Option, - pub(crate) duration_ms: Option, } #[derive(Serialize)] @@ -583,8 +581,6 @@ pub(crate) fn codex_hook_run_metadata( hook.cwd.as_path(), )), status: Some(hook_status(hook.status)), - source_path: Some(hook.source_path.display().to_string()), - duration_ms: hook.duration_ms, } } @@ -651,13 +647,15 @@ fn hook_source_for_path(source_path: &Path, cwd: &Path) -> CodexHookSource { return CodexHookSource::System; } - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let home = dirs::home_dir(); if let Some(home) = home && source_path.starts_with(home.join(".codex")) { return CodexHookSource::User; } + // Project hooks are loaded from a `.codex/hooks.json` rooted at or above the + // current working directory, so classify by walking cwd ancestors. if source_path.ends_with(".codex/hooks.json") && cwd .ancestors() From a453c15c308cd9a0cc6ada82e6de0f7735ae0b83 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 14:08:12 -0700 Subject: [PATCH 3/7] Simplify hook run status analytics --- .../analytics/src/analytics_client_tests.rs | 33 ++++++++++++++++--- codex-rs/analytics/src/events.rs | 22 ++++--------- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b25aee8c50b..847e64a54f7 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1323,7 +1323,7 @@ fn hook_run_event_serializes_expected_shape() { "model_slug": "gpt-5", "hook_name": "pre_tool_use", "hook_source": "user", - "status": "success" + "status": "completed" } }) ); @@ -1373,11 +1373,36 @@ fn hook_run_metadata_maps_sources_and_statuses() { .expect("serialize unknown hook"); assert_eq!(system["hook_source"], "system"); - assert_eq!(system["status"], "success"); + assert_eq!(system["status"], "completed"); assert_eq!(project["hook_source"], "project"); assert_eq!(project["status"], "blocked"); assert_eq!(unknown["hook_source"], "unknown"); - assert_eq!(unknown["status"], "error"); + assert_eq!(unknown["status"], "failed"); +} + +#[test] +fn hook_run_metadata_maps_stopped_status() { + let home = dirs::home_dir().expect("home dir should be available for analytics tests"); + let tracking = TrackEventsContext { + model_slug: "gpt-5".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }; + + let stopped = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + source_path: home.join(".codex/hooks.json"), + cwd: home.join("worktree"), + status: HookRunStatus::Stopped, + duration_ms: None, + }, + )) + .expect("serialize stopped hook"); + + assert_eq!(stopped["hook_source"], "user"); + assert_eq!(stopped["status"], "stopped"); } #[test] @@ -1487,7 +1512,7 @@ async fn reducer_ingests_hook_run_fact() { assert_eq!(payload[0]["event_type"], "codex_hook_run"); assert_eq!(payload[0]["event_params"]["hook_name"], "post_tool_use"); assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); - assert_eq!(payload[0]["event_params"]["status"], "error"); + assert_eq!(payload[0]["event_params"]["status"], "failed"); } #[tokio::test] diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 5adde3d08ad..577a4fb87d1 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -314,14 +314,6 @@ pub(crate) enum CodexHookSource { Unknown, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum CodexHookStatus { - Success, - Error, - Blocked, -} - #[derive(Serialize)] pub(crate) struct CodexHookRunMetadata { pub(crate) thread_id: Option, @@ -329,7 +321,7 @@ pub(crate) struct CodexHookRunMetadata { pub(crate) model_slug: Option, pub(crate) hook_name: Option, pub(crate) hook_source: Option, - pub(crate) status: Option, + pub(crate) status: Option, } #[derive(Serialize)] @@ -580,7 +572,7 @@ pub(crate) fn codex_hook_run_metadata( hook.source_path.as_path(), hook.cwd.as_path(), )), - status: Some(hook_status(hook.status)), + status: Some(analytics_hook_status(hook.status)), } } @@ -667,12 +659,10 @@ fn hook_source_for_path(source_path: &Path, cwd: &Path) -> CodexHookSource { CodexHookSource::Unknown } -fn hook_status(status: HookRunStatus) -> CodexHookStatus { +fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { match status { - HookRunStatus::Completed => CodexHookStatus::Success, - HookRunStatus::Blocked => CodexHookStatus::Blocked, - HookRunStatus::Running | HookRunStatus::Failed | HookRunStatus::Stopped => { - CodexHookStatus::Error - } + // Running is unexpected here and normalized defensively. + HookRunStatus::Running => HookRunStatus::Failed, + other => other, } } From dfe94bd46edd6cd5668fe12038acd640a3fe4326 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 14:32:59 -0700 Subject: [PATCH 4/7] Fix hook run analytics source classification --- codex-rs/Cargo.lock | 1 - codex-rs/analytics/Cargo.toml | 1 - .../analytics/src/analytics_client_tests.rs | 29 ++---- codex-rs/analytics/src/events.rs | 41 +-------- codex-rs/analytics/src/facts.rs | 13 ++- codex-rs/analytics/src/lib.rs | 1 + codex-rs/core/src/config_loader/mod.rs | 4 +- codex-rs/core/src/hook_runtime.rs | 89 +++++++++++++++++-- 8 files changed, 104 insertions(+), 75 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f0141155e41..68e0ec092f1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1376,7 +1376,6 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-utils-absolute-path", - "dirs", "os_info", "pretty_assertions", "serde", diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml index e1a962e7f34..f706814d419 100644 --- a/codex-rs/analytics/Cargo.toml +++ b/codex-rs/analytics/Cargo.toml @@ -18,7 +18,6 @@ codex-git-utils = { workspace = true } codex-login = { workspace = true } codex-plugin = { workspace = true } codex-protocol = { workspace = true } -dirs = { workspace = true } os_info = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 847e64a54f7..77b91b56a77 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -23,6 +23,7 @@ use crate::facts::AppInvocation; use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexHookSource; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -1290,8 +1291,6 @@ fn plugin_management_event_serializes_expected_shape() { #[test] fn hook_run_event_serializes_expected_shape() { - let home = dirs::home_dir().expect("home dir should be available for analytics tests"); - let tracking = TrackEventsContext { model_slug: "gpt-5".to_string(), thread_id: "thread-3".to_string(), @@ -1303,10 +1302,8 @@ fn hook_run_event_serializes_expected_shape() { &tracking, HookRunFact { event_name: HookEventName::PreToolUse, - source_path: PathBuf::from(&home).join(".codex/hooks.json"), - cwd: PathBuf::from(&home).join("worktree"), + hook_source: CodexHookSource::User, status: HookRunStatus::Completed, - duration_ms: Some(42), }, ), }); @@ -1331,7 +1328,6 @@ fn hook_run_event_serializes_expected_shape() { #[test] fn hook_run_metadata_maps_sources_and_statuses() { - let home = dirs::home_dir().expect("home dir should be available for analytics tests"); let tracking = TrackEventsContext { model_slug: "gpt-5".to_string(), thread_id: "thread-1".to_string(), @@ -1342,10 +1338,8 @@ fn hook_run_metadata_maps_sources_and_statuses() { &tracking, HookRunFact { event_name: HookEventName::SessionStart, - source_path: PathBuf::from("/etc/codex/hooks.json"), - cwd: PathBuf::from(&home).join("worktree"), + hook_source: CodexHookSource::System, status: HookRunStatus::Completed, - duration_ms: None, }, )) .expect("serialize system hook"); @@ -1353,10 +1347,8 @@ fn hook_run_metadata_maps_sources_and_statuses() { &tracking, HookRunFact { event_name: HookEventName::Stop, - source_path: PathBuf::from(&home).join("worktree/.codex/hooks.json"), - cwd: PathBuf::from(&home).join("worktree/src"), + hook_source: CodexHookSource::Project, status: HookRunStatus::Blocked, - duration_ms: None, }, )) .expect("serialize project hook"); @@ -1364,10 +1356,8 @@ fn hook_run_metadata_maps_sources_and_statuses() { &tracking, HookRunFact { event_name: HookEventName::UserPromptSubmit, - source_path: PathBuf::from("/tmp/hooks.json"), - cwd: PathBuf::from(&home).join("worktree"), + hook_source: CodexHookSource::Unknown, status: HookRunStatus::Failed, - duration_ms: None, }, )) .expect("serialize unknown hook"); @@ -1382,7 +1372,6 @@ fn hook_run_metadata_maps_sources_and_statuses() { #[test] fn hook_run_metadata_maps_stopped_status() { - let home = dirs::home_dir().expect("home dir should be available for analytics tests"); let tracking = TrackEventsContext { model_slug: "gpt-5".to_string(), thread_id: "thread-1".to_string(), @@ -1393,10 +1382,8 @@ fn hook_run_metadata_maps_stopped_status() { &tracking, HookRunFact { event_name: HookEventName::Stop, - source_path: home.join(".codex/hooks.json"), - cwd: home.join("worktree"), + hook_source: CodexHookSource::User, status: HookRunStatus::Stopped, - duration_ms: None, }, )) .expect("serialize stopped hook"); @@ -1497,10 +1484,8 @@ async fn reducer_ingests_hook_run_fact() { }, hook: HookRunFact { event_name: HookEventName::PostToolUse, - source_path: PathBuf::from("/tmp/hooks.json"), - cwd: PathBuf::from("/tmp/project"), + hook_source: CodexHookSource::Unknown, status: HookRunStatus::Failed, - duration_ms: Some(7), }, })), &mut events, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 577a4fb87d1..71e8e7ef540 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,6 @@ use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; +use crate::facts::CodexHookSource; use crate::facts::HookRunFact; use crate::facts::InvocationType; use crate::facts::PluginState; @@ -20,7 +21,6 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; -use std::path::Path; #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] @@ -305,15 +305,6 @@ pub(crate) struct CodexAppUsedEventRequest { pub(crate) event_params: CodexAppMetadata, } -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -pub(crate) enum CodexHookSource { - System, - User, - Project, - Unknown, -} - #[derive(Serialize)] pub(crate) struct CodexHookRunMetadata { pub(crate) thread_id: Option, @@ -568,10 +559,7 @@ pub(crate) fn codex_hook_run_metadata( turn_id: Some(tracking.turn_id.clone()), model_slug: Some(tracking.model_slug.clone()), hook_name: Some(hook.event_name), - hook_source: Some(hook_source_for_path( - hook.source_path.as_path(), - hook.cwd.as_path(), - )), + hook_source: Some(hook.hook_source), status: Some(analytics_hook_status(hook.status)), } } @@ -634,31 +622,6 @@ pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Opt } } -fn hook_source_for_path(source_path: &Path, cwd: &Path) -> CodexHookSource { - if source_path.starts_with("/etc/codex") { - return CodexHookSource::System; - } - - let home = dirs::home_dir(); - if let Some(home) = home - && source_path.starts_with(home.join(".codex")) - { - return CodexHookSource::User; - } - - // Project hooks are loaded from a `.codex/hooks.json` rooted at or above the - // current working directory, so classify by walking cwd ancestors. - if source_path.ends_with(".codex/hooks.json") - && cwd - .ancestors() - .any(|ancestor| source_path.starts_with(ancestor.join(".codex"))) - { - return CodexHookSource::Project; - } - - CodexHookSource::Unknown -} - fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { match status { // Running is unexpected here and normalized defensively. diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index e1a62f85d7a..374fdb85fa1 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -243,6 +243,15 @@ pub enum CompactionStatus { Interrupted, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CodexHookSource { + System, + User, + Project, + Unknown, +} + #[derive(Clone)] pub struct CodexCompactionEvent { pub thread_id: String, @@ -327,10 +336,8 @@ pub(crate) struct HookRunInput { pub struct HookRunFact { pub event_name: HookEventName, - pub source_path: PathBuf, - pub cwd: PathBuf, + pub hook_source: CodexHookSource, pub status: HookRunStatus, - pub duration_ms: Option, } pub(crate) struct PluginUsedInput { diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 03f485a1c19..fb2d7e68035 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -22,6 +22,7 @@ pub use events::GuardianReviewedAction; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; +pub use facts::CodexHookSource; pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 36fc956bf44..4396ead56be 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -411,12 +411,12 @@ fn system_requirements_toml_file() -> io::Result { } #[cfg(unix)] -fn system_config_toml_file() -> io::Result { +pub(crate) fn system_config_toml_file() -> io::Result { AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX)) } #[cfg(windows)] -fn system_config_toml_file() -> io::Result { +pub(crate) fn system_config_toml_file() -> io::Result { windows_system_config_toml_file() } diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 4d036e1fea0..c9318956d3f 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -1,6 +1,8 @@ use std::future::Future; +use std::path::Path; use std::sync::Arc; +use codex_analytics::CodexHookSource; use codex_analytics::HookRunFact; use codex_analytics::build_track_events_context; use codex_hooks::PostToolUseOutcome; @@ -24,6 +26,7 @@ use serde_json::Value; use crate::codex::Session; use crate::codex::TurnContext; +use crate::config_loader::system_config_toml_file; use crate::event_mapping::parse_turn_item; pub(crate) struct HookRuntimeOutcome { @@ -358,14 +361,41 @@ fn hook_run_analytics_payload( ), HookRunFact { event_name: completed.run.event_name, - source_path: completed.run.source_path.to_path_buf(), - cwd: turn_context.cwd.to_path_buf(), + hook_source: hook_source_for_path( + completed.run.source_path.as_path(), + turn_context.config.codex_home.as_path(), + turn_context.cwd.as_path(), + ), status: completed.run.status, - duration_ms: completed.run.duration_ms, }, ) } +fn hook_source_for_path(source_path: &Path, codex_home: &Path, cwd: &Path) -> CodexHookSource { + if let Ok(system_config_path) = system_config_toml_file() + && let Some(system_config_dir) = system_config_path.as_path().parent() + && source_path.starts_with(system_config_dir) + { + return CodexHookSource::System; + } + + if source_path.starts_with(codex_home) { + return CodexHookSource::User; + } + + // Project hooks are loaded from a `.codex/hooks.json` rooted at or above the + // current working directory, so classify by walking cwd ancestors. + if source_path.ends_with(Path::new(".codex").join("hooks.json")) + && cwd + .ancestors() + .any(|ancestor| source_path.starts_with(ancestor.join(".codex"))) + { + return CodexHookSource::Project; + } + + CodexHookSource::Unknown +} + fn hook_permission_mode(turn_context: &TurnContext) -> String { match turn_context.approval_policy.value() { AskForApproval::Never => "bypassPermissions", @@ -379,6 +409,7 @@ fn hook_permission_mode(turn_context: &TurnContext) -> String { #[cfg(test)] mod tests { + use codex_analytics::CodexHookSource; use codex_protocol::models::ContentItem; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookExecutionMode; @@ -386,7 +417,6 @@ mod tests { use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookScope; use pretty_assertions::assert_eq; - use std::path::PathBuf; use super::additional_context_messages; use super::hook_run_analytics_payload; @@ -445,10 +475,8 @@ mod tests { assert_eq!(tracking.turn_id, "turn-from-hook"); assert_eq!(tracking.model_slug, turn_context.model_info.slug); assert_eq!(hook.event_name, HookEventName::Stop); - assert_eq!(hook.source_path, PathBuf::from("/tmp/hooks.json")); - assert_eq!(hook.cwd, turn_context.cwd.to_path_buf()); + assert_eq!(hook.hook_source, CodexHookSource::Unknown); assert_eq!(hook.status, HookRunStatus::Blocked); - assert_eq!(hook.duration_ms, Some(27)); } #[tokio::test] @@ -466,6 +494,53 @@ mod tests { assert_eq!(hook.status, HookRunStatus::Failed); } + #[test] + fn hook_source_for_path_classifies_user_project_and_unknown() { + let codex_home = test_path_buf("/tmp/custom-codex-home").abs(); + let cwd = test_path_buf("/tmp/worktree/src").abs(); + + let system_hooks_path = super::system_config_toml_file() + .expect("system config path") + .parent() + .expect("system config directory") + .join("hooks.json"); + + assert_eq!( + super::hook_source_for_path( + system_hooks_path.as_path(), + codex_home.as_path(), + cwd.as_path(), + ), + CodexHookSource::System, + ); + assert_eq!( + super::hook_source_for_path( + codex_home.join("hooks.json").as_path(), + codex_home.as_path(), + cwd.as_path(), + ), + CodexHookSource::User, + ); + assert_eq!( + super::hook_source_for_path( + test_path_buf("/tmp/worktree/.codex/hooks.json") + .abs() + .as_path(), + codex_home.as_path(), + cwd.as_path(), + ), + CodexHookSource::Project, + ); + assert_eq!( + super::hook_source_for_path( + test_path_buf("/tmp/hooks.json").abs().as_path(), + codex_home.as_path(), + cwd.as_path(), + ), + CodexHookSource::Unknown, + ); + } + fn sample_hook_run(status: HookRunStatus) -> HookRunSummary { HookRunSummary { id: "stop:0:/tmp/hooks.json".to_string(), From bc67544123498a29e647f7f2afacfb2c9e7e0799 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 15:44:56 -0700 Subject: [PATCH 5/7] Use explicit analytics hook names Co-authored-by: Codex --- codex-rs/analytics/src/analytics_client_tests.rs | 4 ++-- codex-rs/analytics/src/events.rs | 5 ++--- codex-rs/protocol/src/protocol.rs | 12 ++++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 77b91b56a77..f57941670f0 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1318,7 +1318,7 @@ fn hook_run_event_serializes_expected_shape() { "thread_id": "thread-3", "turn_id": "turn-3", "model_slug": "gpt-5", - "hook_name": "pre_tool_use", + "hook_name": "PreToolUse", "hook_source": "user", "status": "completed" } @@ -1495,7 +1495,7 @@ async fn reducer_ingests_hook_run_fact() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_hook_run"); - assert_eq!(payload[0]["event_params"]["hook_name"], "post_tool_use"); + assert_eq!(payload[0]["event_params"]["hook_name"], "PostToolUse"); assert_eq!(payload[0]["event_params"]["hook_source"], "unknown"); assert_eq!(payload[0]["event_params"]["status"], "failed"); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 71e8e7ef540..eabf1cbfafc 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -17,7 +17,6 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxPermissions; -use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::SubAgentSource; use serde::Serialize; @@ -310,7 +309,7 @@ pub(crate) struct CodexHookRunMetadata { pub(crate) thread_id: Option, pub(crate) turn_id: Option, pub(crate) model_slug: Option, - pub(crate) hook_name: Option, + pub(crate) hook_name: Option, pub(crate) hook_source: Option, pub(crate) status: Option, } @@ -558,7 +557,7 @@ pub(crate) fn codex_hook_run_metadata( thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), model_slug: Some(tracking.model_slug.clone()), - hook_name: Some(hook.event_name), + hook_name: Some(hook.event_name.analytics_name().to_owned()), hook_source: Some(hook.hook_source), status: Some(analytics_hook_status(hook.status)), } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 244f748c820..71d916aaedb 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1609,6 +1609,18 @@ pub enum HookEventName { Stop, } +impl HookEventName { + pub fn analytics_name(self) -> &'static str { + match self { + Self::PreToolUse => "PreToolUse", + Self::PostToolUse => "PostToolUse", + Self::SessionStart => "SessionStart", + Self::UserPromptSubmit => "UserPromptSubmit", + Self::Stop => "Stop", + } + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookHandlerType { From d57b4e15da337e31ae92e2193d86e21787bd6d81 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 16:28:46 -0700 Subject: [PATCH 6/7] Fix hook source classification for user hooks --- codex-rs/core/src/hook_runtime.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index c9318956d3f..7f61e430e99 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -379,7 +379,7 @@ fn hook_source_for_path(source_path: &Path, codex_home: &Path, cwd: &Path) -> Co return CodexHookSource::System; } - if source_path.starts_with(codex_home) { + if source_path == codex_home.join("hooks.json") { return CodexHookSource::User; } From cb9fdd1718fee81b10da770cbac545889f54e75b Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 15 Apr 2026 16:30:22 -0700 Subject: [PATCH 7/7] Emit analytics for stop hook completions --- codex-rs/core/src/codex.rs | 7 +++---- codex-rs/core/src/hook_runtime.rs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index cc314a66e9b..e347af30719 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; use crate::connectors; use crate::exec_policy::ExecPolicyManager; +use crate::hook_runtime::emit_hook_completed_events; use crate::installation_id::resolve_installation_id; use crate::mcp_tool_exposure::build_mcp_tool_exposure; use crate::parse_turn_item; @@ -6549,10 +6550,8 @@ pub(crate) async fn run_turn( .await; } let stop_outcome = sess.hooks().run_stop(stop_request).await; - for completed in stop_outcome.hook_events { - sess.send_event(&turn_context, EventMsg::HookCompleted(completed)) - .await; - } + emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) + .await; if stop_outcome.should_block { if let Some(hook_prompt_message) = build_hook_prompt_message(&stop_outcome.continuation_fragments) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 7f61e430e99..f09059eba10 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -321,7 +321,7 @@ async fn emit_hook_started_events( } } -async fn emit_hook_completed_events( +pub(crate) async fn emit_hook_completed_events( sess: &Arc, turn_context: &Arc, completed_events: Vec,