Add goal extension telemetry parity (#24615)

## Why

`core/src/goals.rs` already emits OTEL metrics for goal creation,
resume, terminal transitions, token counts, and duration. As `/goal`
moves into `ext/goal`, the extension needs to preserve that telemetry
contract instead of only emitting app-visible `ThreadGoalUpdated`
events.

This keeps the existing `codex.goal.*` metric surface intact while goal
lifecycle ownership shifts toward the extension.

## What changed

- Added an extension-local `GoalMetrics` helper that records the
existing `codex.goal.*` counters and histograms through `codex-otel`.
- Threaded an optional `MetricsClient` through `install_with_backend`,
`GoalExtension`, `GoalRuntimeHandle`, and `GoalToolExecutor`.
- Emitted created, resumed, and terminal goal metrics from the extension
paths that create goals, restore active goals on thread resume, account
budget limits, complete or block goals, and handle external goal
mutations.
- Updated existing goal extension test setup callsites to pass `None`
for metrics when instrumentation is not under test.

## Verification

Not run locally.
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-26 19:48:32 +02:00
1 parent db9cb04fb6
commit 08504e86fb
8 files changed
+237 -3

No files matched your search

+41 -1
View File
@@ -18,6 +18,7 @@ use serde::Serialize;
use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::spec::CREATE_GOAL_TOOL_NAME;
use crate::spec::GET_GOAL_TOOL_NAME;
use crate::spec::UPDATE_GOAL_TOOL_NAME;
@@ -32,6 +33,7 @@ pub(crate) struct GoalToolExecutor {
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
}
#[derive(Clone, Copy)]
@@ -74,6 +76,7 @@ impl GoalToolExecutor {
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
Self {
kind: GoalToolKind::Get,
@@ -81,6 +84,7 @@ impl GoalToolExecutor {
state_db,
accounting_state,
event_emitter,
metrics,
}
}
@@ -89,6 +93,7 @@ impl GoalToolExecutor {
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
Self {
kind: GoalToolKind::Create,
@@ -96,6 +101,7 @@ impl GoalToolExecutor {
state_db,
accounting_state,
event_emitter,
metrics,
}
}
@@ -104,6 +110,7 @@ impl GoalToolExecutor {
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
Self {
kind: GoalToolKind::Update,
@@ -111,6 +118,7 @@ impl GoalToolExecutor {
state_db,
accounting_state,
event_emitter,
metrics,
}
}
}
@@ -191,6 +199,7 @@ impl GoalToolExecutor {
let turn_id = self
.accounting_state
.mark_current_turn_goal_active(goal.goal_id.clone());
self.metrics.record_created();
let goal = protocol_goal_from_state(goal);
self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone());
goal_response(Some(goal), CompletionBudgetReport::Omit)
@@ -224,6 +233,9 @@ impl GoalToolExecutor {
BudgetLimitedGoalDisposition::ClearActive,
)
.await?;
let previous_status = self
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
.await?;
let goal = self
.state_db
.thread_goals()
@@ -240,12 +252,14 @@ impl GoalToolExecutor {
.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to update goal: {err}"))
})?
.map(protocol_goal_from_state)
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"cannot update goal because this thread has no goal".to_string(),
)
})?;
self.metrics
.record_terminal_if_status_changed(previous_status, &goal);
let goal = protocol_goal_from_state(goal);
let turn_id = self.accounting_state.clear_current_turn_goal();
self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone());
goal_response(
@@ -280,6 +294,9 @@ impl GoalToolExecutor {
let Some(snapshot) = self.accounting_state.progress_snapshot(turn_id.as_str()) else {
return Ok(None);
};
let previous_status = self
.current_goal_status_for_metrics(Some(snapshot.expected_goal_id.as_str()))
.await?;
let outcome = self
.state_db
.thread_goals()
@@ -296,6 +313,8 @@ impl GoalToolExecutor {
})?;
Ok(match outcome {
codex_state::GoalAccountingOutcome::Updated(goal) => {
self.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.accounting_state.mark_progress_accounted_for_status(
turn_id.as_str(),
&snapshot,
@@ -313,6 +332,27 @@ impl GoalToolExecutor {
codex_state::GoalAccountingOutcome::Unchanged(_) => None,
})
}
async fn current_goal_status_for_metrics(
&self,
expected_goal_id: Option<&str>,
) -> Result<Option<codex_state::ThreadGoalStatus>, FunctionCallError> {
let goal = self
.state_db
.thread_goals()
.get_thread_goal(self.thread_id)
.await
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to read goal metrics status: {err}"
))
})?;
Ok(goal.and_then(|goal| {
expected_goal_id
.is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id)
.then_some(goal.status)
}))
}
}
fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>