Add codex_hook_run analytics event (#17996)

# Why
Add product analytics for hook handler executions so we can understand
which hooks are running, where they came from, and whether they
completed, failed, stopped, or blocked work.

# What
- add the new `codex_hook_run` analytics event and payload plumbing in
`codex-rs/analytics`
- emit hook-run analytics from the shared hook completion path in
`codex-rs/core`
- classify hook source from the loaded hook path as `system`, `user`,
`project`, or `unknown`

```
{
  "event_type": "codex_hook_run",
  "event_params": {
    "thread_id": "string",
    "turn_id": "string",
    "model_slug": "string",
    "hook_name": "string, // any HookEventName
    "hook_source": "system | user | project | unknown",
    "status": "completed | failed | stopped | blocked"
  }
}
```

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-16 12:43:16 -07:00
committed by GitHub
parent 62847e7554
commit 8720b7bdce
32 changed files with 682 additions and 114 deletions

View File

@@ -9,6 +9,8 @@ use super::config::HooksFile;
use super::config::MatcherGroup;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use codex_config::ConfigLayerSource;
use codex_protocol::protocol::HookSource;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,
@@ -93,6 +95,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut warnings,
&mut display_order,
&source_path,
hook_source_for_config_layer_source(&layer.name),
event_name,
groups,
);
@@ -102,95 +105,94 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &AbsolutePathBuf,
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
return;
}
for handler in group_handlers {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.clone(),
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
}
}
}
fn append_matcher_groups(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
source_path: &AbsolutePathBuf,
source: HookSource,
event_name: codex_protocol::protocol::HookEventName,
groups: Vec<MatcherGroup>,
) {
for group in groups {
append_group_handlers(
handlers,
warnings,
display_order,
source_path,
event_name,
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
group.hooks,
);
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
source_path.display()
));
continue;
}
for handler in group.hooks {
match handler {
HookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => {
if r#async {
warnings.push(format!(
"skipping async hook in {}: async hooks are not supported yet",
source_path.display()
));
continue;
}
if command.trim().is_empty() {
warnings.push(format!(
"skipping empty hook command in {}",
source_path.display()
));
continue;
}
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
matcher: matcher.map(ToOwned::to_owned),
command,
timeout_sec,
status_message,
source_path: source_path.clone(),
source,
display_order: *display_order,
});
*display_order += 1;
}
HookHandlerConfig::Prompt {} => warnings.push(format!(
"skipping prompt hook in {}: prompt hooks are not supported yet",
source_path.display()
)),
HookHandlerConfig::Agent {} => warnings.push(format!(
"skipping agent hook in {}: agent hooks are not supported yet",
source_path.display()
)),
}
}
}
}
fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource {
match source {
ConfigLayerSource::System { .. } => HookSource::System,
ConfigLayerSource::User { .. } => HookSource::User,
ConfigLayerSource::Project { .. } => HookSource::Project,
ConfigLayerSource::Mdm { .. } => HookSource::Mdm,
ConfigLayerSource::SessionFlags => HookSource::SessionFlags,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => {
HookSource::LegacyManagedConfigFile
}
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => HookSource::LegacyManagedConfigMdm,
}
}
#[cfg(test)]
mod tests {
use codex_config::ConfigLayerSource;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
@@ -198,32 +200,43 @@ mod tests {
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::append_group_handlers;
use crate::events::common::matcher_pattern_for_event;
use super::MatcherGroup;
use super::append_matcher_groups;
fn source_path() -> AbsolutePathBuf {
test_path_buf("/tmp/hooks.json").abs()
}
fn hook_source() -> HookSource {
HookSource::User
}
fn command_group(matcher: Option<&str>) -> MatcherGroup {
MatcherGroup {
matcher: matcher.map(str::to_string),
hooks: vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}
}
#[test]
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
HookEventName::UserPromptSubmit,
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
vec![command_group(Some("["))],
);
assert_eq!(warnings, Vec::<String>::new());
@@ -236,6 +249,7 @@ mod tests {
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source: hook_source(),
display_order: 0,
}]
);
@@ -247,19 +261,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
vec![command_group(Some("^Bash$"))],
);
assert_eq!(warnings, Vec::<String>::new());
@@ -272,6 +281,7 @@ mod tests {
timeout_sec: 600,
status_message: None,
source_path: source_path(),
source: hook_source(),
display_order: 0,
}]
);
@@ -283,19 +293,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
vec![command_group(Some("*"))],
);
assert_eq!(warnings, Vec::<String>::new());
@@ -309,19 +314,14 @@ mod tests {
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
append_matcher_groups(
&mut handlers,
&mut warnings,
&mut display_order,
&source_path(),
hook_source(),
HookEventName::PostToolUse,
matcher_pattern_for_event(HookEventName::PostToolUse, Some("Edit|Write")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
vec![command_group(Some("Edit|Write"))],
);
assert_eq!(warnings, Vec::<String>::new());
@@ -329,4 +329,52 @@ mod tests {
assert_eq!(handlers[0].event_name, HookEventName::PostToolUse);
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
}
#[test]
fn hook_source_for_config_layer_source_discards_source_details() {
let config_file = test_path_buf("/tmp/.codex/config.toml").abs();
let dot_codex_folder = test_path_buf("/tmp/worktree/.codex").abs();
assert_eq!(
super::hook_source_for_config_layer_source(&ConfigLayerSource::System {
file: config_file.clone(),
}),
HookSource::System,
);
assert_eq!(
super::hook_source_for_config_layer_source(&ConfigLayerSource::User {
file: config_file.clone(),
}),
HookSource::User,
);
assert_eq!(
super::hook_source_for_config_layer_source(&ConfigLayerSource::Project {
dot_codex_folder
}),
HookSource::Project,
);
assert_eq!(
super::hook_source_for_config_layer_source(&ConfigLayerSource::Mdm {
domain: "com.openai.codex".to_string(),
key: "config".to_string(),
}),
HookSource::Mdm,
);
assert_eq!(
super::hook_source_for_config_layer_source(&ConfigLayerSource::SessionFlags),
HookSource::SessionFlags,
);
assert_eq!(
super::hook_source_for_config_layer_source(
&ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config_file },
),
HookSource::LegacyManagedConfigFile,
);
assert_eq!(
super::hook_source_for_config_layer_source(
&ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
),
HookSource::LegacyManagedConfigMdm,
);
}
}

View File

@@ -50,6 +50,7 @@ pub(crate) fn running_summary(handler: &ConfiguredHandler) -> HookRunSummary {
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
source: handler.source,
display_order: handler.display_order,
status: HookRunStatus::Running,
status_message: handler.status_message.clone(),
@@ -95,6 +96,7 @@ pub(crate) fn completed_summary(
execution_mode: HookExecutionMode::Sync,
scope: scope_for_event(handler.event_name),
source_path: handler.source_path.clone(),
source: handler.source,
display_order: handler.display_order,
status,
status_message: handler.status_message.clone(),
@@ -118,6 +120,7 @@ fn scope_for_event(event_name: HookEventName) -> HookScope {
#[cfg(test)]
mod tests {
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
@@ -137,6 +140,7 @@ mod tests {
timeout_sec: 5,
status_message: None,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: HookSource::User,
display_order,
}
}

View File

@@ -7,6 +7,7 @@ pub(crate) mod schema_loader;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::events::post_tool_use::PostToolUseOutcome;
@@ -34,6 +35,7 @@ pub(crate) struct ConfiguredHandler {
pub timeout_sec: u64,
pub status_message: Option<String>,
pub source_path: AbsolutePathBuf,
pub source: HookSource,
pub display_order: i64,
}