Add goal lifecycle metrics (#20799)

## Why

Adding goal metrics makes it possible to track how often goals are
created, completed, and stopped by budget limits, plus the final token
and wall-clock usage for terminal outcomes.

## What Changed

- Added OpenTelemetry metric constants for goal lifecycle tracking:
- `codex.goal.created`: increments each time a new persisted goal is
created or an existing goal is replaced with a new objective.
- `codex.goal.completed`: increments when a goal transitions to
`complete`.
- `codex.goal.budget_limited`: increments when a goal transitions to
`budget_limited` because its token budget has been reached.
- `codex.goal.token_count`: records the final persisted token count when
a goal transitions to `complete` or `budget_limited`.
- `codex.goal.duration_s`: records the final persisted elapsed
wall-clock time, in seconds, when a goal transitions to `complete` or
`budget_limited`.
- Emitted creation metrics when a goal is created or replaced.
- Emitted terminal outcome counters and final usage histograms when a
goal transitions to `complete` or `budget_limited`, avoiding
double-counting later in-flight accounting for already budget-limited
goals.
- Added focused `codex-core` tests for create/complete metrics and
one-time budget-limit metrics.
This commit is contained in:
Eric Traut
2026-05-05 09:21:54 -07:00
committed by GitHub
Unverified
parent 69283aa1c0
commit 91b7350187
7 changed files with 201 additions and 55 deletions
@@ -246,6 +246,8 @@ use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
use codex_core::ExternalGoalPreviousStatus;
use codex_core::ExternalGoalSet;
use codex_core::ForkSnapshot;
use codex_core::NewThread;
#[cfg(test)]
@@ -148,7 +148,7 @@ impl ThreadGoalRequestProcessor {
thread.prepare_external_goal_mutation().await;
}
let goal = (if let Some(objective) = objective {
let (goal, previous_status) = (if let Some(objective) = objective {
let existing_goal = state_db
.get_thread_goal(thread_id)
.await
@@ -157,6 +157,7 @@ impl ThreadGoalRequestProcessor {
goal.objective == objective
&& goal.status != codex_state::ThreadGoalStatus::Complete
}) {
let previous_status = ExternalGoalPreviousStatus::Existing(goal.status);
state_db
.update_thread_goal(
thread_id,
@@ -174,7 +175,9 @@ impl ThreadGoalRequestProcessor {
)
})
})
.map(|goal| (goal, previous_status))
} else {
let previous_status = ExternalGoalPreviousStatus::NewGoal;
state_db
.replace_thread_goal(
thread_id,
@@ -183,8 +186,19 @@ impl ThreadGoalRequestProcessor {
params.token_budget.flatten(),
)
.await
.map(|goal| (goal, previous_status))
}
} else {
let existing_goal = state_db
.get_thread_goal(thread_id)
.await
.map_err(|err| invalid_request(err.to_string()))?;
let Some(existing_goal) = existing_goal else {
return Err(invalid_request(format!(
"cannot update goal for thread {thread_id}: no goal exists"
)));
};
let previous_status = ExternalGoalPreviousStatus::Existing(existing_goal.status);
state_db
.update_thread_goal(
thread_id,
@@ -200,9 +214,13 @@ impl ThreadGoalRequestProcessor {
anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists")
})
})
.map(|goal| (goal, previous_status))
})
.map_err(|err| invalid_request(err.to_string()))?;
let goal_status = goal.status;
let external_goal_set = ExternalGoalSet {
goal: goal.clone(),
previous_status,
};
let goal = api_thread_goal_from_state(goal);
self.outgoing
.send_response(
@@ -213,7 +231,7 @@ impl ThreadGoalRequestProcessor {
self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx)
.await;
if let Some(thread) = running_thread.as_ref() {
thread.apply_external_goal_set(goal_status).await;
thread.apply_external_goal_set(external_goal_set).await;
}
Ok(())
}