mirror of
https://github.com/openai/codex.git
synced 2026-08-26 13:38:49 +00:00
## Why App-server deployments can consume structured JSON logs for operational measurements without requiring an OTEL exporter. Existing tool-result telemetry reports the handler outcome, but it does not separate time spent waiting to dispatch from time spent executing the handler. A compact completion event for the outer, direct tool call lets consumers measure those phases and correlate them with a conversation and turn. Code-mode calls are intentionally excluded so nested runtime calls do not create overlapping events that are easy to double-count. ## What changed - Added a [`ToolCallTimingGuard`](141110a73c/codex-rs/core/src/tools/parallel.rs (L32)) around direct tool calls. Event-only strings and timing state are captured only when the `codex_core::tools::parallel` `INFO` target is enabled. - Added a [`codex.tool_call`](141110a73c/codex-rs/core/src/tools/parallel.rs (L313)) completion event with conversation, turn, tool, call, trace, dispatch, handler, and total timing fields. - Recorded the execution-start marker after the dispatch lock is acquired. Event emission snapshots that marker once so a concurrently starting dispatch cannot produce internally inconsistent fields. - Limited the event to `ToolCallSource::Direct`; [unit coverage](141110a73c/codex-rs/core/src/tools/parallel.rs (L365)) verifies code-mode calls are ignored. - Added [cancellation coverage](141110a73c/codex-rs/core/src/tools/parallel.rs (L408)) that holds the execution gate and verifies a call cancelled before admission emits exactly one dispatch-only timing event. - Added reusable [`JsonLogCapture`](141110a73c/codex-rs/app-server/tests/common/json_logging.rs (L15)) support, a [JSON-logging-specific `TestAppServer` constructor](141110a73c/codex-rs/app-server/tests/common/test_app_server.rs (L172)), and an [end-to-end app-server test](141110a73c/codex-rs/app-server/tests/suite/logging.rs (L52)) that drives a direct `exec_command` through the public v2 JSON-RPC API and validates the emitted JSON event. Exec-server-specific request and process timing remains in the stacked PR #30901. ## Suggested logging filter ```bash LOG_FORMAT=json \ RUST_LOG='warn,codex_core::tools::parallel=info' \ codex app-server ``` ## Event example Identifier and timing values are illustrative. ### `codex.tool_call` ```json { "timestamp": "2026-06-27T03:45:20.443Z", "level": "INFO", "fields": { "message": "tool call completed", "event.name": "codex.tool_call", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "conversation.id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "turn_id": "019f04f8-6ac2-78f1-8625-f04a6d35af18", "tool_name": "exec_command", "call_id": "call_7b8483", "tool_source": "direct", "execution_started": true, "dispatch_duration_ms": 12, "handler_duration_ms": 431, "total_duration_ms": 443 }, "target": "codex_core::tools::parallel" } ``` If execution never starts, `execution_started` is `false`, `handler_duration_ms` is `0`, and `dispatch_duration_ms` covers the full observed lifetime. If a duration cannot be represented as an unsigned 64-bit millisecond value, all three duration fields are omitted rather than populated with a sentinel that could corrupt downstream calculations. ## Test plan - `just test -p codex-core tool_call_timing_guard_ignores_code_mode_source` - `just test -p codex-core cancellation_before_dispatch_admission_logs_dispatch_only_timing` - `just test -p codex-app-server app_server_emits_structured_tool_call_timing_event` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/30334). * __->__ #30334
138 lines
4.3 KiB
Rust
138 lines
4.3 KiB
Rust
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::process::Stdio;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tokio::sync::Notify;
|
|
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct JsonLogCapture {
|
|
lines: Arc<Mutex<Vec<String>>>,
|
|
updated: Arc<Notify>,
|
|
}
|
|
|
|
impl JsonLogCapture {
|
|
pub(crate) fn record(&self, line: String) {
|
|
self.lines
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.push(line);
|
|
self.updated.notify_one();
|
|
}
|
|
|
|
pub(crate) async fn wait_for_event(&self, event_name: &str) -> Result<Value> {
|
|
let mut events = self.wait_for_events(event_name, /*count*/ 1).await?;
|
|
Ok(events.remove(0))
|
|
}
|
|
|
|
pub(crate) async fn wait_for_events(
|
|
&self,
|
|
event_name: &str,
|
|
count: usize,
|
|
) -> Result<Vec<Value>> {
|
|
let result = tokio::time::timeout(Duration::from_secs(10), async {
|
|
loop {
|
|
let updated = self.updated.notified();
|
|
let events = self
|
|
.events()?
|
|
.into_iter()
|
|
.filter(|event| event["fields"]["event.name"].as_str() == Some(event_name))
|
|
.collect::<Vec<_>>();
|
|
if events.len() >= count {
|
|
return Ok(events);
|
|
}
|
|
updated.await;
|
|
}
|
|
})
|
|
.await;
|
|
match result {
|
|
Ok(result) => result,
|
|
Err(_) => {
|
|
let lines = self
|
|
.lines
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.join("\n");
|
|
anyhow::bail!(
|
|
"timed out waiting for {count} JSON log event(s) named `{event_name}`; captured stderr:\n{lines}"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn events(&self) -> Result<Vec<Value>> {
|
|
let lines = self
|
|
.lines
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
json_log_events(lines.iter().map(String::as_str))
|
|
}
|
|
}
|
|
|
|
pub fn app_server_json_shutdown_event(
|
|
binary: &str,
|
|
args: &[&str],
|
|
codex_home: &Path,
|
|
) -> Result<Value> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
"[features]\nplugins = false\n",
|
|
)?;
|
|
let output = Command::new(codex_utils_cargo_bin::cargo_bin(binary)?)
|
|
.stdin(Stdio::null())
|
|
.env("CODEX_HOME", codex_home)
|
|
.env(
|
|
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
|
|
codex_home.join("managed_config.toml"),
|
|
)
|
|
.env("LOG_FORMAT", "json")
|
|
.env("RUST_LOG", "codex_app_server=info")
|
|
.args(args)
|
|
.output()?;
|
|
|
|
let stderr = String::from_utf8(output.stderr)?;
|
|
anyhow::ensure!(output.status.success(), "app-server failed: {stderr}");
|
|
|
|
let events = json_log_events(stderr.lines())
|
|
.with_context(|| format!("app-server stderr was not valid JSONL: {stderr}"))?;
|
|
let event = events
|
|
.iter()
|
|
.find(|event| event["fields"]["message"] == "processor task exited")
|
|
.context("missing INFO shutdown event in app-server JSON logs")?;
|
|
Ok(json!({
|
|
"level": event["level"],
|
|
"fields": event["fields"],
|
|
"target": event["target"],
|
|
}))
|
|
}
|
|
|
|
fn json_log_events<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<Vec<Value>> {
|
|
lines
|
|
.into_iter()
|
|
.filter(|line| !line.is_empty())
|
|
.map(|line| {
|
|
let event = serde_json::from_str::<Value>(line)
|
|
.with_context(|| format!("log line was not JSON: {line}"))?;
|
|
anyhow::ensure!(
|
|
event["level"].is_string()
|
|
&& event["fields"].is_object()
|
|
&& event["target"].is_string(),
|
|
"JSON log event did not include level, fields, and target: {line}"
|
|
);
|
|
let timestamp = event["timestamp"]
|
|
.as_str()
|
|
.with_context(|| format!("JSON log event did not include a timestamp: {line}"))?;
|
|
chrono::DateTime::parse_from_rfc3339(timestamp).with_context(|| {
|
|
format!("JSON log event timestamp was not RFC 3339: {timestamp}")
|
|
})?;
|
|
Ok(event)
|
|
})
|
|
.collect()
|
|
}
|