Record Guardian v2 classification metrics (#39241)

## What changed

- Emit a `codex.guardian_v2.classification` counter for each classification.
- Record classification latency in `codex.guardian_v2.classification.duration_ms`.
- Tag both metrics with `success`, `failure`, or `superseded` outcomes.

## Testing

- Verify metrics for successful classification and fail-closed handling.

GitOrigin-RevId: 021702f3b26565d6da5708d2b8f1f8881942a441
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-18 17:48:35 +00:00
1 parent a998c7a1ce
commit e683c3118b
2 files changed
+95 -12

No files matched your search

+61 -10
View File
@@ -3,6 +3,8 @@ use std::sync::Arc;
use std::sync::Weak; use std::sync::Weak;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use std::time::SystemTime; use std::time::SystemTime;
use codex_core::ThreadManager; use codex_core::ThreadManager;
@@ -195,11 +197,31 @@ struct GuardianV2Enabled;
// Sampled when the approval contributor evaluates the cached Luna score. // Sampled when the approval contributor evaluates the cached Luna score.
const TOOL_CALL_LAG_METRIC: &str = "codex.guardian_v2.tool_call_lag"; const TOOL_CALL_LAG_METRIC: &str = "codex.guardian_v2.tool_call_lag";
const REVIEW_FALLBACK_METRIC: &str = "codex.guardian_v2.review_fallback"; const REVIEW_FALLBACK_METRIC: &str = "codex.guardian_v2.review_fallback";
const CLASSIFICATION_METRIC: &str = "codex.guardian_v2.classification";
const CLASSIFICATION_DURATION_METRIC: &str = "codex.guardian_v2.classification.duration_ms";
#[derive(Default)] #[derive(Default)]
struct GuardianV2ScoreProgress { struct GuardianV2ScoreProgress {
latest_tool_call: AtomicUsize, latest_tool_call: AtomicUsize,
latest_scored_tool_call: AtomicUsize, latest_scored_tool_call: AtomicUsize,
metrics: Option<Arc<dyn ExtensionMetrics>>,
}
fn record_classification(
metrics: Option<&dyn ExtensionMetrics>,
duration: Duration,
outcome: &str,
) {
let Some(metrics) = metrics else {
return;
};
let tags = [("outcome", outcome)];
metrics.counter(CLASSIFICATION_METRIC, /*inc*/ 1, &tags);
metrics.histogram(
CLASSIFICATION_DURATION_METRIC,
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX),
&tags,
);
} }
#[derive(Clone)] #[derive(Clone)]
@@ -275,9 +297,10 @@ impl ThreadLifecycleContributor<Config> for GuardianV2Extension {
} }
input.thread_store.insert(sampler); input.thread_store.insert(sampler);
input.thread_store.insert(guardian_config); input.thread_store.insert(guardian_config);
input input.thread_store.insert(GuardianV2ScoreProgress {
.thread_store metrics: input.extension_metrics.clone(),
.insert(GuardianV2ScoreProgress::default()); ..Default::default()
});
input.thread_store.insert(GuardianV2Enabled); input.thread_store.insert(GuardianV2Enabled);
} }
Err(error) => self.event_sink.emit_warning(ExtensionWarning { Err(error) => self.event_sink.emit_warning(ExtensionWarning {
@@ -345,12 +368,17 @@ impl ApprovalReviewContributor for GuardianV2Extension {
impl ToolLifecycleContributor for GuardianV2Extension { impl ToolLifecycleContributor for GuardianV2Extension {
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> { fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
let classification_started_at = Instant::now();
let Some(sampler) = input.thread_store.get::<LunaSampler>() else { let Some(sampler) = input.thread_store.get::<LunaSampler>() else {
return Box::pin(std::future::ready(())); return Box::pin(std::future::ready(()));
}; };
let Some(guardian_config) = input.thread_store.get::<GuardianV2Config>() else { let Some(guardian_config) = input.thread_store.get::<GuardianV2Config>() else {
return Box::pin(std::future::ready(())); return Box::pin(std::future::ready(()));
}; };
let Some(score_progress) = input.thread_store.get::<GuardianV2ScoreProgress>() else {
return Box::pin(std::future::ready(()));
};
let metrics = score_progress.metrics.clone();
let parent_model = input.thread_store.get::<ModelInfo>(); let parent_model = input.thread_store.get::<ModelInfo>();
let model_defaults = parent_model let model_defaults = parent_model
.as_ref() .as_ref()
@@ -359,6 +387,11 @@ impl ToolLifecycleContributor for GuardianV2Extension {
let guardian_config = match guardian_config.with_model_defaults(model_defaults) { let guardian_config = match guardian_config.with_model_defaults(model_defaults) {
Ok(config) => config, Ok(config) => config,
Err(error) => { Err(error) => {
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
);
self.event_sink.emit_warning(ExtensionWarning { self.event_sink.emit_warning(ExtensionWarning {
thread_id: input.thread_store.level_id().to_owned(), thread_id: input.thread_store.level_id().to_owned(),
turn_id: Some(input.turn_id.to_owned()), turn_id: Some(input.turn_id.to_owned()),
@@ -368,9 +401,6 @@ impl ToolLifecycleContributor for GuardianV2Extension {
} }
}; };
input.thread_store.insert(guardian_config.clone()); input.thread_store.insert(guardian_config.clone());
let Some(score_progress) = input.thread_store.get::<GuardianV2ScoreProgress>() else {
return Box::pin(std::future::ready(()));
};
let tool_call_index = score_progress let tool_call_index = score_progress
.latest_tool_call .latest_tool_call
.fetch_add(/*val*/ 1, Ordering::Relaxed) .fetch_add(/*val*/ 1, Ordering::Relaxed)
@@ -414,6 +444,11 @@ impl ToolLifecycleContributor for GuardianV2Extension {
input.thread_store.insert_if(score.clone(), |previous| { input.thread_store.insert_if(score.clone(), |previous| {
previous.is_none_or(|previous| previous.sampled_at <= score.sampled_at) previous.is_none_or(|previous| previous.sampled_at <= score.sampled_at)
}); });
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
);
return Box::pin(std::future::ready(())); return Box::pin(std::future::ready(()));
} }
let event_sink = Arc::clone(&self.event_sink); let event_sink = Arc::clone(&self.event_sink);
@@ -452,6 +487,11 @@ impl ToolLifecycleContributor for GuardianV2Extension {
let planned_action = match action.render(guardian_config.max_action_tokens) { let planned_action = match action.render(guardian_config.max_action_tokens) {
Ok(planned_action) => planned_action, Ok(planned_action) => planned_action,
Err(error) => { Err(error) => {
record_classification(
metrics.as_deref(),
classification_started_at.elapsed(),
"failure",
);
event_sink.emit_warning(ExtensionWarning { event_sink.emit_warning(ExtensionWarning {
thread_id, thread_id,
turn_id: Some(turn_id), turn_id: Some(turn_id),
@@ -470,7 +510,8 @@ impl ToolLifecycleContributor for GuardianV2Extension {
format!("{planned_action}\n"), format!("{planned_action}\n"),
">>> APPROVAL REQUEST END\n".to_owned(), ">>> APPROVAL REQUEST END\n".to_owned(),
]); ]);
let result: Result<(), String> = async { let mut classification_finished_at = None;
let result: Result<&str, String> = async {
let parsed_thread_id = let parsed_thread_id =
ThreadId::from_string(&thread_id).map_err(|error| error.to_string())?; ThreadId::from_string(&thread_id).map_err(|error| error.to_string())?;
let manager = thread_manager let manager = thread_manager
@@ -544,7 +585,7 @@ impl ToolLifecycleContributor for GuardianV2Extension {
.await .await
{ {
Ok(output) => output, Ok(output) => output,
Err(LunaSamplerError::Superseded) => return Ok(()), Err(LunaSamplerError::Superseded) => return Ok("superseded"),
Err(error) => return Err(error.to_string()), Err(error) => return Err(error.to_string()),
}; };
let output: serde_json::Value = let output: serde_json::Value =
@@ -573,20 +614,30 @@ impl ToolLifecycleContributor for GuardianV2Extension {
previous.is_none_or(|previous| previous.sampled_at < score.sampled_at) previous.is_none_or(|previous| previous.sampled_at < score.sampled_at)
}) })
{ {
return Ok(()); return Ok("superseded");
} }
score_progress score_progress
.latest_scored_tool_call .latest_scored_tool_call
.fetch_max(tool_call_index, Ordering::Release); .fetch_max(tool_call_index, Ordering::Release);
classification_finished_at = Some(Instant::now());
if !config.ephemeral { if !config.ephemeral {
thread thread
.append_rollout_items(&[RolloutItem::SecurityRiskScore(score)]) .append_rollout_items(&[RolloutItem::SecurityRiskScore(score)])
.await .await
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
} }
Ok(()) Ok("success")
} }
.await; .await;
record_classification(
metrics.as_deref(),
classification_finished_at
.map(|finished_at: Instant| {
finished_at.duration_since(classification_started_at)
})
.unwrap_or_else(|| classification_started_at.elapsed()),
result.as_deref().unwrap_or("failure"),
);
if let Err(error) = result { if let Err(error) = result {
event_sink.emit_warning(ExtensionWarning { event_sink.emit_warning(ExtensionWarning {
thread_id, thread_id,
@@ -48,6 +48,8 @@ use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use super::CLASSIFICATION_DURATION_METRIC;
use super::CLASSIFICATION_METRIC;
use super::GuardianV2ScoreProgress; use super::GuardianV2ScoreProgress;
use super::REVIEW_FALLBACK_METRIC; use super::REVIEW_FALLBACK_METRIC;
use super::StrictReviewReason; use super::StrictReviewReason;
@@ -632,11 +634,13 @@ max_recent_non_user_entries = 8
let score_progress = thread_store let score_progress = thread_store
.get::<GuardianV2ScoreProgress>() .get::<GuardianV2ScoreProgress>()
.expect("Guardian v2 should track score progress per thread"); .expect("Guardian v2 should track score progress per thread");
let metrics = thread_store.get::<RecordingMetrics>().unwrap();
tokio::time::timeout(Duration::from_secs(5), async { tokio::time::timeout(Duration::from_secs(5), async {
while score_progress while score_progress
.latest_scored_tool_call .latest_scored_tool_call
.load(Ordering::Acquire) .load(Ordering::Acquire)
== 0 == 0
|| metrics.0.lock().unwrap().len() < 2
{ {
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
@@ -745,9 +749,28 @@ max_recent_non_user_entries = 8
Some(ReviewDecision::Approved) Some(ReviewDecision::Approved)
); );
let samples = initial_metrics.0.lock().unwrap();
let classification_duration_ms = match &samples[1] {
RecordedMetric::Histogram(name, duration_ms, _)
if name == CLASSIFICATION_DURATION_METRIC =>
{
*duration_ms
}
sample => panic!("expected classification duration metric, got {sample:?}"),
};
assert_eq!( assert_eq!(
*initial_metrics.0.lock().unwrap(), *samples,
vec![ vec![
RecordedMetric::Counter(
CLASSIFICATION_METRIC.to_owned(),
1,
vec![("outcome".to_owned(), "success".to_owned())],
),
RecordedMetric::Histogram(
CLASSIFICATION_DURATION_METRIC.to_owned(),
classification_duration_ms,
vec![("outcome".to_owned(), "success".to_owned())],
),
RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]), RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]),
RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]), RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 0, vec![]),
RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 2, vec![]), RecordedMetric::Histogram(TOOL_CALL_LAG_METRIC.to_owned(), 2, vec![]),
@@ -1338,6 +1361,7 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<
let registry = builder.build(); let registry = builder.build();
let session_store = ExtensionData::new("session-1"); let session_store = ExtensionData::new("session-1");
let thread_store = test.codex.thread_extension_data(); let thread_store = test.codex.thread_extension_data();
let metrics = Arc::new(RecordingMetrics::default());
thread_store.insert(parent_model); thread_store.insert(parent_model);
registry.thread_lifecycle_contributors()[0] registry.thread_lifecycle_contributors()[0]
.on_thread_start(ThreadStartInput { .on_thread_start(ThreadStartInput {
@@ -1346,7 +1370,7 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<
persistent_thread_state_available: false, persistent_thread_state_available: false,
environments: &[], environments: &[],
mcp_resource_client: None, mcp_resource_client: None,
extension_metrics: None, extension_metrics: Some(metrics.clone()),
session_store: &session_store, session_store: &session_store,
thread_store, thread_store,
}) })
@@ -1488,6 +1512,14 @@ async fn contributor_reuses_the_latest_compatible_parent_compaction() -> Result<
1, 1,
"an oversized latest compaction must bypass Luna rather than reuse stale context" "an oversized latest compaction must bypass Luna rather than reuse stale context"
); );
assert!(metrics.0.lock().unwrap().iter().any(|sample| {
matches!(
sample,
RecordedMetric::Counter(name, 1, tags)
if name == CLASSIFICATION_METRIC
&& tags == &[("outcome".to_owned(), "failure".to_owned())]
)
}));
Ok(()) Ok(())
} }