mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
a998c7a1ce
commit
e683c3118b
2 files changed
+95
-12
No files matched your search
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user