Add plugin script lifecycle analytics contract

This commit is contained in:
Kyle Brown
2026-07-10 00:06:45 +00:00
parent a1ad2a78ff
commit 53474dbee2
6 changed files with 263 additions and 0 deletions
@@ -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();
+9
View File
@@ -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)),
+31
View File
@@ -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<CodexCompactionEventRequest>),
Goal(Box<CodexGoalEventRequest>),
PluginScriptLifecycle(Box<CodexPluginScriptLifecycleEventRequest>),
TurnEvent(Box<CodexTurnEventRequest>),
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<ThreadSource>,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) exit_code: Option<i32>,
pub(crate) skill_id: Option<String>,
}
#[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,
+30
View File
@@ -446,6 +446,35 @@ pub struct CodexGoalEvent {
pub cumulative_time_accounted_seconds: Option<i64>,
}
#[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<u64>,
pub exit_code: Option<i32>,
pub skill: Option<PluginScriptSkill>,
}
#[allow(dead_code)]
pub(crate) enum AnalyticsFact {
Initialize {
@@ -499,6 +528,7 @@ pub(crate) enum CustomAnalyticsFact {
SubAgentThreadStarted(SubAgentThreadStartedInput),
Compaction(Box<CodexCompactionEvent>),
Goal(Box<CodexGoalEvent>),
PluginScriptLifecycle(Box<CodexPluginScriptLifecycleEvent>),
GuardianReview(Box<GuardianReviewEventParams>),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnTokenUsage(Box<TurnTokenUsageFact>),
+3
View File
@@ -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;
+70
View File
@@ -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<TrackEventRequest>,
) {
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,