Skip to main content

codex_rollout/
persistence_metrics.rs

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
96/// Measures logical JSON sizes while applying the shared rollout persistence policy once.
97pub 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                    /*inc*/ 1,
337                    &[
338                        ("rollout_item_type", item.rollout_item_type.as_str()),
339                        ("phase", "serialize"),
340                    ],
341                );
342            }
343        }
344        // Count successful input appends and the subset that remain storage operations after the
345        // persistence policy removes filtered items.
346        let _ = metrics.counter(
347            APPEND_METRIC,
348            /*inc*/ 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                /*inc*/ 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                /*inc*/ 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;