[codex-analytics] emit goal lifecycle analytics (#27078)

## Why
- Currently, there is no analytics event for `/goal` behavior
- Existing events cannot identify goal execution or its resulting
outcome
- The original update in
[#26182](https://github.com/openai/codex/pull/26182) was implemented
before `/goal` moved into `codex-goal-extension`.

## What Changed
- Adds `codex_goal_event` serialization and enrichment to
`codex-analytics`
- Emits goal events from the canonical `codex-goal-extension` mutation
and accounting paths:
  - `created` when a new logical goal is persisted
  - `usage_accounted` when cumulative goal usage is persisted
  - `status_changed` when the stored goal status changes
  - `cleared` when the goal is deleted
- Preserves causal `turn_id` for turn driven events and uses null
attribution for external or idle lifecycle events
- Changes goal deletion to return the deleted row so `cleared` retains
the stable goal ID

## Event Details

Includes standard analytics metadata along with goal specific fields:
- `goal_id`: Stable ID stored in the local SQLite goal row and shared
across the goal's events
- `event_kind`: Observed operation (see the 4 lifecycle events cited in
the above bullet)
- `goal_status`: Resulting or last stored status: `active`, `paused`,
`blocked`, `usage_limited`, etc.
  - `has_token_budget`: Indicates whether a token budget is configured
  - `turn_id`: Causal turn ID, or null when no causal turn exists
- `cumulative_tokens_accounted`: Cumulative tokens on `usage_accounted`
events; null otherwise
- `cumulative_time_accounted_seconds`: Cumulative active time on
`usage_accounted` events; null otherwise

## Validation
- `just test -p codex-analytics -p codex-state -p codex-goal-extension`
- `just test -p codex-core -E 'test(/goal/)'`
- `just test -p codex-app-server`
- `cargo build -p codex-analytics -p codex-core -p codex-state -p
codex-app-server`
This commit is contained in:
marksteinbrick-oai
2026-06-09 18:45:54 -07:00
committed by GitHub
parent 4a3eac2144
commit 608b8b1cc6
23 changed files with 412 additions and 24 deletions
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
async-trait = { workspace = true }
codex-analytics = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-otel = { workspace = true }
+77
View File
@@ -0,0 +1,77 @@
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::CodexGoalEvent;
use codex_analytics::GoalEventKind;
#[derive(Clone)]
pub(crate) struct GoalAnalytics {
client: AnalyticsEventsClient,
}
pub(crate) enum GoalEventAttribution<'a> {
Turn(&'a str),
NoTurn,
}
impl GoalAnalytics {
pub(crate) fn new(client: AnalyticsEventsClient) -> Self {
Self { client }
}
pub(crate) fn created(
&self,
goal: &codex_state::ThreadGoal,
attribution: GoalEventAttribution<'_>,
) {
self.track(goal, attribution, GoalEventKind::Created);
}
pub(crate) fn usage_accounted(
&self,
goal: &codex_state::ThreadGoal,
attribution: GoalEventAttribution<'_>,
) {
self.track(goal, attribution, GoalEventKind::UsageAccounted);
}
pub(crate) fn status_changed(
&self,
goal: &codex_state::ThreadGoal,
previous_status: Option<codex_state::ThreadGoalStatus>,
attribution: GoalEventAttribution<'_>,
) {
if previous_status.is_some_and(|status| status != goal.status) {
self.track(goal, attribution, GoalEventKind::StatusChanged);
}
}
pub(crate) fn cleared(&self, goal: &codex_state::ThreadGoal) {
self.track(goal, GoalEventAttribution::NoTurn, GoalEventKind::Cleared);
}
fn track(
&self,
goal: &codex_state::ThreadGoal,
attribution: GoalEventAttribution<'_>,
event_kind: GoalEventKind,
) {
let (cumulative_tokens_accounted, cumulative_time_accounted_seconds) = match event_kind {
GoalEventKind::UsageAccounted => (Some(goal.tokens_used), Some(goal.time_used_seconds)),
GoalEventKind::Created | GoalEventKind::StatusChanged | GoalEventKind::Cleared => {
(None, None)
}
};
self.client.track_goal_event(CodexGoalEvent {
thread_id: goal.thread_id.to_string(),
turn_id: match attribution {
GoalEventAttribution::Turn(turn_id) => Some(turn_id.to_string()),
GoalEventAttribution::NoTurn => None,
},
goal_id: goal.goal_id.clone(),
event_kind,
goal_status: goal.status,
has_token_budget: goal.token_budget.is_some(),
cumulative_tokens_accounted,
cumulative_time_accounted_seconds,
});
}
}
+4 -4
View File
@@ -259,19 +259,19 @@ impl GoalService {
tracing::warn!("failed to prepare external goal mutation: {err}");
}
let cleared = state_db
let cleared_goal = state_db
.thread_goals()
.delete_thread_goal(thread_id)
.await
.map_err(|err| {
GoalServiceError::Internal(format!("failed to clear thread goal: {err}"))
})?;
let cleared = cleared_goal.is_some();
drop(goal_state_permit);
drop(runtime);
if cleared
&& let Some(runtime) = self.runtime_for_thread(thread_id)
&& let Err(err) = runtime.apply_external_goal_clear().await
if let (Some(runtime), Some(goal)) = (self.runtime_for_thread(thread_id), cleared_goal)
&& let Err(err) = runtime.apply_external_goal_clear(goal).await
{
tracing::warn!("failed to apply external goal clear runtime effects: {err}");
}
+11
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use std::sync::Weak;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_core::ThreadManager;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
@@ -33,6 +34,7 @@ use codex_protocol::protocol::TokenUsageInfo;
use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::analytics::GoalAnalytics;
use crate::api::GoalService;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
@@ -57,6 +59,7 @@ impl GoalExtensionConfig {
#[derive(Clone)]
pub struct GoalExtension<C> {
state_dbs: Arc<codex_state::StateRuntime>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
thread_manager: Weak<ThreadManager>,
@@ -73,6 +76,7 @@ impl<C> std::fmt::Debug for GoalExtension<C> {
impl<C> GoalExtension<C> {
pub(crate) fn new_with_host_capabilities(
state_dbs: Arc<codex_state::StateRuntime>,
analytics_events_client: AnalyticsEventsClient,
event_sink: Arc<dyn ExtensionEventSink>,
metrics_client: Option<MetricsClient>,
thread_manager: Weak<ThreadManager>,
@@ -81,6 +85,7 @@ impl<C> GoalExtension<C> {
) -> Self {
Self {
state_dbs,
analytics: GoalAnalytics::new(analytics_events_client),
event_emitter: GoalEventEmitter::new(event_sink),
metrics: GoalMetrics::new(metrics_client),
thread_manager,
@@ -120,6 +125,7 @@ where
self.thread_manager.clone(),
accounting_state,
GoalRuntimeConfig {
analytics: self.analytics.clone(),
enabled,
tools_available_for_thread,
},
@@ -403,6 +409,7 @@ where
runtime.thread_id(),
Arc::clone(&self.state_dbs),
runtime.accounting_state(),
self.analytics.clone(),
self.event_emitter.clone(),
self.metrics.clone(),
)),
@@ -410,6 +417,7 @@ where
runtime.thread_id(),
Arc::clone(&self.state_dbs),
runtime.accounting_state(),
self.analytics.clone(),
self.event_emitter.clone(),
self.metrics.clone(),
)),
@@ -417,6 +425,7 @@ where
runtime.thread_id(),
Arc::clone(&self.state_dbs),
runtime.accounting_state(),
self.analytics.clone(),
self.event_emitter.clone(),
self.metrics.clone(),
)),
@@ -427,6 +436,7 @@ where
pub fn install_with_backend<C>(
registry: &mut ExtensionRegistryBuilder<C>,
state_dbs: Arc<codex_state::StateRuntime>,
analytics_events_client: AnalyticsEventsClient,
metrics_client: Option<MetricsClient>,
thread_manager: Weak<ThreadManager>,
goal_service: Arc<GoalService>,
@@ -436,6 +446,7 @@ pub fn install_with_backend<C>(
{
let extension = Arc::new(GoalExtension::new_with_host_capabilities(
state_dbs,
analytics_events_client,
registry.event_sink(),
metrics_client,
thread_manager,
+1
View File
@@ -1,6 +1,7 @@
//! Extension crate for the `/goal` feature.
mod accounting;
mod analytics;
mod api;
mod events;
mod extension;
+37 -1
View File
@@ -10,6 +10,8 @@ use codex_protocol::protocol::ThreadGoal;
use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::analytics::GoalAnalytics;
use crate::analytics::GoalEventAttribution;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::steering::continuation_steering_item;
@@ -24,6 +26,7 @@ pub struct GoalRuntimeHandle {
}
pub(crate) struct GoalRuntimeConfig {
pub(crate) analytics: GoalAnalytics,
pub(crate) enabled: bool,
pub(crate) tools_available_for_thread: bool,
}
@@ -36,6 +39,7 @@ pub(crate) enum ActiveGoalStopReason {
struct GoalRuntimeInner {
thread_id: ThreadId,
state_dbs: Arc<codex_state::StateRuntime>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
thread_manager: Weak<ThreadManager>,
@@ -87,6 +91,7 @@ impl GoalRuntimeHandle {
inner: Arc::new(GoalRuntimeInner {
thread_id,
state_dbs,
analytics: config.analytics,
event_emitter,
metrics,
thread_manager,
@@ -165,6 +170,9 @@ impl GoalRuntimeHandle {
.is_some_and(|previous_goal| previous_goal.goal_id != goal.goal_id);
if previous_goal.is_none() || replaced_existing_goal {
self.inner.metrics.record_created();
self.inner
.analytics
.created(&goal, GoalEventAttribution::NoTurn);
}
let previous_status = previous_goal
.as_ref()
@@ -175,6 +183,9 @@ impl GoalRuntimeHandle {
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner
.analytics
.status_changed(&goal, previous_status, GoalEventAttribution::NoTurn);
let objective_changed = previous_goal.as_ref().is_some_and(|previous_goal| {
!replaced_existing_goal && previous_goal.objective != goal.objective
});
@@ -211,11 +222,15 @@ impl GoalRuntimeHandle {
Ok(())
}
pub async fn apply_external_goal_clear(&self) -> Result<(), String> {
pub async fn apply_external_goal_clear(
&self,
goal: codex_state::ThreadGoal,
) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
}
self.inner.analytics.cleared(&goal);
self.inner.accounting_state.clear_active_goal();
Ok(())
}
@@ -302,6 +317,11 @@ impl GoalRuntimeHandle {
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner.analytics.status_changed(
&goal,
previous_status,
GoalEventAttribution::Turn(turn_id),
);
self.inner.accounting_state.clear_active_goal();
let goal = protocol_goal_from_state(goal);
self.inner.event_emitter.thread_goal_updated(
@@ -445,6 +465,14 @@ impl GoalRuntimeHandle {
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner
.analytics
.usage_accounted(&goal, GoalEventAttribution::Turn(turn_id));
self.inner.analytics.status_changed(
&goal,
previous_status,
GoalEventAttribution::Turn(turn_id),
);
accounting.mark_progress_accounted_for_status(
turn_id,
&snapshot,
@@ -499,6 +527,14 @@ impl GoalRuntimeHandle {
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner
.analytics
.usage_accounted(&goal, GoalEventAttribution::NoTurn);
self.inner.analytics.status_changed(
&goal,
previous_status,
GoalEventAttribution::NoTurn,
);
accounting.mark_idle_progress_accounted_for_status(
&snapshot,
goal.status,
+25
View File
@@ -17,6 +17,8 @@ use serde::Serialize;
use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::analytics::GoalAnalytics;
use crate::analytics::GoalEventAttribution;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::spec::CREATE_GOAL_TOOL_NAME;
@@ -32,6 +34,7 @@ pub(crate) struct GoalToolExecutor {
thread_id: ThreadId,
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
}
@@ -75,6 +78,7 @@ impl GoalToolExecutor {
thread_id: ThreadId,
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
@@ -83,6 +87,7 @@ impl GoalToolExecutor {
thread_id,
state_db,
accounting_state,
analytics,
event_emitter,
metrics,
}
@@ -92,6 +97,7 @@ impl GoalToolExecutor {
thread_id: ThreadId,
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
@@ -100,6 +106,7 @@ impl GoalToolExecutor {
thread_id,
state_db,
accounting_state,
analytics,
event_emitter,
metrics,
}
@@ -109,6 +116,7 @@ impl GoalToolExecutor {
thread_id: ThreadId,
state_db: Arc<codex_state::StateRuntime>,
accounting_state: Arc<GoalAccountingState>,
analytics: GoalAnalytics,
event_emitter: GoalEventEmitter,
metrics: GoalMetrics,
) -> Self {
@@ -117,6 +125,7 @@ impl GoalToolExecutor {
thread_id,
state_db,
accounting_state,
analytics,
event_emitter,
metrics,
}
@@ -200,6 +209,10 @@ impl GoalToolExecutor {
.accounting_state
.mark_current_turn_goal_active(goal.goal_id.clone());
self.metrics.record_created();
self.analytics.created(
&goal,
GoalEventAttribution::Turn(invocation.turn_id.as_str()),
);
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)
@@ -259,6 +272,11 @@ impl GoalToolExecutor {
})?;
self.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.analytics.status_changed(
&goal,
previous_status,
GoalEventAttribution::Turn(invocation.turn_id.as_str()),
);
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());
@@ -324,6 +342,13 @@ impl GoalToolExecutor {
codex_state::GoalAccountingOutcome::Updated(goal) => {
self.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.analytics
.usage_accounted(&goal, GoalEventAttribution::Turn(turn_id.as_str()));
self.analytics.status_changed(
&goal,
previous_status,
GoalEventAttribution::Turn(turn_id.as_str()),
);
self.accounting_state.mark_progress_accounted_for_status(
turn_id.as_str(),
&snapshot,
@@ -4,6 +4,7 @@ use std::sync::PoisonError;
use std::sync::Weak;
use std::time::Duration;
use codex_analytics::AnalyticsEventsClient;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
@@ -1114,6 +1115,7 @@ async fn installed_tools_with_start(
install_with_backend(
&mut builder,
runtime,
AnalyticsEventsClient::disabled(),
/*metrics_client*/ None,
Weak::new(),
goal_service,
@@ -1164,6 +1166,7 @@ impl GoalExtensionHarness {
install_with_backend(
&mut builder,
runtime,
AnalyticsEventsClient::disabled(),
/*metrics_client*/ None,
Weak::new(),
Arc::clone(&goal_service),