mi6_core/input/otel/
codex_event.rs

1//! Codex activity event parsing
2//!
3//! This module handles parsing of Codex CLI activity events (non-token events)
4//! such as conversation_starts, user_prompt, tool_decision, and tool_result.
5
6use chrono::{DateTime, Utc};
7use serde_json::json;
8
9use super::attributes::{get_i64, get_string};
10use super::otlp_types::{KeyValue, LogRecord};
11use crate::model::{Event, EventBuilder, EventType};
12
13/// Codex event types for activity tracking
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CodexEventType {
16    /// Session start with configuration
17    ConversationStarts,
18    /// User submitted a prompt
19    UserPrompt,
20    /// Tool approval decision
21    ToolDecision,
22    /// Tool execution result
23    ToolResult,
24    /// API request metadata (cf_ray, attempt, status_code)
25    ApiRequest,
26}
27
28impl CodexEventType {
29    /// Returns the string representation of the event type
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::ConversationStarts => "conversation_starts",
33            Self::UserPrompt => "user_prompt",
34            Self::ToolDecision => "tool_decision",
35            Self::ToolResult => "tool_result",
36            Self::ApiRequest => "api_request",
37        }
38    }
39}
40
41/// Parsed Codex activity event (non-token events)
42#[derive(Debug)]
43pub struct ParsedCodexEvent {
44    pub event_type: CodexEventType,
45    pub session_id: String,
46    pub timestamp: Option<DateTime<Utc>>,
47    pub model: Option<String>,
48    /// Event-specific metadata as JSON
49    pub metadata: serde_json::Value,
50}
51
52impl ParsedCodexEvent {
53    /// Parse a LogRecord into a Codex activity event
54    pub fn from_log_record(record: &LogRecord) -> Option<Self> {
55        let attributes = record.attributes.as_ref()?;
56
57        let event_name = get_string(attributes, "event.name")?;
58
59        // Determine event type
60        let event_type = match event_name.as_str() {
61            "codex.conversation_starts" => CodexEventType::ConversationStarts,
62            "codex.user_prompt" => CodexEventType::UserPrompt,
63            "codex.tool_decision" => CodexEventType::ToolDecision,
64            "codex.tool_result" => CodexEventType::ToolResult,
65            "codex.api_request" => CodexEventType::ApiRequest,
66            _ => return None,
67        };
68
69        // Get session ID (Codex uses conversation.id)
70        let session_id = get_string(attributes, "conversation.id")?;
71
72        // Parse timestamp from event.timestamp (ISO 8601)
73        let timestamp = get_string(attributes, "event.timestamp").and_then(|ts| {
74            chrono::DateTime::parse_from_rfc3339(&ts)
75                .ok()
76                .map(|dt| dt.with_timezone(&chrono::Utc))
77        });
78
79        // Get model
80        let model = get_string(attributes, "model");
81
82        // Build event-specific metadata
83        let metadata = build_metadata(&event_type, attributes);
84
85        Some(Self {
86            event_type,
87            session_id,
88            timestamp,
89            model,
90            metadata,
91        })
92    }
93
94    /// Convert to an Event with ApiRequest event type (0 tokens for activity events)
95    pub fn into_event(self, machine_id: String) -> Event {
96        let mut metadata_obj = self.metadata.as_object().cloned().unwrap_or_default();
97        metadata_obj.insert("event_type".to_string(), json!(self.event_type.as_str()));
98
99        EventBuilder::new(machine_id, EventType::ApiRequest, self.session_id)
100            .framework("codex")
101            .tokens(0, 0)
102            .metadata(serde_json::Value::Object(metadata_obj).to_string())
103            .timestamp_opt(self.timestamp)
104            .model_opt(self.model)
105            .source("otel")
106            .build()
107    }
108}
109
110/// Build event-specific metadata based on event type
111fn build_metadata(event_type: &CodexEventType, attrs: &[KeyValue]) -> serde_json::Value {
112    let mut obj = serde_json::Map::new();
113
114    match event_type {
115        CodexEventType::ConversationStarts => {
116            insert_if_present(&mut obj, attrs, "approval_policy", "approval_policy");
117            insert_if_present(&mut obj, attrs, "sandbox_policy", "sandbox_policy");
118            insert_if_present(&mut obj, attrs, "reasoning_summary", "reasoning_summary");
119            insert_if_present(&mut obj, attrs, "reasoning_effort", "reasoning_effort");
120            insert_if_present(&mut obj, attrs, "mcp_servers", "mcp_servers");
121            insert_if_present(&mut obj, attrs, "provider_name", "provider");
122            insert_if_present(&mut obj, attrs, "app.version", "app_version");
123            insert_if_present(&mut obj, attrs, "auth_mode", "auth_mode");
124            insert_if_present(&mut obj, attrs, "active_profile", "active_profile");
125            insert_i64_if_present(&mut obj, attrs, "context_window", "context_window");
126            insert_i64_if_present(&mut obj, attrs, "max_output_tokens", "max_output_tokens");
127            insert_i64_if_present(
128                &mut obj,
129                attrs,
130                "auto_compact_token_limit",
131                "auto_compact_token_limit",
132            );
133        }
134        CodexEventType::UserPrompt => {
135            insert_i64_if_present(&mut obj, attrs, "prompt_length", "prompt_length");
136        }
137        CodexEventType::ToolDecision => {
138            insert_if_present(&mut obj, attrs, "tool_name", "tool_name");
139            insert_if_present(&mut obj, attrs, "call_id", "call_id");
140            insert_if_present(&mut obj, attrs, "decision", "decision");
141            insert_if_present(&mut obj, attrs, "decision_type", "decision_type");
142            // Try both "source" (docs) and "decision_source" (observed in logs)
143            if let Some(v) =
144                get_string(attrs, "source").or_else(|| get_string(attrs, "decision_source"))
145            {
146                obj.insert("source".into(), json!(v));
147            }
148        }
149        CodexEventType::ToolResult => {
150            insert_if_present(&mut obj, attrs, "tool_name", "tool_name");
151            insert_if_present(&mut obj, attrs, "call_id", "call_id");
152            if let Some(v) = get_string(attrs, "success") {
153                obj.insert("success".into(), json!(v == "true"));
154            }
155            insert_i64_if_present(&mut obj, attrs, "duration_ms", "duration_ms");
156            insert_i64_if_present(
157                &mut obj,
158                attrs,
159                "tool_result_size_bytes",
160                "result_size_bytes",
161            );
162            // Don't include 'output' or 'arguments' as they can be huge
163        }
164        CodexEventType::ApiRequest => {
165            insert_if_present(&mut obj, attrs, "cf_ray", "cf_ray");
166            insert_i64_if_present(&mut obj, attrs, "attempt", "attempt");
167            insert_i64_if_present(&mut obj, attrs, "duration_ms", "duration_ms");
168            insert_i64_if_present(&mut obj, attrs, "http.response.status_code", "status_code");
169            insert_if_present(&mut obj, attrs, "error.message", "error_message");
170        }
171    }
172
173    serde_json::Value::Object(obj)
174}
175
176/// Insert a string value into the metadata object if present
177fn insert_if_present(
178    obj: &mut serde_json::Map<String, serde_json::Value>,
179    attrs: &[KeyValue],
180    attr_key: &str,
181    json_key: &str,
182) {
183    if let Some(v) = get_string(attrs, attr_key) {
184        obj.insert(json_key.into(), json!(v));
185    }
186}
187
188/// Insert an i64 value into the metadata object if present
189fn insert_i64_if_present(
190    obj: &mut serde_json::Map<String, serde_json::Value>,
191    attrs: &[KeyValue],
192    attr_key: &str,
193    json_key: &str,
194) {
195    if let Some(v) = get_i64(attrs, attr_key) {
196        obj.insert(json_key.into(), json!(v));
197    }
198}
199
200#[cfg(test)]
201#[expect(clippy::unwrap_used)]
202mod tests {
203    use super::*;
204    use crate::input::otel::test_utils::{make_kv, make_log_record};
205
206    #[test]
207    fn test_parse_codex_conversation_starts() {
208        let record = make_log_record(
209            "codex.conversation_starts",
210            vec![
211                make_kv("conversation.id", "codex-conv-start"),
212                make_kv("event.timestamp", "2025-12-17T06:11:18.219Z"),
213                make_kv("model", "gpt-5.1-codex-max"),
214                make_kv("approval_policy", "on-request"),
215                make_kv("sandbox_policy", "workspace-write"),
216                make_kv("reasoning_summary", "auto"),
217                make_kv("provider_name", "OpenAI"),
218                make_kv("app.version", "0.73.0"),
219                make_kv("auth_mode", "ChatGPT"),
220            ],
221        );
222
223        let parsed = ParsedCodexEvent::from_log_record(&record).unwrap();
224
225        assert_eq!(parsed.event_type, CodexEventType::ConversationStarts);
226        assert_eq!(parsed.session_id, "codex-conv-start");
227        assert_eq!(parsed.model, Some("gpt-5.1-codex-max".to_string()));
228        assert!(parsed.timestamp.is_some());
229
230        // Check metadata
231        assert_eq!(parsed.metadata["approval_policy"], "on-request");
232        assert_eq!(parsed.metadata["sandbox_policy"], "workspace-write");
233        assert_eq!(parsed.metadata["reasoning_summary"], "auto");
234        assert_eq!(parsed.metadata["provider"], "OpenAI");
235        assert_eq!(parsed.metadata["app_version"], "0.73.0");
236        assert_eq!(parsed.metadata["auth_mode"], "ChatGPT");
237
238        // Test conversion to Event
239        let event = parsed.into_event("machine-1".to_string());
240        assert_eq!(event.tokens_input, Some(0));
241        assert_eq!(event.tokens_output, Some(0));
242        assert_eq!(event.framework, Some("codex".to_string()));
243
244        let metadata: serde_json::Value =
245            serde_json::from_str(event.metadata.as_ref().unwrap()).unwrap();
246        assert_eq!(metadata["event_type"], "conversation_starts");
247    }
248
249    #[test]
250    fn test_parse_codex_user_prompt() {
251        let record = make_log_record(
252            "codex.user_prompt",
253            vec![
254                make_kv("conversation.id", "codex-conv-prompt"),
255                make_kv("event.timestamp", "2025-12-17T06:11:20.000Z"),
256                make_kv("model", "gpt-5.1-codex-max"),
257                make_kv("prompt_length", "150"),
258            ],
259        );
260
261        let parsed = ParsedCodexEvent::from_log_record(&record).unwrap();
262
263        assert_eq!(parsed.event_type, CodexEventType::UserPrompt);
264        assert_eq!(parsed.session_id, "codex-conv-prompt");
265        assert_eq!(parsed.metadata["prompt_length"], 150);
266    }
267
268    #[test]
269    fn test_parse_codex_tool_decision() {
270        let record = make_log_record(
271            "codex.tool_decision",
272            vec![
273                make_kv("conversation.id", "codex-conv-tool"),
274                make_kv("event.timestamp", "2025-12-17T06:11:25.000Z"),
275                make_kv("model", "gpt-5.1-codex-max"),
276                make_kv("tool_name", "shell_command"),
277                make_kv("call_id", "call_abc123"),
278                make_kv("decision", "approved"),
279                make_kv("decision_type", "auto"),
280                make_kv("decision_source", "policy"),
281            ],
282        );
283
284        let parsed = ParsedCodexEvent::from_log_record(&record).unwrap();
285
286        assert_eq!(parsed.event_type, CodexEventType::ToolDecision);
287        assert_eq!(parsed.session_id, "codex-conv-tool");
288        assert_eq!(parsed.metadata["tool_name"], "shell_command");
289        assert_eq!(parsed.metadata["call_id"], "call_abc123");
290        assert_eq!(parsed.metadata["decision"], "approved");
291        assert_eq!(parsed.metadata["decision_type"], "auto");
292        assert_eq!(parsed.metadata["source"], "policy");
293    }
294
295    #[test]
296    fn test_parse_codex_tool_result() {
297        let record = make_log_record(
298            "codex.tool_result",
299            vec![
300                make_kv("conversation.id", "codex-conv-result"),
301                make_kv("event.timestamp", "2025-12-17T06:11:30.000Z"),
302                make_kv("model", "gpt-5.1-codex-max"),
303                make_kv("tool_name", "shell_command"),
304                make_kv("call_id", "call_abc123"),
305                make_kv("success", "true"),
306                make_kv("duration_ms", "500"),
307                make_kv("tool_result_size_bytes", "1024"),
308            ],
309        );
310
311        let parsed = ParsedCodexEvent::from_log_record(&record).unwrap();
312
313        assert_eq!(parsed.event_type, CodexEventType::ToolResult);
314        assert_eq!(parsed.session_id, "codex-conv-result");
315        assert_eq!(parsed.metadata["tool_name"], "shell_command");
316        assert_eq!(parsed.metadata["call_id"], "call_abc123");
317        assert_eq!(parsed.metadata["success"], true);
318        assert_eq!(parsed.metadata["duration_ms"], 500);
319        assert_eq!(parsed.metadata["result_size_bytes"], 1024);
320    }
321
322    #[test]
323    fn test_parse_codex_api_request_event() {
324        let record = make_log_record(
325            "codex.api_request",
326            vec![
327                make_kv("conversation.id", "codex-conv-api"),
328                make_kv("event.timestamp", "2025-12-17T06:11:27.000Z"),
329                make_kv("model", "gpt-5.1-codex-max"),
330                make_kv("attempt", "1"),
331                make_kv("duration_ms", "2500"),
332                make_kv("http.response.status_code", "200"),
333            ],
334        );
335
336        let parsed = ParsedCodexEvent::from_log_record(&record).unwrap();
337
338        assert_eq!(parsed.event_type, CodexEventType::ApiRequest);
339        assert_eq!(parsed.session_id, "codex-conv-api");
340        assert_eq!(parsed.metadata["attempt"], 1);
341        assert_eq!(parsed.metadata["duration_ms"], 2500);
342        assert_eq!(parsed.metadata["status_code"], 200);
343    }
344
345    #[test]
346    fn test_codex_event_rejects_unknown_event() {
347        let record = make_log_record(
348            "codex.unknown_event",
349            vec![make_kv("conversation.id", "codex-conv-123")],
350        );
351
352        assert!(ParsedCodexEvent::from_log_record(&record).is_none());
353    }
354
355    #[test]
356    fn test_codex_event_rejects_without_conversation_id() {
357        let record = make_log_record(
358            "codex.conversation_starts",
359            vec![make_kv("model", "gpt-5.1-codex-max")],
360        );
361
362        assert!(ParsedCodexEvent::from_log_record(&record).is_none());
363    }
364}