[codex-analytics] report compaction analytics details (#26680)

## Why

Compaction analytics adds retained image count and compaction summary
output tokens for v1.5 specifically.

## What changed

- Add nullable `retained_image_count` and `compaction_summary_tokens`
fields to `codex_compaction_event`.
- Populate them only for `responses_compaction_v2`: retained images come
from the retained v2 compacted history, and summary tokens come from
`response.completed.token_usage.output_tokens`.
- Leave local and legacy remote compaction events as `null` for these
detail fields.

## Verification

- `just fmt`
- `just fix -p codex-core`
- `just test -p codex-core
build_v2_compacted_history_counts_retained_input_images`
- `git diff --check`
This commit is contained in:
rhan-oai
2026-06-08 10:52:31 -07:00
committed by GitHub
Unverified
parent a815311466
commit f1c18df9ae
6 changed files with 117 additions and 46 deletions
@@ -1253,6 +1253,8 @@ fn compaction_event_serializes_expected_shape() {
error: None,
active_context_tokens_before: 120_000,
active_context_tokens_after: 18_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 106,
duration_ms: Some(6543),
@@ -1301,6 +1303,8 @@ fn compaction_event_serializes_expected_shape() {
"error": null,
"active_context_tokens_before": 120000,
"active_context_tokens_after": 18000,
"retained_image_count": null,
"compaction_summary_tokens": null,
"started_at": 100,
"completed_at": 106,
"duration_ms": 6543
@@ -1821,6 +1825,8 @@ async fn compaction_event_ingests_custom_fact() {
error: Some("context limit exceeded".to_string()),
active_context_tokens_before: 131_000,
active_context_tokens_after: 131_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
@@ -2743,6 +2749,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
error: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 64_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
+4
View File
@@ -758,6 +758,8 @@ pub(crate) struct CodexCompactionEventParams {
pub(crate) error: Option<String>,
pub(crate) active_context_tokens_before: i64,
pub(crate) active_context_tokens_after: i64,
pub(crate) retained_image_count: Option<usize>,
pub(crate) compaction_summary_tokens: Option<i64>,
pub(crate) started_at: u64,
pub(crate) completed_at: u64,
pub(crate) duration_ms: Option<u64>,
@@ -970,6 +972,8 @@ pub(crate) fn codex_compaction_event_params(
error: input.error,
active_context_tokens_before: input.active_context_tokens_before,
active_context_tokens_after: input.active_context_tokens_after,
retained_image_count: input.retained_image_count,
compaction_summary_tokens: input.compaction_summary_tokens,
started_at: input.started_at,
completed_at: input.completed_at,
duration_ms: input.duration_ms,
+2
View File
@@ -434,6 +434,8 @@ pub struct CodexCompactionEvent {
pub error: Option<String>,
pub active_context_tokens_before: i64,
pub active_context_tokens_after: i64,
pub retained_image_count: Option<usize>,
pub compaction_summary_tokens: Option<i64>,
pub started_at: u64,
pub completed_at: u64,
pub duration_ms: Option<u64>,
+18 -4
View File
@@ -150,7 +150,7 @@ async fn run_compact_task_inner(
sess.as_ref(),
CompactionStatus::Interrupted,
Some(error),
/*active_context_tokens_before*/ None,
CompactionAnalyticsDetails::default(),
)
.await;
return Err(CodexErr::TurnAborted);
@@ -174,7 +174,7 @@ async fn run_compact_task_inner(
sess.as_ref(),
status,
error,
/*active_context_tokens_before*/ None,
CompactionAnalyticsDetails::default(),
)
.await;
return Err(CodexErr::TurnAborted);
@@ -185,7 +185,7 @@ async fn run_compact_task_inner(
sess.as_ref(),
status,
error,
/*active_context_tokens_before*/ None,
CompactionAnalyticsDetails::default(),
)
.await;
result.map(|_| ())
@@ -335,6 +335,13 @@ pub(crate) struct CompactionAnalyticsAttempt {
start_instant: Instant,
}
#[derive(Clone, Copy, Default)]
pub(crate) struct CompactionAnalyticsDetails {
pub(crate) active_context_tokens_before: Option<i64>,
pub(crate) retained_image_count: Option<usize>,
pub(crate) compaction_summary_tokens: Option<i64>,
}
impl CompactionAnalyticsAttempt {
pub(crate) async fn begin(
sess: &Session,
@@ -363,8 +370,13 @@ impl CompactionAnalyticsAttempt {
sess: &Session,
status: CompactionStatus,
error: Option<String>,
active_context_tokens_before: Option<i64>,
details: CompactionAnalyticsDetails,
) {
let CompactionAnalyticsDetails {
active_context_tokens_before,
retained_image_count,
compaction_summary_tokens,
} = details;
let active_context_tokens_before =
active_context_tokens_before.unwrap_or(self.active_context_tokens_before);
let active_context_tokens_after = sess.get_total_token_usage().await;
@@ -382,6 +394,8 @@ impl CompactionAnalyticsAttempt {
error,
active_context_tokens_before,
active_context_tokens_after,
retained_image_count,
compaction_summary_tokens,
started_at: self.started_at,
completed_at: now_unix_seconds(),
duration_ms: Some(
+16 -18
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::Prompt;
use crate::client::CompactConversationRequestSettings;
use crate::compact::CompactionAnalyticsAttempt;
use crate::compact::CompactionAnalyticsDetails;
use crate::compact::InitialContextInjection;
use crate::compact::compaction_status_from_result;
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
@@ -100,7 +101,10 @@ async fn run_remote_compact_task_inner(
CompactionImplementation::ResponsesCompact,
phase,
);
let mut active_context_tokens_before = sess.get_total_token_usage().await;
let mut analytics_details = CompactionAnalyticsDetails {
active_context_tokens_before: Some(sess.get_total_token_usage().await),
..Default::default()
};
let attempt = CompactionAnalyticsAttempt::begin(
sess.as_ref(),
turn_context.as_ref(),
@@ -120,7 +124,7 @@ async fn run_remote_compact_task_inner(
sess.as_ref(),
codex_analytics::CompactionStatus::Interrupted,
Some(error),
Some(active_context_tokens_before),
analytics_details,
)
.await;
return Err(CodexErr::TurnAborted);
@@ -131,7 +135,7 @@ async fn run_remote_compact_task_inner(
turn_context,
initial_context_injection,
compaction_metadata,
&mut active_context_tokens_before,
&mut analytics_details,
)
.await;
let status = compaction_status_from_result(&result);
@@ -140,23 +144,13 @@ async fn run_remote_compact_task_inner(
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt
.track(
sess.as_ref(),
status,
error,
Some(active_context_tokens_before),
)
.track(sess.as_ref(), status, error, analytics_details)
.await;
return Err(CodexErr::TurnAborted);
}
}
attempt
.track(
sess.as_ref(),
status,
error.clone(),
Some(active_context_tokens_before),
)
.track(sess.as_ref(), status, error.clone(), analytics_details)
.await;
if let Err(err) = result {
sess.track_turn_codex_error(turn_context, &err);
@@ -174,7 +168,7 @@ async fn run_remote_compact_task_inner_impl(
turn_context: &Arc<TurnContext>,
initial_context_injection: InitialContextInjection,
compaction_metadata: CompactionTurnMetadata,
active_context_tokens_before: &mut i64,
analytics_details: &mut CompactionAnalyticsDetails,
) -> CodexResult<()> {
let context_compaction_item = ContextCompactionItem::new();
// Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events,
@@ -208,8 +202,12 @@ async fn run_remote_compact_task_inner_impl(
.get_total_token_usage_breakdown()
.await
.estimated_tokens_of_items_added_since_last_successful_api_response;
*active_context_tokens_before = (*active_context_tokens_before)
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens));
analytics_details.active_context_tokens_before = analytics_details
.active_context_tokens_before
.map(|active_context_tokens_before| {
active_context_tokens_before
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
});
}
// This is the history selected for remote compaction, after any output rewriting required to
// fit the compact endpoint. The checkpoint below records it separately from the next sampling
+69 -24
View File
@@ -5,6 +5,7 @@ use crate::ResponseStream;
use crate::client::ModelClientSession;
use crate::client_common::ResponseEvent;
use crate::compact::CompactionAnalyticsAttempt;
use crate::compact::CompactionAnalyticsDetails;
use crate::compact::InitialContextInjection;
use crate::compact::compaction_status_from_result;
use crate::compact_remote::build_compact_request_log_data;
@@ -112,7 +113,10 @@ async fn run_remote_compact_task_inner(
CompactionImplementation::ResponsesCompactionV2,
phase,
);
let mut active_context_tokens_before = sess.get_total_token_usage().await;
let mut analytics_details = CompactionAnalyticsDetails {
active_context_tokens_before: Some(sess.get_total_token_usage().await),
..Default::default()
};
let attempt = CompactionAnalyticsAttempt::begin(
sess.as_ref(),
turn_context.as_ref(),
@@ -132,7 +136,7 @@ async fn run_remote_compact_task_inner(
sess.as_ref(),
codex_analytics::CompactionStatus::Interrupted,
Some(error),
Some(active_context_tokens_before),
analytics_details,
)
.await;
return Err(CodexErr::TurnAborted);
@@ -144,7 +148,7 @@ async fn run_remote_compact_task_inner(
client_session,
initial_context_injection,
compaction_metadata,
&mut active_context_tokens_before,
&mut analytics_details,
)
.await;
let status = compaction_status_from_result(&result);
@@ -153,23 +157,13 @@ async fn run_remote_compact_task_inner(
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
attempt
.track(
sess.as_ref(),
status,
error,
Some(active_context_tokens_before),
)
.track(sess.as_ref(), status, error, analytics_details)
.await;
return Err(CodexErr::TurnAborted);
}
}
attempt
.track(
sess.as_ref(),
status,
error.clone(),
Some(active_context_tokens_before),
)
.track(sess.as_ref(), status, error.clone(), analytics_details)
.await;
if let Err(err) = result {
sess.track_turn_codex_error(turn_context, &err);
@@ -188,7 +182,7 @@ async fn run_remote_compact_task_inner_impl(
client_session: Option<&mut ModelClientSession>,
initial_context_injection: InitialContextInjection,
compaction_metadata: CompactionTurnMetadata,
active_context_tokens_before: &mut i64,
analytics_details: &mut CompactionAnalyticsDetails,
) -> CodexResult<()> {
let context_compaction_item = ContextCompactionItem::new();
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
@@ -221,8 +215,12 @@ async fn run_remote_compact_task_inner_impl(
.get_total_token_usage_breakdown()
.await
.estimated_tokens_of_items_added_since_last_successful_api_response;
*active_context_tokens_before = (*active_context_tokens_before)
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens));
analytics_details.active_context_tokens_before = analytics_details
.active_context_tokens_before
.map(|active_context_tokens_before| {
active_context_tokens_before
.saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens))
});
}
let trace_input_history = history.raw_items().to_vec();
@@ -283,9 +281,12 @@ async fn run_remote_compact_task_inner_impl(
token_usage,
} = compaction_output_result?;
if let Some(token_usage) = token_usage {
*active_context_tokens_before = token_usage.input_tokens;
analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens);
}
let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output);
let (compacted_history, retained_images) =
build_v2_compacted_history(&prompt_input, compaction_output);
analytics_details.retained_image_count = Some(retained_images);
let new_history = process_compacted_history(
sess.as_ref(),
turn_context.as_ref(),
@@ -447,7 +448,7 @@ async fn collect_compaction_output(
fn build_v2_compacted_history(
prompt_input: &[ResponseItem],
compaction_output: ResponseItem,
) -> Vec<ResponseItem> {
) -> (Vec<ResponseItem>, usize) {
let retained = prompt_input
.iter()
.filter(|item| is_retained_for_remote_compaction_v2(item))
@@ -456,8 +457,12 @@ fn build_v2_compacted_history(
.collect::<Vec<_>>();
let mut retained =
truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET);
let retained_image_count = retained
.iter()
.map(retained_input_image_count)
.sum::<usize>();
retained.push(compaction_output);
retained
(retained, retained_image_count)
}
fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
@@ -468,6 +473,17 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
matches!(role.as_str(), "user" | "developer" | "system")
}
fn retained_input_image_count(item: &ResponseItem) -> usize {
let ResponseItem::Message { content, .. } = item else {
return 0;
};
content
.iter()
.filter(|item| matches!(item, ContentItem::InputImage { .. }))
.count()
}
fn truncate_retained_messages_for_remote_compaction(
items: Vec<ResponseItem>,
max_tokens: usize,
@@ -617,7 +633,7 @@ mod tests {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
let (history, _) = build_v2_compacted_history(&input, output.clone());
assert_eq!(
history,
@@ -644,11 +660,40 @@ mod tests {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
let (history, _) = build_v2_compacted_history(&input, output.clone());
assert_eq!(history, vec![old, new, output]);
}
#[test]
fn build_v2_compacted_history_counts_retained_input_images() {
let input = vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "user".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::InputImage {
image_url: "data:image/png;base64,def".to_string(),
detail: None,
},
],
phase: None,
}];
let output = ResponseItem::Compaction {
encrypted_content: "new".to_string(),
};
let (_, retained_image_count) = build_v2_compacted_history(&input, output);
assert_eq!(retained_image_count, 2);
}
#[test]
fn retained_history_truncation_keeps_newest_messages_first() {
let middle = message("user", "middle1234", /*phase*/ None);