diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7b878e952c..c35a97fa82 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -48,6 +48,7 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexErrKind; +use crate::facts::CodexPluginScriptLifecycleEvent; use crate::facts::CompactionImplementation; use crate::facts::CompactionPhase; use crate::facts::CompactionReason; @@ -67,6 +68,8 @@ use crate::facts::PluginInstallRequested; use crate::facts::PluginInstallRequestedInput; use crate::facts::PluginInstallRequestedPlugin; use crate::facts::PluginInstallSource; +use crate::facts::PluginScriptLifecycleStatus; +use crate::facts::PluginScriptSkill; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -3501,6 +3504,123 @@ async fn reducer_includes_plugin_id_for_plugin_skill_invocations() { ); } +#[tokio::test] +async fn reducer_enriches_plugin_script_lifecycle_events_without_sensitive_paths() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + reducer + .ingest( + AnalyticsFact::Initialize { + connection_id: 7, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + request_attestation: false, + opt_out_notification_methods: None, + mcp_server_openai_form_elicitation: false, + }), + }, + product_client_id: DEFAULT_ORIGINATOR.to_string(), + runtime: sample_runtime_metadata(), + rpc_transport: AppServerRpcTransport::Stdio, + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + thread_originator: None, + }, + &mut events, + ) + .await; + events.clear(); + + let sensitive_skill_path = + PathBuf::from("/Users/private/.codex/plugins/cache/openai/sample/skills/doc/SKILL.md"); + let skill = PluginScriptSkill { + skill_name: "sample:doc".to_string(), + skill_path: sensitive_skill_path.clone(), + }; + let expected_skill_id = skill_id_for_local_skill( + /*repo_url*/ None, + /*repo_root*/ None, + sensitive_skill_path.as_path(), + "sample:doc", + ); + for (status, duration_ms, exit_code) in [ + (PluginScriptLifecycleStatus::Started, None, None), + (PluginScriptLifecycleStatus::Completed, Some(42), Some(0)), + ] { + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginScriptLifecycle(Box::new( + CodexPluginScriptLifecycleEvent { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + plugin_id: "sample@openai-curated".to_string(), + execution_id: "execution-1".to_string(), + script_path: "scripts/run.py".to_string(), + timestamp: "2026-06-12T17:39:31.000Z".to_string(), + status, + duration_ms, + exit_code, + skill: Some(skill.clone()), + }, + ))), + &mut events, + ) + .await; + } + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload.as_array().map(Vec::len), Some(2)); + let started = &payload[0]; + let completed = &payload[1]; + assert_eq!(started["event_type"], "codex_plugin_lifecycle_event"); + assert_eq!(started["event_params"]["version"], 1); + assert_eq!(started["event_params"]["thread_id"], "thread-1"); + assert_eq!(started["event_params"]["session_id"], "session-thread-1"); + assert_eq!(started["event_params"]["turn_id"], "turn-1"); + assert_eq!( + started["event_params"]["app_server_client"]["product_client_id"], + DEFAULT_ORIGINATOR + ); + assert_eq!( + started["event_params"]["plugin_id"], + "sample@openai-curated" + ); + assert_eq!(started["event_params"]["execution_id"], "execution-1"); + assert_eq!(started["event_params"]["script_path"], "scripts/run.py"); + assert_eq!( + started["event_params"]["timestamp"], + "2026-06-12T17:39:31.000Z" + ); + assert_eq!(started["event_params"]["status"], "started"); + assert_eq!(started["event_params"]["skill_id"], expected_skill_id); + assert!(started["event_params"].get("duration_ms").is_none()); + assert!(started["event_params"].get("exit_code").is_none()); + + assert_eq!(completed["event_params"]["status"], "completed"); + assert_eq!(completed["event_params"]["duration_ms"], 42); + assert_eq!(completed["event_params"]["exit_code"], 0); + assert_eq!(completed["event_params"]["skill_id"], expected_skill_id); + let serialized = payload.to_string(); + assert!(!serialized.contains("/Users/private")); + assert!(!serialized.contains("SKILL.md")); +} + #[tokio::test] async fn reducer_ingests_hook_run_fact() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 1fd77b0560..9a0feeb5f0 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -338,6 +338,15 @@ impl AnalyticsEventsClient { )))); } + pub fn track_plugin_script_lifecycle( + &self, + event: crate::facts::CodexPluginScriptLifecycleEvent, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginScriptLifecycle(Box::new(event)), + )); + } + pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 999ae3e1f0..ba2f0a19e6 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -15,6 +15,7 @@ use crate::facts::GoalEventKind; use crate::facts::HookRunFact; use crate::facts::InvocationType; use crate::facts::PluginInstallRequested; +use crate::facts::PluginScriptLifecycleStatus; use crate::facts::PluginState; use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; @@ -68,6 +69,7 @@ pub(crate) enum TrackEventRequest { HookRun(CodexHookRunEventRequest), Compaction(Box), Goal(Box), + PluginScriptLifecycle(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), CommandExecution(CodexCommandExecutionEventRequest), @@ -846,6 +848,35 @@ pub(crate) struct CodexGoalEventRequest { pub(crate) event_params: CodexGoalEventParams, } +#[derive(Serialize)] +pub(crate) struct CodexPluginScriptLifecycleEventParams { + pub(crate) version: u32, + pub(crate) thread_id: String, + pub(crate) session_id: String, + pub(crate) turn_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) plugin_id: String, + pub(crate) execution_id: String, + pub(crate) script_path: String, + pub(crate) timestamp: String, + pub(crate) status: PluginScriptLifecycleStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) exit_code: Option, + pub(crate) skill_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginScriptLifecycleEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginScriptLifecycleEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1f3333b8bc..7f590eea00 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -446,6 +446,35 @@ pub struct CodexGoalEvent { pub cumulative_time_accounted_seconds: Option, } +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginScriptLifecycleStatus { + Started, + Completed, + Failed, + Interrupted, +} + +#[derive(Clone, Debug)] +pub struct PluginScriptSkill { + pub skill_name: String, + pub skill_path: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct CodexPluginScriptLifecycleEvent { + pub thread_id: String, + pub turn_id: String, + pub plugin_id: String, + pub execution_id: String, + pub script_path: String, + pub timestamp: String, + pub status: PluginScriptLifecycleStatus, + pub duration_ms: Option, + pub exit_code: Option, + pub skill: Option, +} + #[allow(dead_code)] pub(crate) enum AnalyticsFact { Initialize { @@ -499,6 +528,7 @@ pub(crate) enum CustomAnalyticsFact { SubAgentThreadStarted(SubAgentThreadStartedInput), Compaction(Box), Goal(Box), + PluginScriptLifecycle(Box), GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index d178d8d5bf..5068e3e0ac 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -29,6 +29,7 @@ pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; pub use facts::CodexErrKind; pub use facts::CodexGoalEvent; +pub use facts::CodexPluginScriptLifecycleEvent; pub use facts::CodexTurnSteerEvent; pub use facts::CompactionImplementation; pub use facts::CompactionPhase; @@ -46,6 +47,8 @@ pub use facts::PluginInstallRequestSource; pub use facts::PluginInstallRequested; pub use facts::PluginInstallRequestedPlugin; pub use facts::PluginInstallSource; +pub use facts::PluginScriptLifecycleStatus; +pub use facts::PluginScriptSkill; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 13696d670f..52ac043039 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -29,6 +29,8 @@ use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginInstallFailedEventRequest; use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginInstallRequestedEventRequest; +use crate::events::CodexPluginScriptLifecycleEventParams; +use crate::events::CodexPluginScriptLifecycleEventRequest; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -73,6 +75,7 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexGoalEvent; +use crate::facts::CodexPluginScriptLifecycleEvent; use crate::facts::CustomAnalyticsFact; use crate::facts::ExternalAgentConfigImportCompletedInput; use crate::facts::ExternalAgentConfigImportFailureInput; @@ -231,6 +234,16 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn plugin_script_lifecycle(input: &'a CodexPluginScriptLifecycleEvent) -> Self { + Self { + event_name: "plugin script lifecycle", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: None, + item_id: None, + } + } + fn tool_item( notification: &'a codex_app_server_protocol::ItemCompletedNotification, item_id: &'a str, @@ -506,6 +519,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::Goal(input) => { self.ingest_goal(*input, out); } + CustomAnalyticsFact::PluginScriptLifecycle(input) => { + self.ingest_plugin_script_lifecycle(*input, out).await; + } CustomAnalyticsFact::GuardianReview(input) => { self.ingest_guardian_review(*input, out); } @@ -1433,6 +1449,60 @@ impl AnalyticsReducer { }))); } + async fn ingest_plugin_script_lifecycle( + &mut self, + input: CodexPluginScriptLifecycleEvent, + out: &mut Vec, + ) { + let Some((connection_state, thread_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::plugin_script_lifecycle(&input)) + else { + return; + }; + let skill_id = if let Some(skill) = input.skill.as_ref() { + let repo_root = get_git_repo_root(skill.skill_path.as_path()); + let repo_url = if let Some(root) = repo_root.as_ref() { + collect_git_info(root) + .await + .and_then(|info| info.repository_url) + } else { + None + }; + Some(skill_id_for_local_skill( + repo_url.as_deref(), + repo_root.as_deref(), + skill.skill_path.as_path(), + skill.skill_name.as_str(), + )) + } else { + None + }; + out.push(TrackEventRequest::PluginScriptLifecycle(Box::new( + CodexPluginScriptLifecycleEventRequest { + event_type: "codex_plugin_lifecycle_event", + event_params: CodexPluginScriptLifecycleEventParams { + version: 1, + thread_id: input.thread_id, + session_id: thread_metadata.session_id.clone(), + turn_id: input.turn_id, + app_server_client: thread_state.app_server_client(connection_state), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source.clone(), + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + plugin_id: input.plugin_id, + execution_id: input.execution_id, + script_path: input.script_path, + timestamp: input.timestamp, + status: input.status, + duration_ms: input.duration_ms, + exit_code: input.exit_code, + skill_id, + }, + }, + ))); + } + fn ingest_guardian_review_completed( &mut self, notification: codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification,