mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Add elicitation classification support to app and MCP analytics (#45649)
## What changed Add an optional `elicitation_type` to app-used and MCP tool-call events, with `auth_or_link` and `approval` classifications and `null` for unclassified calls. Existing core app-use tracking passes `None`. Expose an API to queue MCP classifications by thread, turn, and item before completion. Preserve them across turn completion, consume them on item completion, and clear them on thread closure. Bound pending classifications to 256 entries, evicting the oldest when full. ## Testing Add coverage for serialization, first-classification retention during app-use deduplication, per-call classification across turn completion, pending-state eviction and cleanup, and exclusion of tool arguments from emitted events. GitOrigin-RevId: 185a2ccb38153e123fc731231edff0259a26bce3
This commit is contained in:
committed by
copyberry
parent
a8964cb1ba
commit
b0af519c39
@@ -6,6 +6,7 @@ use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
||||
use crate::events::CodexAppMentionedEventRequest;
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
use crate::events::CodexAppUsedEventRequest;
|
||||
use crate::events::CodexAppUsedMetadata;
|
||||
use crate::events::CodexCommandExecutionEventParams;
|
||||
use crate::events::CodexCommandExecutionEventRequest;
|
||||
use crate::events::CodexCompactionEventRequest;
|
||||
@@ -66,6 +67,7 @@ use crate::facts::CompactionTrigger;
|
||||
use crate::facts::ControlToolCallFact;
|
||||
use crate::facts::ControlToolCallStatus;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ElicitationType;
|
||||
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
||||
use crate::facts::ExternalAgentConfigImportFailureInput;
|
||||
use crate::facts::HookRunFact;
|
||||
@@ -75,6 +77,7 @@ use crate::facts::ImagePreparationFact;
|
||||
use crate::facts::ImagePreparationMetadata;
|
||||
use crate::facts::InputError;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::McpToolCallElicitation;
|
||||
use crate::facts::PluginInstallFailedInput;
|
||||
use crate::facts::PluginInstallRequestSource;
|
||||
use crate::facts::PluginInstallRequested;
|
||||
@@ -1185,14 +1188,17 @@ fn app_used_event_serializes_expected_shape() {
|
||||
let tracking = test_tracking_context("thread-2", "turn-2");
|
||||
let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
||||
event_type: "codex_app_used",
|
||||
event_params: codex_app_metadata(
|
||||
&tracking,
|
||||
AppInvocation {
|
||||
connector_id: Some("drive".to_string()),
|
||||
app_name: Some("Google Drive".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
},
|
||||
),
|
||||
event_params: CodexAppUsedMetadata {
|
||||
app: codex_app_metadata(
|
||||
&tracking,
|
||||
AppInvocation {
|
||||
connector_id: Some("drive".to_string()),
|
||||
app_name: Some("Google Drive".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
},
|
||||
),
|
||||
elicitation_type: None,
|
||||
},
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize app used event");
|
||||
@@ -1208,7 +1214,8 @@ fn app_used_event_serializes_expected_shape() {
|
||||
"app_name": "Google Drive",
|
||||
"product_client_id": TEST_PRODUCT_CLIENT_ID,
|
||||
"invoke_type": "implicit",
|
||||
"model_slug": "gpt-5"
|
||||
"model_slug": "gpt-5",
|
||||
"elicitation_type": null
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1641,28 +1648,6 @@ fn compaction_implementation_serializes_remote_v2() {
|
||||
assert_eq!(payload, json!("responses_compaction_v2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let queue = AnalyticsEventsQueue {
|
||||
sender,
|
||||
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
let app = AppInvocation {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
app_name: Some("Calendar".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
};
|
||||
|
||||
let turn_1 = test_tracking_context("thread-1", "turn-1");
|
||||
let turn_2 = test_tracking_context("thread-1", "turn-2");
|
||||
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true);
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_initialized_event_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::ThreadInitialized(ThreadInitializedEvent {
|
||||
@@ -4715,6 +4700,7 @@ async fn reducer_ingests_app_and_plugin_facts() {
|
||||
app_name: Some("Drive".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
},
|
||||
elicitation_type: Some(ElicitationType::AuthOrLink),
|
||||
})),
|
||||
&mut events,
|
||||
)
|
||||
@@ -4733,6 +4719,10 @@ async fn reducer_ingests_app_and_plugin_facts() {
|
||||
assert_eq!(payload.as_array().expect("events array").len(), 3);
|
||||
assert_eq!(payload[0]["event_type"], "codex_app_mentioned");
|
||||
assert_eq!(payload[1]["event_type"], "codex_app_used");
|
||||
assert_eq!(
|
||||
payload[1]["event_params"]["elicitation_type"],
|
||||
"auth_or_link"
|
||||
);
|
||||
assert_eq!(payload[2]["event_type"], "codex_plugin_used");
|
||||
assert_eq!(
|
||||
payload[0]["event_params"]["product_client_id"],
|
||||
@@ -5885,6 +5875,129 @@ async fn turn_event_counts_completed_tool_items() {
|
||||
assert_eq!(payload["event_params"]["image_generation_count"], json!(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_call_grain() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
ingest_turn_prerequisites(
|
||||
&mut reducer,
|
||||
&mut events,
|
||||
/*include_initialize*/ true,
|
||||
/*include_resolved_config*/ true,
|
||||
/*include_started*/ true,
|
||||
/*include_token_usage*/ false,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (item_id, connector_id, elicitation_type) in [
|
||||
("auth", "calendar", Some(ElicitationType::AuthOrLink)),
|
||||
("retry", "calendar", None),
|
||||
("denied", "drive", Some(ElicitationType::Approval)),
|
||||
] {
|
||||
let item = ThreadItem::McpToolCall {
|
||||
id: item_id.to_string(),
|
||||
server: "server".to_string(),
|
||||
tool: "search".to_string(),
|
||||
status: McpToolCallStatus::Completed,
|
||||
arguments: json!({ "token": "synthetic-private-input" }),
|
||||
app_context: Some(McpToolCallAppContext {
|
||||
connector_id: connector_id.to_string(),
|
||||
link_id: None,
|
||||
resource_uri: None,
|
||||
app_name: None,
|
||||
action_name: None,
|
||||
}),
|
||||
mcp_app_resource_uri: None,
|
||||
plugin_id: None,
|
||||
read_only_hint: None,
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: Some(2),
|
||||
};
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
|
||||
ItemStartedNotification {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
started_at_ms: 998,
|
||||
item: item.clone(),
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
if let Some(elicitation_type) = elicitation_type {
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation(
|
||||
McpToolCallElicitation {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
elicitation_type,
|
||||
},
|
||||
)),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
|
||||
"thread-2",
|
||||
"turn-2",
|
||||
AppServerTurnStatus::Completed,
|
||||
/*codex_error_info*/ None,
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
for item in items {
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
|
||||
ItemCompletedNotification {
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
completed_at_ms: 1_000,
|
||||
item,
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let payload = serde_json::to_value(&events).expect("serialize analytics events");
|
||||
let classifications = payload
|
||||
.as_array()
|
||||
.expect("analytics events array")
|
||||
.iter()
|
||||
.filter(|event| event["event_type"] == "codex_mcp_tool_call_event")
|
||||
.map(|event| {
|
||||
json!({
|
||||
"item_id": event["event_params"]["item_id"],
|
||||
"connector_id": event["event_params"]["connector_id"],
|
||||
"elicitation_type": event["event_params"].get("elicitation_type")
|
||||
.expect("elicitation_type must be present"),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
classifications,
|
||||
vec![
|
||||
json!({"item_id": "auth", "connector_id": "calendar", "elicitation_type": "auth_or_link"}),
|
||||
json!({"item_id": "retry", "connector_id": "calendar", "elicitation_type": null}),
|
||||
json!({"item_id": "denied", "connector_id": "drive", "elicitation_type": "approval"}),
|
||||
]
|
||||
);
|
||||
assert!(!payload.to_string().contains("synthetic-private-input"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_and_tool_events_read_current_trusted_root() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
|
||||
@@ -13,11 +13,13 @@ use crate::facts::ArtifactOperation;
|
||||
use crate::facts::ArtifactOperationInput;
|
||||
use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ElicitationType;
|
||||
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
||||
use crate::facts::ExternalAgentConfigImportFailureInput;
|
||||
use crate::facts::HookRunFact;
|
||||
use crate::facts::HookRunInput;
|
||||
use crate::facts::ImagePreparationFact;
|
||||
use crate::facts::McpToolCallElicitation;
|
||||
use crate::facts::PluginInstallFailedInput;
|
||||
use crate::facts::PluginInstallRequested;
|
||||
use crate::facts::PluginInstallRequestedInput;
|
||||
@@ -444,7 +446,12 @@ impl AnalyticsEventsClient {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
||||
pub fn track_app_used(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
app: AppInvocation,
|
||||
elicitation_type: Option<ElicitationType>,
|
||||
) {
|
||||
let Some(queue) = self.queue.as_ref() else {
|
||||
return;
|
||||
};
|
||||
@@ -452,10 +459,20 @@ impl AnalyticsEventsClient {
|
||||
return;
|
||||
}
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(
|
||||
AppUsedInput { tracking, app },
|
||||
AppUsedInput {
|
||||
tracking,
|
||||
app,
|
||||
elicitation_type,
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
pub fn track_mcp_tool_call_elicitation(&self, input: McpToolCallElicitation) {
|
||||
self.record_fact(AnalyticsFact::Custom(
|
||||
CustomAnalyticsFact::McpToolCallElicitation(input),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) {
|
||||
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(
|
||||
HookRunInput { tracking, hook },
|
||||
|
||||
@@ -49,15 +49,16 @@ use crate::events::TrackEventRequest;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::events::codex_artifact_operation_event_request;
|
||||
use crate::facts::AnalyticsFact;
|
||||
use crate::facts::AppInvocation;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::ArtifactOperation;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::ArtifactOperationLifecycle;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ElicitationType;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::PluginMeasurementRow;
|
||||
use crate::facts::PluginMeasurementsInput;
|
||||
#[cfg(debug_assertions)]
|
||||
use crate::facts::TrackEventsContext;
|
||||
use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH;
|
||||
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
|
||||
@@ -235,6 +236,7 @@ fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> Track
|
||||
mcp_error_present: false,
|
||||
plugin_id: plugin_id.map(str::to_string),
|
||||
connector_id: None,
|
||||
elicitation_type: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -913,6 +915,55 @@ async fn flush_is_noop_when_analytics_is_disabled() {
|
||||
client.flush().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_used_preserves_first_classification_and_emits_again_next_turn() {
|
||||
let (client, mut receiver) = client_with_receiver();
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
product_client_id: "codex_desktop".to_string(),
|
||||
};
|
||||
for (turn_id, elicitation_type) in [
|
||||
("turn-1", Some(ElicitationType::AuthOrLink)),
|
||||
("turn-1", None),
|
||||
("turn-2", None),
|
||||
] {
|
||||
client.track_app_used(
|
||||
TrackEventsContext {
|
||||
turn_id: turn_id.to_string(),
|
||||
..tracking.clone()
|
||||
},
|
||||
AppInvocation {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
app_name: Some("Calendar".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
},
|
||||
elicitation_type,
|
||||
);
|
||||
}
|
||||
for (turn_id, elicitation_type) in [
|
||||
("turn-1", Some(ElicitationType::AuthOrLink)),
|
||||
("turn-2", None),
|
||||
] {
|
||||
let Ok(AnalyticsEventsQueueMessage::Fact(input)) = receiver.try_recv() else {
|
||||
panic!("expected app-used analytics fact");
|
||||
};
|
||||
let AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(input)) = *input else {
|
||||
panic!("expected app-used analytics fact");
|
||||
};
|
||||
assert_eq!(
|
||||
(
|
||||
input.tracking.turn_id.as_str(),
|
||||
input.app.connector_id.as_deref(),
|
||||
input.elicitation_type,
|
||||
),
|
||||
(turn_id, Some("calendar"), elicitation_type)
|
||||
);
|
||||
}
|
||||
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_notification_only_enqueues_analytics_relevant_notifications() {
|
||||
let (client, mut receiver) = client_with_receiver();
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::facts::CompactionReason;
|
||||
use crate::facts::CompactionStatus;
|
||||
use crate::facts::CompactionStrategy;
|
||||
use crate::facts::CompactionTrigger;
|
||||
use crate::facts::ElicitationType;
|
||||
use crate::facts::GoalEventKind;
|
||||
use crate::facts::HookRunFact;
|
||||
use crate::facts::ImagePreparationMetadata;
|
||||
@@ -859,6 +860,7 @@ pub(crate) struct CodexMcpToolCallEventParams {
|
||||
pub(crate) mcp_error_present: bool,
|
||||
pub(crate) plugin_id: Option<String>,
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) elicitation_type: Option<ElicitationType>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -967,10 +969,17 @@ pub(crate) struct CodexAppMentionedEventRequest {
|
||||
pub(crate) event_params: CodexAppMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexAppUsedMetadata {
|
||||
#[serde(flatten)]
|
||||
pub(crate) app: CodexAppMetadata,
|
||||
pub(crate) elicitation_type: Option<ElicitationType>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexAppUsedEventRequest {
|
||||
pub(crate) event_type: &'static str,
|
||||
pub(crate) event_params: CodexAppMetadata,
|
||||
pub(crate) event_params: CodexAppUsedMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -391,12 +391,33 @@ pub enum InvocationType {
|
||||
Implicit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ElicitationType {
|
||||
/// Authentication or account linking blocked the original attempt, as reported
|
||||
/// by trusted connector auth-failure metadata. Both are treated the same when
|
||||
/// identifying elicitation-only usage. This does not imply that an
|
||||
/// authentication prompt was shown or completed.
|
||||
AuthOrLink,
|
||||
Approval,
|
||||
}
|
||||
|
||||
pub struct AppInvocation {
|
||||
pub connector_id: Option<String>,
|
||||
pub app_name: Option<String>,
|
||||
pub invocation_type: Option<InvocationType>,
|
||||
}
|
||||
|
||||
/// A known classification, queued before the corresponding item completion.
|
||||
/// Ordinary calls do not send this fact; their emitted classification stays null.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct McpToolCallElicitation {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
pub elicitation_type: ElicitationType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SubAgentThreadStartedInput {
|
||||
pub session_id: String,
|
||||
@@ -576,6 +597,7 @@ pub(crate) enum CustomAnalyticsFact {
|
||||
SkillInvoked(SkillInvokedInput),
|
||||
AppMentioned(AppMentionedInput),
|
||||
AppUsed(AppUsedInput),
|
||||
McpToolCallElicitation(McpToolCallElicitation),
|
||||
HookRun(HookRunInput),
|
||||
PluginUsed(PluginUsedInput),
|
||||
PluginInstallRequested(PluginInstallRequestedInput),
|
||||
@@ -625,6 +647,7 @@ pub(crate) struct AppMentionedInput {
|
||||
pub(crate) struct AppUsedInput {
|
||||
pub tracking: TrackEventsContext,
|
||||
pub app: AppInvocation,
|
||||
pub elicitation_type: Option<ElicitationType>,
|
||||
}
|
||||
|
||||
pub(crate) struct HookRunInput {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use facts::CompactionStrategy;
|
||||
pub use facts::CompactionTrigger;
|
||||
pub use facts::ControlToolCallFact;
|
||||
pub use facts::ControlToolCallStatus;
|
||||
pub use facts::ElicitationType;
|
||||
pub use facts::ExternalAgentConfigImportCompletedInput;
|
||||
pub use facts::ExternalAgentConfigImportFailureInput;
|
||||
pub use facts::GoalEventKind;
|
||||
@@ -52,6 +53,7 @@ pub use facts::ImagePreparationFact;
|
||||
pub use facts::ImagePreparationMetadata;
|
||||
pub use facts::InputError;
|
||||
pub use facts::InvocationType;
|
||||
pub use facts::McpToolCallElicitation;
|
||||
pub use facts::PluginInstallRequestSource;
|
||||
pub use facts::PluginInstallRequested;
|
||||
pub use facts::PluginInstallRequestedPlugin;
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::events::AppServerRpcTransport;
|
||||
use crate::events::CodexAppMentionedEventRequest;
|
||||
use crate::events::CodexAppServerClientMetadata;
|
||||
use crate::events::CodexAppUsedEventRequest;
|
||||
use crate::events::CodexAppUsedMetadata;
|
||||
use crate::events::CodexCollabAgentToolCallEventParams;
|
||||
use crate::events::CodexCollabAgentToolCallEventRequest;
|
||||
use crate::events::CodexCommandExecutionEventParams;
|
||||
@@ -88,12 +89,14 @@ use crate::facts::CodexGoalEvent;
|
||||
use crate::facts::ControlToolCallFact;
|
||||
use crate::facts::ControlToolCallStatus;
|
||||
use crate::facts::CustomAnalyticsFact;
|
||||
use crate::facts::ElicitationType;
|
||||
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
||||
use crate::facts::ExternalAgentConfigImportFailureInput;
|
||||
use crate::facts::HookRunInput;
|
||||
use crate::facts::ImagePreparationFact;
|
||||
use crate::facts::ImagePreparationMetadata;
|
||||
use crate::facts::InvocationType;
|
||||
use crate::facts::McpToolCallElicitation;
|
||||
use crate::facts::PluginInstallFailedInput;
|
||||
use crate::facts::PluginInstallRequestedInput;
|
||||
use crate::facts::PluginMeasurementRow;
|
||||
@@ -210,6 +213,8 @@ pub(crate) struct AnalyticsReducer {
|
||||
code_mode_cells: HashMap<String, HashMap<String, CodeModeCellState>>,
|
||||
pending_reviews: HashMap<RequestId, PendingReviewState>,
|
||||
item_review_summaries: HashMap<ToolItemKey, ItemReviewSummary>,
|
||||
// Bounded to 256 entries, keeping linear lookups small; evict the oldest entry when full.
|
||||
pending_mcp_tool_elicitations: VecDeque<(ToolItemKey, ElicitationType)>,
|
||||
}
|
||||
|
||||
struct ConnectionState {
|
||||
@@ -712,6 +717,34 @@ impl AnalyticsReducer {
|
||||
CustomAnalyticsFact::AppUsed(input) => {
|
||||
self.ingest_app_used(input, out);
|
||||
}
|
||||
CustomAnalyticsFact::McpToolCallElicitation(input) => {
|
||||
let McpToolCallElicitation {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
elicitation_type,
|
||||
} = input;
|
||||
let key = ToolItemKey {
|
||||
thread_id,
|
||||
turn_id,
|
||||
item_id,
|
||||
};
|
||||
if self
|
||||
.pending_mcp_tool_elicitations
|
||||
.iter()
|
||||
.any(|(pending, _)| pending == &key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.pending_mcp_tool_elicitations.len() >= MAX_TOOL_RESPONSE_ENTRIES {
|
||||
self.pending_mcp_tool_elicitations.pop_front();
|
||||
tracing::warn!(
|
||||
"expiring oldest MCP elicitation classification: state is full"
|
||||
);
|
||||
}
|
||||
self.pending_mcp_tool_elicitations
|
||||
.push_back((key, elicitation_type));
|
||||
}
|
||||
CustomAnalyticsFact::HookRun(input) => {
|
||||
self.ingest_hook_run(input, out);
|
||||
}
|
||||
@@ -1365,8 +1398,15 @@ impl AnalyticsReducer {
|
||||
}
|
||||
|
||||
fn ingest_app_used(&mut self, input: AppUsedInput, out: &mut Vec<TrackEventRequest>) {
|
||||
let AppUsedInput { tracking, app } = input;
|
||||
let event_params = codex_app_metadata(&tracking, app);
|
||||
let AppUsedInput {
|
||||
tracking,
|
||||
app,
|
||||
elicitation_type,
|
||||
} = input;
|
||||
let event_params = CodexAppUsedMetadata {
|
||||
app: codex_app_metadata(&tracking, app),
|
||||
elicitation_type,
|
||||
};
|
||||
out.push(TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
||||
event_type: "codex_app_used",
|
||||
event_params,
|
||||
@@ -1922,6 +1962,23 @@ impl AnalyticsReducer {
|
||||
let Some(item_id) = tracked_tool_item_id(¬ification.item) else {
|
||||
return;
|
||||
};
|
||||
let key = ToolItemKey {
|
||||
thread_id: notification.thread_id.clone(),
|
||||
turn_id: notification.turn_id.clone(),
|
||||
item_id: item_id.to_string(),
|
||||
};
|
||||
let elicitation_type =
|
||||
if matches!(notification.item, ThreadItem::McpToolCall { .. }) {
|
||||
// Producers enqueue known classifications before completion on
|
||||
// the same analytics queue, so completed keys need no history.
|
||||
self.pending_mcp_tool_elicitations
|
||||
.iter()
|
||||
.position(|(pending, _)| pending == &key)
|
||||
.and_then(|index| self.pending_mcp_tool_elicitations.remove(index))
|
||||
.map(|(_, elicitation_type)| elicitation_type)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else {
|
||||
tracing::warn!(
|
||||
thread_id = %notification.thread_id,
|
||||
@@ -1932,11 +1989,6 @@ impl AnalyticsReducer {
|
||||
return;
|
||||
};
|
||||
turn_state.tool_counts.record(¬ification.item);
|
||||
let key = ToolItemKey {
|
||||
thread_id: notification.thread_id.clone(),
|
||||
turn_id: notification.turn_id.clone(),
|
||||
item_id: item_id.to_string(),
|
||||
};
|
||||
let Some((started_at_ms, model_context)) =
|
||||
self.tool_items_started_at_ms.remove(&key)
|
||||
else {
|
||||
@@ -1968,6 +2020,7 @@ impl AnalyticsReducer {
|
||||
thread_state,
|
||||
thread_metadata,
|
||||
review_summary: self.item_review_summaries.get(&key),
|
||||
elicitation_type,
|
||||
}) {
|
||||
let root_turn_id = self
|
||||
.turns
|
||||
@@ -2017,6 +2070,8 @@ impl AnalyticsReducer {
|
||||
}
|
||||
});
|
||||
self.code_mode_cells.remove(¬ification.thread_id);
|
||||
self.pending_mcp_tool_elicitations
|
||||
.retain(|(key, _)| key.thread_id != notification.thread_id);
|
||||
}
|
||||
ServerNotification::TurnStarted(notification) => {
|
||||
let turn_state = self.turns.entry(notification.turn.id).or_default();
|
||||
@@ -2628,6 +2683,7 @@ struct ToolItemEventInput<'a> {
|
||||
thread_state: &'a ThreadAnalyticsState,
|
||||
thread_metadata: &'a ThreadMetadataState,
|
||||
review_summary: Option<&'a ItemReviewSummary>,
|
||||
elicitation_type: Option<ElicitationType>,
|
||||
}
|
||||
|
||||
fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
@@ -2642,6 +2698,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
thread_state,
|
||||
thread_metadata,
|
||||
review_summary,
|
||||
elicitation_type,
|
||||
} = input;
|
||||
match item {
|
||||
ThreadItem::CommandExecution {
|
||||
@@ -2781,6 +2838,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
|
||||
connector_id: app_context
|
||||
.as_ref()
|
||||
.map(|app_context| app_context.connector_id.clone()),
|
||||
elicitation_type,
|
||||
},
|
||||
},
|
||||
))
|
||||
@@ -3730,12 +3788,116 @@ pub(crate) fn normalize_path_for_skill_id(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ThreadClosedNotification;
|
||||
use codex_protocol::models::SandboxEnforcement;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_classifications_evict_oldest_and_clean_up() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
let mut events = Vec::new();
|
||||
let keys = (0..=MAX_TOOL_RESPONSE_ENTRIES)
|
||||
.map(|index| ToolItemKey {
|
||||
thread_id: if index == MAX_TOOL_RESPONSE_ENTRIES {
|
||||
"other-thread"
|
||||
} else {
|
||||
"thread"
|
||||
}
|
||||
.to_string(),
|
||||
turn_id: "turn".to_string(),
|
||||
item_id: index.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let assert_pending = |reducer: &AnalyticsReducer, keys: &[ToolItemKey]| {
|
||||
let expected = keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|key| (key, ElicitationType::AuthOrLink))
|
||||
.collect::<VecDeque<_>>();
|
||||
assert!(reducer.pending_mcp_tool_elicitations == expected);
|
||||
};
|
||||
for key in &keys {
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation(
|
||||
McpToolCallElicitation {
|
||||
thread_id: key.thread_id.clone(),
|
||||
turn_id: key.turn_id.clone(),
|
||||
item_id: key.item_id.clone(),
|
||||
elicitation_type: ElicitationType::AuthOrLink,
|
||||
},
|
||||
)),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
assert_pending(&reducer, &keys[1..]);
|
||||
|
||||
// A duplicate at capacity keeps the first value and original arrival order.
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Custom(CustomAnalyticsFact::McpToolCallElicitation(
|
||||
McpToolCallElicitation {
|
||||
thread_id: keys[1].thread_id.clone(),
|
||||
turn_id: keys[1].turn_id.clone(),
|
||||
item_id: keys[1].item_id.clone(),
|
||||
elicitation_type: ElicitationType::Approval,
|
||||
},
|
||||
)),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
assert_pending(&reducer, &keys[1..]);
|
||||
|
||||
// Out-of-order completion consumes its marker even without turn context.
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
|
||||
ItemCompletedNotification {
|
||||
thread_id: keys[2].thread_id.clone(),
|
||||
turn_id: keys[2].turn_id.clone(),
|
||||
completed_at_ms: 1_000,
|
||||
item: ThreadItem::McpToolCall {
|
||||
id: keys[2].item_id.clone(),
|
||||
server: "server".to_string(),
|
||||
tool: "tool".to_string(),
|
||||
status: McpToolCallStatus::Failed,
|
||||
arguments: serde_json::json!({}),
|
||||
app_context: None,
|
||||
mcp_app_resource_uri: None,
|
||||
plugin_id: None,
|
||||
read_only_hint: None,
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
let mut remaining = keys[1..].to_vec();
|
||||
remaining.remove(1);
|
||||
assert_pending(&reducer, &remaining);
|
||||
|
||||
reducer
|
||||
.ingest(
|
||||
AnalyticsFact::Notification(Box::new(ServerNotification::ThreadClosed(
|
||||
ThreadClosedNotification {
|
||||
thread_id: "thread".to_string(),
|
||||
},
|
||||
))),
|
||||
&mut events,
|
||||
)
|
||||
.await;
|
||||
assert_pending(&reducer, &keys[MAX_TOOL_RESPONSE_ENTRIES..]);
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_turn_interrupt_removes_pending_analytics_request() {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
|
||||
@@ -1090,6 +1090,7 @@ async fn maybe_track_codex_app_used(
|
||||
app_name,
|
||||
invocation_type: Some(invocation_type),
|
||||
},
|
||||
/*elicitation_type*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ async fn thread_analytics_opt_out_overrides_shared_client() {
|
||||
app_name: None,
|
||||
invocation_type: None,
|
||||
},
|
||||
/*elicitation_type*/ None,
|
||||
);
|
||||
services.analytics_events_client.flush().await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user