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

@@ -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;
@@ -316,17 +318,52 @@ async fn emit_hook_started_events(
}
}
async fn emit_hook_completed_events(
pub(crate) async fn emit_hook_completed_events(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
completed_events: Vec<HookCompletedEvent>,
) {
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<Session>,
turn_context: &Arc<TurnContext>,
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,
hook_source: completed.run.source,
status: completed.run.status,
},
)
}
fn hook_permission_mode(turn_context: &TurnContext) -> String {
match turn_context.approval_policy.value() {
AskForApproval::Never => "bypassPermissions",
@@ -341,9 +378,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 codex_protocol::protocol::HookSource;
use pretty_assertions::assert_eq;
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 +427,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, HookSource::Project),
};
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.hook_source, HookSource::Project);
assert_eq!(hook.status, HookRunStatus::Blocked);
}
#[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, HookSource::Unknown),
};
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.hook_source, HookSource::Unknown);
assert_eq!(hook.status, HookRunStatus::Failed);
}
fn sample_hook_run(status: HookRunStatus, source: HookSource) -> 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(),
source,
display_order: 0,
status,
status_message: None,
started_at: 10,
completed_at: Some(37),
duration_ms: Some(27),
entries: Vec::new(),
}
}
}