1use std::io::Write;
2use std::sync::Arc;
3use std::sync::Mutex;
4
5use codex_otel::MetricsClient;
6use codex_protocol::ThreadId;
7use codex_protocol::items::TurnItem;
8use codex_protocol::models::ResponseItem;
9use codex_protocol::protocol::EventMsg;
10use codex_protocol::protocol::RolloutItem;
11use codex_protocol::protocol::ThreadHistoryMode;
12
13use crate::policy::is_persisted_rollout_item;
14
15const ITEM_BYTES_METRIC: &str = "codex.rollout.persistence.item_bytes";
16const APPEND_METRIC: &str = "codex.rollout.persistence.append";
17const TURN_BYTES_METRIC: &str = "codex.rollout.persistence.turn_bytes";
18const MEASUREMENT_ERROR_METRIC: &str = "codex.rollout.persistence.measurement_error";
19const SAMPLE_DENOMINATOR: u64 = 100;
20const SAMPLE_RATE_LABEL: &str = "0.01";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum PersistenceDecision {
24 Kept,
25 Dropped,
26}
27
28impl PersistenceDecision {
29 fn as_str(self) -> &'static str {
30 match self {
31 Self::Kept => "kept",
32 Self::Dropped => "dropped",
33 }
34 }
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct RolloutSizeTotals {
39 pub items: u64,
40 pub payload_bytes: u64,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RolloutItemMeasurement {
45 pub decision: PersistenceDecision,
46 pub rollout_item_type: String,
47 pub payload_bytes: Option<u64>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct RolloutPersistenceBatchMeasurement {
52 pub pre_filter: RolloutSizeTotals,
53 pub post_filter: RolloutSizeTotals,
54 pub items: Vec<RolloutItemMeasurement>,
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58struct TurnSizeTotals {
59 pre_filter: RolloutSizeTotals,
60 post_filter: RolloutSizeTotals,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum TurnOutcome {
65 Completed,
66 Aborted,
67}
68
69impl TurnOutcome {
70 fn as_str(self) -> &'static str {
71 match self {
72 Self::Completed => "completed",
73 Self::Aborted => "aborted",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79struct CompletedTurnMeasurement {
80 totals: TurnSizeTotals,
81 outcome: TurnOutcome,
82}
83
84#[derive(Debug, Default, PartialEq, Eq)]
85struct TurnMeasurementState {
86 pending: TurnSizeTotals,
87 active: Option<TurnSizeTotals>,
88}
89
90#[derive(Debug, Default, PartialEq, Eq)]
91struct TurnMeasurementUpdate {
92 completed: Vec<CompletedTurnMeasurement>,
93 boundary_errors: Vec<&'static str>,
94}
95
96pub fn measure_and_filter_rollout_items(
98 items: &[RolloutItem],
99 history_mode: ThreadHistoryMode,
100) -> (Vec<RolloutItem>, RolloutPersistenceBatchMeasurement) {
101 let mut persisted = Vec::new();
102 let mut measurement = RolloutPersistenceBatchMeasurement {
103 items: Vec::with_capacity(items.len()),
104 ..Default::default()
105 };
106
107 for item in items {
108 let kept = is_persisted_rollout_item(item, history_mode);
109 let decision = if kept {
110 PersistenceDecision::Kept
111 } else {
112 PersistenceDecision::Dropped
113 };
114 let payload_bytes = serialized_len(item).ok();
115 add_to_totals(&mut measurement.pre_filter, payload_bytes);
116 if kept {
117 add_to_totals(&mut measurement.post_filter, payload_bytes);
118 persisted.push(item.clone());
119 }
120 measurement.items.push(RolloutItemMeasurement {
121 decision,
122 rollout_item_type: rollout_item_type(item),
123 payload_bytes,
124 });
125 }
126
127 (persisted, measurement)
128}
129
130fn add_to_totals(totals: &mut RolloutSizeTotals, payload_bytes: Option<u64>) {
131 totals.items = totals.items.saturating_add(1);
132 if let Some(payload_bytes) = payload_bytes {
133 totals.payload_bytes = totals.payload_bytes.saturating_add(payload_bytes);
134 }
135}
136
137fn update_turn_measurements(
138 state: &mut TurnMeasurementState,
139 items: &[RolloutItem],
140 measurement: &RolloutPersistenceBatchMeasurement,
141) -> TurnMeasurementUpdate {
142 let mut update = TurnMeasurementUpdate::default();
143 for (item, item_measurement) in items.iter().zip(&measurement.items) {
144 match item {
145 RolloutItem::EventMsg(EventMsg::TurnStarted(_)) => {
146 if state.active.take().is_some() {
147 update.boundary_errors.push("event.turn_started");
148 }
149 let mut totals = std::mem::take(&mut state.pending);
150 add_item_to_turn(&mut totals, item_measurement);
151 state.active = Some(totals);
152 }
153 RolloutItem::EventMsg(EventMsg::TurnComplete(_)) => {
154 finish_turn(
155 state,
156 item_measurement,
157 TurnOutcome::Completed,
158 "event.turn_complete",
159 &mut update,
160 );
161 }
162 RolloutItem::EventMsg(EventMsg::TurnAborted(_)) => {
163 finish_turn(
164 state,
165 item_measurement,
166 TurnOutcome::Aborted,
167 "event.turn_aborted",
168 &mut update,
169 );
170 }
171 _ => match state.active.as_mut() {
172 Some(totals) => add_item_to_turn(totals, item_measurement),
173 None => add_item_to_turn(&mut state.pending, item_measurement),
174 },
175 }
176 }
177 update
178}
179
180fn finish_turn(
181 state: &mut TurnMeasurementState,
182 item: &RolloutItemMeasurement,
183 outcome: TurnOutcome,
184 boundary_type: &'static str,
185 update: &mut TurnMeasurementUpdate,
186) {
187 let Some(mut totals) = state.active.take() else {
188 state.pending = TurnSizeTotals::default();
189 update.boundary_errors.push(boundary_type);
190 return;
191 };
192 add_item_to_turn(&mut totals, item);
193 update
194 .completed
195 .push(CompletedTurnMeasurement { totals, outcome });
196}
197
198fn add_item_to_turn(totals: &mut TurnSizeTotals, item: &RolloutItemMeasurement) {
199 add_to_totals(&mut totals.pre_filter, item.payload_bytes);
200 if item.decision == PersistenceDecision::Kept {
201 add_to_totals(&mut totals.post_filter, item.payload_bytes);
202 }
203}
204
205fn serialized_len(item: &RolloutItem) -> serde_json::Result<u64> {
206 let mut writer = CountingWriter::default();
207 serde_json::to_writer(&mut writer, item)?;
208 Ok(writer.bytes)
209}
210
211#[derive(Default)]
212struct CountingWriter {
213 bytes: u64,
214}
215
216impl Write for CountingWriter {
217 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
218 self.bytes = self.bytes.saturating_add(buf.len() as u64);
219 Ok(buf.len())
220 }
221
222 fn flush(&mut self) -> std::io::Result<()> {
223 Ok(())
224 }
225}
226
227fn rollout_item_type(item: &RolloutItem) -> String {
228 match item {
229 RolloutItem::SessionMeta(_) => "session_meta".to_string(),
230 RolloutItem::ResponseItem(item) => response_item_type(item).to_string(),
231 RolloutItem::InterAgentCommunication(_) => "inter_agent_communication".to_string(),
232 RolloutItem::InterAgentCommunicationMetadata { .. } => {
233 "inter_agent_communication_metadata".to_string()
234 }
235 RolloutItem::Compacted(_) => "compacted".to_string(),
236 RolloutItem::TurnContext(_) => "turn_context".to_string(),
237 RolloutItem::WorldState(_) => "world_state".to_string(),
238 RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => {
239 format!("event.item_completed.{}", turn_item_type(&event.item))
240 }
241 RolloutItem::EventMsg(event) => format!("event.{event}"),
242 }
243}
244
245fn turn_item_type(item: &TurnItem) -> &'static str {
246 match item {
247 TurnItem::UserMessage(_) => "user_message",
248 TurnItem::HookPrompt(_) => "hook_prompt",
249 TurnItem::AgentMessage(_) => "agent_message",
250 TurnItem::Plan(_) => "plan",
251 TurnItem::Reasoning(_) => "reasoning",
252 TurnItem::CommandExecution(_) => "command_execution",
253 TurnItem::DynamicToolCall(_) => "dynamic_tool_call",
254 TurnItem::CollabAgentToolCall(_) => "collab_agent_tool_call",
255 TurnItem::SubAgentActivity(_) => "sub_agent_activity",
256 TurnItem::WebSearch(_) => "web_search",
257 TurnItem::ImageView(_) => "image_view",
258 TurnItem::Extension(_) => "extension",
259 TurnItem::ImageGeneration(_) => "image_generation",
260 TurnItem::EnteredReviewMode(_) => "entered_review_mode",
261 TurnItem::ExitedReviewMode(_) => "exited_review_mode",
262 TurnItem::FileChange(_) => "file_change",
263 TurnItem::McpToolCall(_) => "mcp_tool_call",
264 TurnItem::ContextCompaction(_) => "context_compaction",
265 }
266}
267
268fn response_item_type(item: &ResponseItem) -> &'static str {
269 match item {
270 ResponseItem::Message { .. } => "response.message",
271 ResponseItem::AdditionalTools { .. } => "response.additional_tools",
272 ResponseItem::AgentMessage { .. } => "response.agent_message",
273 ResponseItem::Reasoning { .. } => "response.reasoning",
274 ResponseItem::LocalShellCall { .. } => "response.local_shell_call",
275 ResponseItem::FunctionCall { .. } => "response.function_call",
276 ResponseItem::ToolSearchCall { .. } => "response.tool_search_call",
277 ResponseItem::FunctionCallOutput { .. } => "response.function_call_output",
278 ResponseItem::ToolSearchOutput { .. } => "response.tool_search_output",
279 ResponseItem::CustomToolCall { .. } => "response.custom_tool_call",
280 ResponseItem::CustomToolCallOutput { .. } => "response.custom_tool_call_output",
281 ResponseItem::WebSearchCall { .. } => "response.web_search_call",
282 ResponseItem::ImageGenerationCall { .. } => "response.image_generation_call",
283 ResponseItem::Compaction { .. } => "response.compaction",
284 ResponseItem::CompactionTrigger { .. } => "response.compaction_trigger",
285 ResponseItem::ContextCompaction { .. } => "response.context_compaction",
286 ResponseItem::Other => "response.other",
287 }
288}
289
290#[derive(Clone)]
291pub struct RolloutPersistenceTelemetry {
292 metrics: Option<MetricsClient>,
293 sampled: bool,
294 turn_state: Option<Arc<Mutex<TurnMeasurementState>>>,
295}
296
297impl RolloutPersistenceTelemetry {
298 pub fn new(thread_id: ThreadId) -> Self {
299 let metrics = codex_otel::global();
300 let sampled = metrics.is_some() && is_thread_sampled(thread_id);
301 Self {
302 metrics,
303 sampled,
304 turn_state: sampled.then(|| Arc::new(Mutex::new(TurnMeasurementState::default()))),
305 }
306 }
307
308 pub fn is_enabled(&self) -> bool {
309 self.enabled_metrics().is_some()
310 }
311
312 pub fn record_batch(
313 &self,
314 items: &[RolloutItem],
315 measurement: &RolloutPersistenceBatchMeasurement,
316 ) {
317 let Some(metrics) = self.enabled_metrics() else {
318 return;
319 };
320
321 for item in &measurement.items {
322 if let Some(payload_bytes) = item.payload_bytes {
323 let _ = metrics.histogram(
324 ITEM_BYTES_METRIC,
325 saturating_i64(payload_bytes),
326 &[
327 ("decision", item.decision.as_str()),
328 ("rollout_item_type", item.rollout_item_type.as_str()),
329 ("encoding", "rollout_item_json_v1"),
330 ("sample_rate", SAMPLE_RATE_LABEL),
331 ],
332 );
333 } else {
334 let _ = metrics.counter(
335 MEASUREMENT_ERROR_METRIC,
336 1,
337 &[
338 ("rollout_item_type", item.rollout_item_type.as_str()),
339 ("phase", "serialize"),
340 ],
341 );
342 }
343 }
344 let _ = metrics.counter(
347 APPEND_METRIC,
348 1,
349 &[("stage", "pre_filter"), ("sample_rate", SAMPLE_RATE_LABEL)],
350 );
351 if measurement.post_filter.items > 0 {
352 let _ = metrics.counter(
353 APPEND_METRIC,
354 1,
355 &[("stage", "post_filter"), ("sample_rate", SAMPLE_RATE_LABEL)],
356 );
357 }
358
359 let Some(turn_state) = self.turn_state.as_ref() else {
360 return;
361 };
362 let turn_update = update_turn_measurements(
363 &mut turn_state
364 .lock()
365 .unwrap_or_else(std::sync::PoisonError::into_inner),
366 items,
367 measurement,
368 );
369 for boundary_type in turn_update.boundary_errors {
370 let _ = metrics.counter(
371 MEASUREMENT_ERROR_METRIC,
372 1,
373 &[
374 ("rollout_item_type", boundary_type),
375 ("phase", "turn_boundary"),
376 ],
377 );
378 }
379 for turn in turn_update.completed {
380 for (stage, totals) in [
381 ("pre_filter", turn.totals.pre_filter),
382 ("post_filter", turn.totals.post_filter),
383 ] {
384 let _ = metrics.histogram(
385 TURN_BYTES_METRIC,
386 saturating_i64(totals.payload_bytes),
387 &[
388 ("stage", stage),
389 ("outcome", turn.outcome.as_str()),
390 ("encoding", "rollout_item_json_v1"),
391 ("sample_rate", SAMPLE_RATE_LABEL),
392 ],
393 );
394 }
395 }
396 }
397
398 fn enabled_metrics(&self) -> Option<&MetricsClient> {
399 self.sampled.then_some(self.metrics.as_ref()).flatten()
400 }
401}
402
403fn saturating_i64(value: u64) -> i64 {
404 value.try_into().unwrap_or(i64::MAX)
405}
406
407fn is_thread_sampled(thread_id: ThreadId) -> bool {
408 let hash = thread_id
409 .to_string()
410 .bytes()
411 .fold(0xcbf29ce484222325_u64, |hash, byte| {
412 (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
413 });
414 hash % SAMPLE_DENOMINATOR == 0
415}
416
417#[cfg(test)]
418#[path = "persistence_metrics_tests.rs"]
419mod tests;