mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
Track Guardian reviewer turn and tool analytics (#41023)
## Why Trusted Guardian reviewer sessions can emit events without an app-server listener, leaving their turns and tool usage absent from analytics. ## What changed - Translate Guardian session lifecycle and canonical tool item events into analytics notifications while excluding messages, reasoning, and review content. - Preserve Guardian lineage during prewarming even when inherited client metadata is not yet available, without emitting an incomplete thread initialization event. - Limit direct session tracking to trusted Guardian children so resumed threads that have an app-server listener do not report duplicate events. ## Testing - Cover completed, failed, and interrupted Guardian turns, tool counts and timing, lineage, content exclusion, prewarmed lineage, and resumed-thread deduplication. GitOrigin-RevId: 70ace5f7ed30a7e3b6a9d6097c8b2a6a326dc4f7
This commit is contained in:
1 parent
81e180044d
commit
307ce6cda9
10 files changed
+467
-28
No files matched your search
@@ -8,6 +8,8 @@ use app_test_support::create_mock_responses_server_sequence;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||
use codex_app_server_protocol::ApprovalsReviewer;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
@@ -18,6 +20,7 @@ use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
@@ -49,6 +52,197 @@ use wiremock::matchers::path_regex;
|
||||
|
||||
const SERVICE_VERSION: &str = "0.0.0-test";
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_turns_and_tools_reach_analytics() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
const PRIVATE: &str = "guardian-private-content";
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
let server = responses::start_mock_server().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
mount_analytics_capture(&server, codex_home.path()).await?;
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
|
||||
.with_provider_config("supports_websockets = false")
|
||||
.enable_feature(Feature::GuardianApproval)
|
||||
.disable_feature(Feature::Apps)
|
||||
.write(codex_home.path())?;
|
||||
let parent_tool = |id: &str| {
|
||||
responses::sse_response(responses::sse(vec![
|
||||
responses::ev_response_created(id),
|
||||
responses::ev_function_call(
|
||||
id,
|
||||
"exec_command",
|
||||
&json!({
|
||||
"cmd": format!("echo {id} > {id}.txt"),
|
||||
"sandbox_permissions": "require_escalated",
|
||||
"justification": "Exercise Guardian analytics",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed(id),
|
||||
]))
|
||||
};
|
||||
let requests = responses::mount_response_sequence(
|
||||
&server,
|
||||
vec![
|
||||
parent_tool("parent-first"),
|
||||
responses::sse_response(responses::sse(vec![
|
||||
responses::ev_response_created("guardian-first"),
|
||||
responses::ev_web_search_call_added_partial("guardian-search", "in_progress"),
|
||||
responses::ev_web_search_call_done("guardian-search", "completed", PRIVATE),
|
||||
responses::ev_reasoning_item("guardian-reasoning", &[PRIVATE], &[PRIVATE]),
|
||||
responses::ev_assistant_message(
|
||||
"guardian-answer",
|
||||
&json!({"outcome": "deny", "rationale": PRIVATE}).to_string(),
|
||||
),
|
||||
responses::ev_completed("guardian-first"),
|
||||
])),
|
||||
parent_tool("parent-second"),
|
||||
responses::sse_response(responses::sse_failed(
|
||||
"guardian-failed",
|
||||
"invalid_prompt",
|
||||
PRIVATE,
|
||||
)),
|
||||
parent_tool("parent-third"),
|
||||
responses::sse_response(responses::sse(vec![responses::ev_response_created(
|
||||
"guardian-pending",
|
||||
)]))
|
||||
.set_delay(READ_TIMEOUT),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_managed_config()
|
||||
// Basic Guardian command approvals require host-native paths.
|
||||
.without_auto_env()
|
||||
.build_initialized_with_timeout(READ_TIMEOUT)
|
||||
.await?;
|
||||
let ThreadStartResponse { thread, .. } = app_server
|
||||
.request(|request_id| ClientRequest::ThreadStart {
|
||||
request_id,
|
||||
params: ThreadStartParams {
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
let request_id = app_server
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![UserInput::Text {
|
||||
text: "Review three commands".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let TurnStartResponse { turn } =
|
||||
timeout(READ_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
let reviews = timeout(READ_TIMEOUT, async {
|
||||
loop {
|
||||
let reviews = requests
|
||||
.requests()
|
||||
.into_iter()
|
||||
.map(|request| request.body_json()["client_metadata"].clone())
|
||||
.filter(|metadata| metadata["x-openai-subagent"] == "guardian")
|
||||
.collect::<Vec<_>>();
|
||||
if reviews.len() == 3 {
|
||||
break reviews;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
app_server
|
||||
.interrupt_turn_and_wait_for_aborted(thread.id.clone(), turn.id, READ_TIMEOUT)
|
||||
.await?;
|
||||
wait_for_matching_analytics_event(&server, READ_TIMEOUT, |event| {
|
||||
event["event_type"] == "codex_turn_event"
|
||||
&& event["event_params"]["turn_id"] == reviews[2]["turn_id"]
|
||||
})
|
||||
.await?;
|
||||
timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??;
|
||||
let events = captured_analytics_events(&server).await;
|
||||
assert!(!serde_json::to_string(&events)?.contains(PRIVATE));
|
||||
let children = events
|
||||
.iter()
|
||||
.filter(|event| event["event_params"]["subagent_source"] == "guardian")
|
||||
.collect::<Vec<_>>();
|
||||
for event in &children {
|
||||
let params = &event["event_params"];
|
||||
assert_eq!(
|
||||
json!([
|
||||
params["thread_id"],
|
||||
params["session_id"],
|
||||
params["parent_thread_id"],
|
||||
params["thread_source"]
|
||||
]),
|
||||
json!([
|
||||
reviews[0]["thread_id"],
|
||||
thread.session_id,
|
||||
thread.id,
|
||||
"guardian_review"
|
||||
])
|
||||
);
|
||||
}
|
||||
let turns = children
|
||||
.iter()
|
||||
.filter(|event| event["event_type"] == "codex_turn_event")
|
||||
.map(|event| {
|
||||
let params = &event["event_params"];
|
||||
let started_at = params["started_at"].as_u64().expect("turn start time");
|
||||
let completed_at = params["completed_at"]
|
||||
.as_u64()
|
||||
.expect("turn completion time");
|
||||
assert!(started_at > 0 && completed_at >= started_at);
|
||||
assert!(params["duration_ms"].is_u64());
|
||||
json!([
|
||||
params["turn_id"],
|
||||
params["status"],
|
||||
params["turn_error"],
|
||||
params["total_tool_call_count"],
|
||||
params["web_search_count"]
|
||||
])
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
turns,
|
||||
vec![
|
||||
json!([reviews[0]["turn_id"], "completed", null, 1, 1]),
|
||||
json!([reviews[1]["turn_id"], "failed", "other", 0, 0]),
|
||||
json!([reviews[2]["turn_id"], "interrupted", null, 0, 0]),
|
||||
]
|
||||
);
|
||||
let tools = children
|
||||
.iter()
|
||||
.filter(|event| event["event_type"] == "codex_web_search_event")
|
||||
.collect::<Vec<_>>();
|
||||
let [tool] = tools.as_slice() else {
|
||||
anyhow::bail!(
|
||||
"expected one Guardian web search event, got {}",
|
||||
tools.len()
|
||||
);
|
||||
};
|
||||
let params = &tool["event_params"];
|
||||
let started_at = params["started_at_ms"].as_u64().expect("tool start time");
|
||||
let completed_at = params["completed_at_ms"]
|
||||
.as_u64()
|
||||
.expect("tool completion time");
|
||||
assert!(started_at > 0 && completed_at >= started_at);
|
||||
assert_eq!(params["duration_ms"], completed_at - started_at);
|
||||
assert_eq!(
|
||||
json!([
|
||||
params["turn_id"],
|
||||
params["item_id"],
|
||||
params["terminal_status"]
|
||||
]),
|
||||
json!([reviews[0]["turn_id"], "guardian-search", "completed"])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> {
|
||||
const PARENT_PROMPT: &str = "exercise collaborator analytics";
|
||||
@@ -314,6 +508,23 @@ pub(crate) async fn mount_analytics_capture(server: &MockServer, codex_home: &Pa
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn captured_analytics_events(server: &MockServer) -> Vec<Value> {
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.flat_map(|request| {
|
||||
let payload: Value = serde_json::from_slice(&request.body).expect("analytics payload");
|
||||
payload["events"]
|
||||
.as_array()
|
||||
.expect("analytics events")
|
||||
.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_analytics_payload(
|
||||
server: &MockServer,
|
||||
read_timeout: Duration,
|
||||
|
||||
@@ -30,6 +30,10 @@ use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::analytics::captured_analytics_events;
|
||||
use super::analytics::mount_analytics_capture;
|
||||
use super::analytics::wait_for_analytics_event;
|
||||
|
||||
// Bazel CI can spend tens of seconds starting app-server subprocesses or
|
||||
// processing turn RPCs under load.
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
@@ -306,6 +310,8 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2()
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_web_search_call_added_partial("resumed-search", "in_progress"),
|
||||
responses::ev_web_search_call_done("resumed-search", "completed", "test query"),
|
||||
responses::ev_assistant_message("msg-1", "Done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
]),
|
||||
@@ -314,8 +320,10 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2()
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
|
||||
.with_provider_config("supports_websockets = false")
|
||||
.write(codex_home.path())?;
|
||||
mount_analytics_capture(&server, codex_home.path()).await?;
|
||||
|
||||
let root_thread_id = CoreThreadId::new();
|
||||
let root_thread_id_str = root_thread_id.to_string();
|
||||
@@ -393,6 +401,33 @@ async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2()
|
||||
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
|
||||
assert!(metadata.get("forked_from_thread_id").is_none());
|
||||
|
||||
let turn_event =
|
||||
wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?;
|
||||
let params = &turn_event["event_params"];
|
||||
assert_eq!(
|
||||
(
|
||||
params["total_tool_call_count"].as_u64(),
|
||||
params["web_search_count"].as_u64()
|
||||
),
|
||||
(Some(1), Some(1))
|
||||
);
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??;
|
||||
let events = captured_analytics_events(&server).await;
|
||||
let count = |event_type: &str| {
|
||||
events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event["event_type"] == event_type
|
||||
&& event["event_params"]["thread_id"] == thread.id
|
||||
&& event["event_params"]["turn_id"] == turn.id
|
||||
})
|
||||
.count()
|
||||
};
|
||||
assert_eq!(
|
||||
(count("codex_turn_event"), count("codex_web_search_event")),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user