diff --git a/codex-rs/analytics/src/analytics_client.rs b/codex-rs/analytics/src/analytics_client.rs index a83aa13ab0..4d62186753 100644 --- a/codex-rs/analytics/src/analytics_client.rs +++ b/codex-rs/analytics/src/analytics_client.rs @@ -11,11 +11,11 @@ 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::CodexErrorInfo; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; -use codex_protocol::protocol::SubmissionType; use serde::Serialize; use sha1::Digest; use sha1::Sha1; @@ -37,7 +37,7 @@ pub struct TrackEventsContext { #[derive(Clone)] pub struct CodexTurnEvent { - pub submission_type: Option, + pub submission_type: Option, pub model_provider: String, pub sandbox_policy: SandboxPolicy, pub reasoning_effort: Option, @@ -50,18 +50,40 @@ pub struct CodexTurnEvent { pub personality: Option, pub num_input_images: usize, pub is_first_turn: bool, + pub status: Option, + pub turn_error: Option, + pub steer_count: Option, + pub total_tool_call_count: Option, + pub shell_command_count: Option, + pub file_change_count: Option, + pub mcp_tool_call_count: Option, + pub dynamic_tool_call_count: Option, + pub subagent_tool_call_count: Option, + pub web_search_count: Option, + pub image_generation_count: Option, + pub input_tokens: Option, + pub cached_input_tokens: Option, + pub output_tokens: Option, + pub reasoning_output_tokens: Option, + pub total_tokens: Option, + pub duration_ms: Option, + pub started_at: Option, + pub completed_at: Option, } -#[derive(Clone)] -pub struct CodexThreadInitializedEvent { - pub thread_id: String, - pub model: String, - pub ephemeral: bool, - pub session_source: SessionSource, - pub initialization_mode: InitializationMode, - pub subagent_source: Option, - pub parent_thread_id: Option, - pub created_at: u64, +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnSubmissionType { + Default, + Queued, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TurnStatus { + Completed, + Failed, + Interrupted, } #[derive(Clone)] @@ -124,7 +146,7 @@ pub struct AppInvocation { } pub enum AnalyticsInput { - ThreadInitialized(CodexThreadInitializedInput), + CodexThreadInitialized(CodexThreadInitializedInput), TurnEvent(TurnEventInput), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), @@ -176,7 +198,7 @@ pub struct AnalyticsReducer { } struct ThreadState { - _initialized_event: CodexThreadInitializedEvent, + _initialized_input: CodexThreadInitializedInput, } #[derive(Clone)] @@ -274,7 +296,7 @@ impl AnalyticsEventsClient { } pub fn track_thread_initialized(&self, input: CodexThreadInitializedInput) { - self.record(AnalyticsInput::ThreadInitialized(input)); + self.record(AnalyticsInput::CodexThreadInitialized(input)); } pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { @@ -368,10 +390,10 @@ struct TrackEventsRequest { #[serde(untagged)] enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), - ThreadInitialized(CodexThreadInitializedEventRequest), + CodexThreadInitialized(CodexThreadInitializedEvent), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), - TurnEvent(CodexTurnEventRequest), + TurnEvent(Box), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -411,7 +433,7 @@ struct CodexThreadInitializedEventParams { } #[derive(Serialize)] -struct CodexThreadInitializedEventRequest { +struct CodexThreadInitializedEvent { event_type: &'static str, event_params: CodexThreadInitializedEventParams, } @@ -444,8 +466,8 @@ struct CodexTurnEventParams { thread_id: String, turn_id: String, product_client_id: Option, + submission_type: Option, model: Option, - submission_type: Option, model_provider: String, sandbox_policy: Option<&'static str>, reasoning_effort: Option, @@ -458,6 +480,25 @@ struct CodexTurnEventParams { personality: Option, num_input_images: usize, is_first_turn: bool, + status: Option, + turn_error: Option, + steer_count: Option, + total_tool_call_count: Option, + shell_command_count: Option, + file_change_count: Option, + mcp_tool_call_count: Option, + dynamic_tool_call_count: Option, + subagent_tool_call_count: Option, + web_search_count: Option, + image_generation_count: Option, + input_tokens: Option, + cached_input_tokens: Option, + output_tokens: Option, + reasoning_output_tokens: Option, + total_tokens: Option, + duration_ms: Option, + started_at: Option, + completed_at: Option, } #[derive(Serialize)] @@ -501,7 +542,7 @@ struct CodexPluginUsedEventRequest { impl AnalyticsReducer { async fn ingest(&mut self, input: AnalyticsInput, out: &mut Vec) { match input { - AnalyticsInput::ThreadInitialized(input) => { + AnalyticsInput::CodexThreadInitialized(input) => { self.ingest_thread_initialized(input, out); } AnalyticsInput::TurnEvent(input) => { @@ -530,25 +571,14 @@ impl AnalyticsReducer { input: CodexThreadInitializedInput, out: &mut Vec, ) { - let product_client_id = input.product_client_id.clone(); - let event = CodexThreadInitializedEvent { - thread_id: input.thread_id, - model: input.model, - ephemeral: input.thread_context.ephemeral, - session_source: input.thread_context.session_source, - initialization_mode: input.thread_context.initialization_mode, - subagent_source: input.thread_context.subagent_source, - parent_thread_id: input.thread_context.parent_thread_id, - created_at: input.created_at, - }; self.threads.insert( - event.thread_id.clone(), + input.thread_id.clone(), ThreadState { - _initialized_event: event.clone(), + _initialized_input: input.clone(), }, ); - out.push(TrackEventRequest::ThreadInitialized( - codex_thread_initialized_event_request(product_client_id, event), + out.push(TrackEventRequest::CodexThreadInitialized( + codex_thread_initialized_event_request(input), )); } @@ -557,10 +587,12 @@ impl AnalyticsReducer { tracking, turn_event, } = input; - out.push(TrackEventRequest::TurnEvent(CodexTurnEventRequest { - event_type: "codex_turn_event", - event_params: codex_turn_event_params(&tracking, turn_event), - })); + out.push(TrackEventRequest::TurnEvent(Box::new( + CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params(&tracking, turn_event), + }, + ))); } async fn ingest_skill_invoked( @@ -686,8 +718,8 @@ fn codex_turn_event_params( thread_id: tracking.thread_id.clone(), turn_id: tracking.turn_id.clone(), product_client_id: Some(originator().value), - model: Some(tracking.model_slug.clone()), submission_type: turn_event.submission_type, + model: Some(tracking.model_slug.clone()), model_provider: turn_event.model_provider, sandbox_policy: Some(sandbox_policy_mode(&turn_event.sandbox_policy)), reasoning_effort: turn_event.reasoning_effort.map(|value| value.to_string()), @@ -703,6 +735,25 @@ fn codex_turn_event_params( personality: personality_mode(turn_event.personality), num_input_images: turn_event.num_input_images, is_first_turn: turn_event.is_first_turn, + status: turn_event.status, + turn_error: turn_event.turn_error, + steer_count: turn_event.steer_count, + total_tool_call_count: turn_event.total_tool_call_count, + shell_command_count: turn_event.shell_command_count, + file_change_count: turn_event.file_change_count, + mcp_tool_call_count: turn_event.mcp_tool_call_count, + dynamic_tool_call_count: turn_event.dynamic_tool_call_count, + subagent_tool_call_count: turn_event.subagent_tool_call_count, + web_search_count: turn_event.web_search_count, + image_generation_count: turn_event.image_generation_count, + input_tokens: turn_event.input_tokens, + cached_input_tokens: turn_event.cached_input_tokens, + output_tokens: turn_event.output_tokens, + reasoning_output_tokens: turn_event.reasoning_output_tokens, + total_tokens: turn_event.total_tokens, + duration_ms: turn_event.duration_ms, + started_at: turn_event.started_at, + completed_at: turn_event.completed_at, } } @@ -735,33 +786,32 @@ fn personality_mode(personality: Option) -> Option { Some(personality) => Some(personality.to_string()), } } + fn codex_thread_initialized_event_request( - product_client_id: String, - thread_event: CodexThreadInitializedEvent, -) -> CodexThreadInitializedEventRequest { - CodexThreadInitializedEventRequest { + input: CodexThreadInitializedInput, +) -> CodexThreadInitializedEvent { + CodexThreadInitializedEvent { event_type: "codex_thread_initialized", - event_params: codex_thread_initialized_event_params_with_product_client_id( - product_client_id, - thread_event, - ), + event_params: codex_thread_initialized_event_params(input), } } -fn codex_thread_initialized_event_params_with_product_client_id( - product_client_id: String, - thread_event: CodexThreadInitializedEvent, +fn codex_thread_initialized_event_params( + input: CodexThreadInitializedInput, ) -> CodexThreadInitializedEventParams { CodexThreadInitializedEventParams { - thread_id: thread_event.thread_id, - product_client_id, - model: thread_event.model, - ephemeral: thread_event.ephemeral, - session_source: session_source_name(&thread_event.session_source), - initialization_mode: thread_event.initialization_mode, - subagent_source: thread_event.subagent_source.map(subagent_source_name), - parent_thread_id: thread_event.parent_thread_id, - created_at: thread_event.created_at, + thread_id: input.thread_id, + product_client_id: input.product_client_id, + model: input.model, + ephemeral: input.thread_context.ephemeral, + session_source: session_source_name(&input.thread_context.session_source), + initialization_mode: input.thread_context.initialization_mode, + subagent_source: input + .thread_context + .subagent_source + .map(subagent_source_name), + parent_thread_id: input.thread_context.parent_thread_id, + created_at: input.created_at, } } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 86e3973f33..d5b7a04546 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -4,14 +4,15 @@ use super::CodexAppMentionedEventRequest; use super::CodexAppUsedEventRequest; use super::CodexPluginEventRequest; use super::CodexPluginUsedEventRequest; -use super::CodexThreadInitializedEvent; +use super::CodexThreadContext; +use super::CodexThreadInitializedInput; use super::CodexTurnEvent; use super::CodexTurnEventRequest; use super::InitializationMode; use super::InvocationType; -use super::SubmissionType; use super::TrackEventRequest; use super::TrackEventsContext; +use super::TurnSubmissionType; use super::codex_app_metadata; use super::codex_plugin_metadata; use super::codex_plugin_used_metadata; @@ -210,12 +211,12 @@ fn turn_event_serializes_expected_shape() { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), }; - let event = TrackEventRequest::TurnEvent(CodexTurnEventRequest { + let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { event_type: "codex_turn_event", event_params: codex_turn_event_params( &tracking, CodexTurnEvent { - submission_type: Some(SubmissionType::Prompt), + submission_type: Some(TurnSubmissionType::Default), model_provider: "openai".to_string(), sandbox_policy: SandboxPolicy::new_read_only_policy(), reasoning_effort: Some(ReasoningEffort::High), @@ -228,9 +229,28 @@ fn turn_event_serializes_expected_shape() { personality: Some(Personality::Pragmatic), num_input_images: 2, is_first_turn: true, + status: None, + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, + duration_ms: None, + started_at: None, + completed_at: None, }, ), - }); + })); let payload = serde_json::to_value(&event).expect("serialize turn event"); @@ -243,7 +263,7 @@ fn turn_event_serializes_expected_shape() { "turn_id": "turn-2", "product_client_id": originator().value, "model": "gpt-5", - "submission_type": "prompt", + "submission_type": "default", "model_provider": "openai", "sandbox_policy": "read_only", "reasoning_effort": "high", @@ -255,7 +275,26 @@ fn turn_event_serializes_expected_shape() { "collaboration_mode": "plan", "personality": "pragmatic", "num_input_images": 2, - "is_first_turn": true + "is_first_turn": true, + "status": null, + "turn_error": null, + "steer_count": null, + "total_tool_call_count": null, + "shell_command_count": null, + "file_change_count": null, + "mcp_tool_call_count": null, + "dynamic_tool_call_count": null, + "subagent_tool_call_count": null, + "web_search_count": null, + "image_generation_count": null, + "input_tokens": null, + "cached_input_tokens": null, + "output_tokens": null, + "reasoning_output_tokens": null, + "total_tokens": null, + "duration_ms": null, + "started_at": null, + "completed_at": null } }) ); @@ -263,17 +302,19 @@ fn turn_event_serializes_expected_shape() { #[test] fn thread_initialized_event_serializes_expected_shape() { - let event = TrackEventRequest::ThreadInitialized(codex_thread_initialized_event_request( - originator().value, - CodexThreadInitializedEvent { + let event = TrackEventRequest::CodexThreadInitialized(codex_thread_initialized_event_request( + CodexThreadInitializedInput { thread_id: "thread-0".to_string(), model: "gpt-5".to_string(), - ephemeral: true, - session_source: SessionSource::Exec, - initialization_mode: InitializationMode::New, - subagent_source: None, - parent_thread_id: None, + product_client_id: originator().value, created_at: 1_716_000_000, + thread_context: CodexThreadContext { + ephemeral: true, + session_source: SessionSource::Exec, + initialization_mode: InitializationMode::New, + subagent_source: None, + parent_thread_id: None, + }, }, )); @@ -300,17 +341,19 @@ fn thread_initialized_event_serializes_expected_shape() { #[test] fn thread_initialized_event_serializes_subagent_source() { - let event = TrackEventRequest::ThreadInitialized(codex_thread_initialized_event_request( - originator().value, - CodexThreadInitializedEvent { + let event = TrackEventRequest::CodexThreadInitialized(codex_thread_initialized_event_request( + CodexThreadInitializedInput { thread_id: "thread-1".to_string(), model: "gpt-5".to_string(), - ephemeral: false, - session_source: SessionSource::SubAgent(SubAgentSource::Review), - initialization_mode: InitializationMode::New, - subagent_source: Some(SubAgentSource::Review), - parent_thread_id: None, + product_client_id: originator().value, created_at: 1, + thread_context: CodexThreadContext { + ephemeral: false, + session_source: SessionSource::SubAgent(SubAgentSource::Review), + initialization_mode: InitializationMode::New, + subagent_source: Some(SubAgentSource::Review), + parent_thread_id: None, + }, }, )); @@ -322,17 +365,19 @@ fn thread_initialized_event_serializes_subagent_source() { #[test] fn thread_initialized_event_omits_non_user_non_subagent_session_source() { - let event = TrackEventRequest::ThreadInitialized(codex_thread_initialized_event_request( - originator().value, - CodexThreadInitializedEvent { + let event = TrackEventRequest::CodexThreadInitialized(codex_thread_initialized_event_request( + CodexThreadInitializedInput { thread_id: "thread-2".to_string(), model: "gpt-5".to_string(), - ephemeral: false, - session_source: SessionSource::Mcp, - initialization_mode: InitializationMode::New, - subagent_source: None, - parent_thread_id: None, + product_client_id: originator().value, created_at: 1, + thread_context: CodexThreadContext { + ephemeral: false, + session_source: SessionSource::Mcp, + initialization_mode: InitializationMode::New, + subagent_source: None, + parent_thread_id: None, + }, }, )); diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 2a486cdc67..5956ad1c7f 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -7,7 +7,6 @@ pub use analytics_client::AppInvocation; pub use analytics_client::AppMentionedInput; pub use analytics_client::AppUsedInput; pub use analytics_client::CodexThreadContext; -pub use analytics_client::CodexThreadInitializedEvent; pub use analytics_client::CodexThreadInitializedInput; pub use analytics_client::CodexTurnEvent; pub use analytics_client::InitializationMode; @@ -18,4 +17,6 @@ pub use analytics_client::PluginUsedInput; pub use analytics_client::SkillInvocation; pub use analytics_client::SkillInvokedInput; pub use analytics_client::TrackEventsContext; +pub use analytics_client::TurnStatus; +pub use analytics_client::TurnSubmissionType; pub use analytics_client::build_track_events_context; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 75a517a57a..2f07506644 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -357,6 +357,7 @@ use codex_analytics::CodexThreadInitializedInput; use codex_analytics::CodexTurnEvent; use codex_analytics::InitializationMode; use codex_analytics::InvocationType; +use codex_analytics::TurnSubmissionType; use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_git_utils::get_git_repo_root; @@ -836,6 +837,13 @@ fn session_source_parent_thread_id(session_source: &SessionSource) -> Option TurnSubmissionType { + match submission_type { + SubmissionType::Prompt => TurnSubmissionType::Default, + SubmissionType::QueuedPrompt => TurnSubmissionType::Queued, + } +} + #[cfg(test)] pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination { futures::future::ready(()).boxed().shared() @@ -6169,7 +6177,8 @@ pub(crate) async fn run_turn( if !input.is_empty() { let submission_type = turn_context .submission_type - .unwrap_or(SubmissionType::Prompt); + .map(turn_submission_type) + .unwrap_or(TurnSubmissionType::Default); let is_first_turn = { let mut state = sess.state.lock().await; state.take_next_turn_is_first() @@ -6195,6 +6204,25 @@ pub(crate) async fn run_turn( }) .count(), is_first_turn, + status: None, + turn_error: None, + steer_count: None, + total_tool_call_count: None, + shell_command_count: None, + file_change_count: None, + mcp_tool_call_count: None, + dynamic_tool_call_count: None, + subagent_tool_call_count: None, + web_search_count: None, + image_generation_count: None, + input_tokens: None, + cached_input_tokens: None, + output_tokens: None, + reasoning_output_tokens: None, + total_tokens: None, + duration_ms: None, + started_at: None, + completed_at: None, }, ); } diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/analytics.rs similarity index 100% rename from codex-rs/core/tests/suite/thread_metadata.rs rename to codex-rs/core/tests/suite/analytics.rs diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 4011e692a8..ff2da246fc 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -58,6 +58,7 @@ pub static CODEX_ALIASES_TEMP_DIR: TestCodexAliasesGuard = unsafe { mod abort_tasks; mod agent_jobs; mod agent_websocket; +mod analytics; mod apply_patch_cli; #[cfg(not(target_os = "windows"))] mod approvals; @@ -127,7 +128,6 @@ mod stream_error_allows_next_turn; mod stream_no_completed; mod subagent_notifications; mod text_encoding_fix; -mod thread_metadata; mod tool_harness; mod tool_parallelism; mod tool_suggest;