1use 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#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CodexEventType {
16 ConversationStarts,
18 UserPrompt,
20 ToolDecision,
22 ToolResult,
24 ApiRequest,
26}
27
28impl CodexEventType {
29 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#[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 pub metadata: serde_json::Value,
50}
51
52impl ParsedCodexEvent {
53 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 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 let session_id = get_string(attributes, "conversation.id")?;
71
72 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 let model = get_string(attributes, "model");
81
82 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 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
110fn 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 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 }
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
176fn 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
188fn 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 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 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}