Files
codex/codex-rs/ext/goal/src/analytics.rs
T
marksteinbrick-oai 608b8b1cc6 [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`
2026-06-09 18:45:54 -07:00

78 lines
2.4 KiB
Rust

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,
});
}
}