[codex] Emit sandbox outcome telemetry event (#25955)

## Summary

Adds a dedicated `codex.sandbox_outcome` telemetry event so we can query
sandbox edge outcomes without threading sandbox metadata through
tool-result output types.

This is meant to make sandbox failures and approved escalation retries
visible in OTEL while keeping the existing `codex.tool_result` event
shape focused on tool completion data.

## What changed

- Adds `SessionTelemetry::sandbox_outcome(...)`, which emits
`codex.sandbox_outcome` as both a log and trace event.
- Records the tool name, call id, sandbox outcome, initial attempt
duration, and escalated attempt duration when a retry runs.
- Emits `denied` when the sandbox blocks execution and no retry is run.
- Emits `timed_out` and `signal` when those sandbox errors surface from
tool execution.
- Emits `escalated` when the initial sandboxed attempt fails and the
approved unsandboxed retry succeeds.
- Adds OTEL coverage for the new event payload, including timing fields.

## Validation

- `RUST_MIN_STACK=8388608 just test -p codex-core
sandbox_outcome_event_records_outcome
handle_sandbox_error_user_approves_retry_records_tool_decision`
- `just test -p codex-otel
otel_export_routing_policy_routes_tool_result_log_and_trace_events
runtime_metrics_summary_collects_tool_api_and_streaming_metrics`
- `just fix -p codex-core`
- `just fix -p codex-otel`
This commit is contained in:
rreichel3-oai
2026-06-04 20:58:14 -04:00
committed by GitHub
Unverified
parent 4be1a168fc
commit ecae412740
3 changed files with 181 additions and 5 deletions
+81 -5
View File
@@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use std::time::Instant;
pub(crate) struct ToolOrchestrator {
sandbox: SandboxManager,
@@ -256,6 +257,7 @@ impl ToolOrchestrator {
network_denial_cancellation_token: None,
};
let initial_attempt_start = Instant::now();
let (first_result, first_deferred_network_approval) = Self::run_attempt(
tool,
req,
@@ -264,6 +266,7 @@ impl ToolOrchestrator {
managed_network_active,
)
.await;
let initial_duration = initial_attempt_start.elapsed();
match first_result {
Ok(out) => {
// We have a successful initial result
@@ -284,12 +287,26 @@ impl ToolOrchestrator {
None
};
if network_policy_decision.is_some() && network_approval_context.is_none() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
})));
}
if !tool.escalate_on_failure() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -312,6 +329,13 @@ impl ToolOrchestrator {
ExecApprovalRequirement::NeedsApproval { .. }
);
if !allow_on_request_network_prompt {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -319,6 +343,13 @@ impl ToolOrchestrator {
}
}
if !unsandboxed_allowed && network_approval_context.is_none() {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"denied",
initial_duration,
/*escalated_duration*/ None,
);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output,
network_policy_decision,
@@ -400,15 +431,51 @@ impl ToolOrchestrator {
};
// Second attempt.
let escalated_attempt_start = Instant::now();
let (retry_result, retry_deferred_network_approval) =
Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active)
.await;
retry_result.map(|output| OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
})
let escalated_duration = escalated_attempt_start.elapsed();
match retry_result {
Ok(output) => {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
"escalated",
initial_duration,
Some(escalated_duration),
);
Ok(OrchestratorRunResult {
output,
deferred_network_approval: retry_deferred_network_approval,
})
}
Err(err) => {
if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
outcome,
initial_duration,
Some(escalated_duration),
);
}
Err(err)
}
}
}
Err(err) => {
if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
otel.sandbox_outcome(
&otel_tn,
otel_ci,
outcome,
initial_duration,
/*escalated_duration*/ None,
);
}
Err(err)
}
Err(err) => Err(err),
}
}
@@ -509,6 +576,15 @@ impl ToolOrchestrator {
}
}
fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> {
match err {
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"),
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"),
ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"),
ToolError::Rejected(_) | ToolError::Codex(_) => None,
}
}
fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
// Keep approval reason terse and stable for UX/tests, but accept the
// output so we can evolve heuristics later without touching call sites.