1use crate::events::AppServerRpcTransport;
2use crate::events::GuardianReviewAnalyticsResult;
3use crate::events::GuardianReviewTrackContext;
4use crate::events::TrackEventRequest;
5use crate::events::TrackEventsRequest;
6use crate::events::current_runtime_metadata;
7use crate::facts::AnalyticsFact;
8use crate::facts::AnalyticsJsonRpcError;
9use crate::facts::AppInvocation;
10use crate::facts::AppMentionedInput;
11use crate::facts::AppUsedInput;
12use crate::facts::CodexGoalEvent;
13use crate::facts::CustomAnalyticsFact;
14use crate::facts::ExternalAgentConfigImportCompletedInput;
15use crate::facts::ExternalAgentConfigImportFailureInput;
16use crate::facts::HookRunFact;
17use crate::facts::HookRunInput;
18use crate::facts::PluginInstallFailedInput;
19use crate::facts::PluginInstallRequested;
20use crate::facts::PluginInstallRequestedInput;
21use crate::facts::PluginInstallSource;
22use crate::facts::PluginState;
23use crate::facts::PluginStateChangedInput;
24use crate::facts::SkillInvocation;
25use crate::facts::SkillInvokedInput;
26use crate::facts::SubAgentThreadStartedInput;
27use crate::facts::TrackEventsContext;
28use crate::facts::TurnCodexErrorFact;
29use crate::facts::TurnProfileFact;
30use crate::facts::TurnResolvedConfigFact;
31use crate::facts::TurnTokenUsageFact;
32use crate::reducer::AnalyticsReducer;
33use codex_app_server_protocol::ClientRequest;
34use codex_app_server_protocol::ClientResponsePayload;
35use codex_app_server_protocol::InitializeParams;
36use codex_app_server_protocol::JSONRPCErrorError;
37use codex_app_server_protocol::RequestId;
38use codex_app_server_protocol::ServerNotification;
39use codex_app_server_protocol::ServerRequest;
40use codex_app_server_protocol::ServerResponse;
41use codex_login::AuthManager;
42use codex_login::CodexAuth;
43use codex_login::default_client::create_client;
44use codex_plugin::PluginId;
45use codex_plugin::PluginTelemetryMetadata;
46use codex_protocol::request_permissions::RequestPermissionsResponse;
47use std::collections::HashSet;
48use std::path::PathBuf;
49use std::sync::Arc;
50use std::sync::Mutex;
51use std::time::Duration;
52use tokio::sync::mpsc;
53
54const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
55const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
56const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096;
57
58#[derive(Clone)]
59pub(crate) struct AnalyticsEventsQueue {
60 pub(crate) sender: mpsc::Sender<AnalyticsFact>,
61 pub(crate) app_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
62 pub(crate) plugin_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
63}
64
65#[derive(Clone)]
66pub struct AnalyticsEventsClient {
67 queue: Option<AnalyticsEventsQueue>,
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71enum AnalyticsEventsDestination {
72 Http {
73 url: String,
74 },
75 #[cfg(debug_assertions)]
76 CaptureFile {
77 path: PathBuf,
78 },
79}
80
81impl AnalyticsEventsDestination {
82 fn from_base_url(base_url: String) -> Self {
83 let capture_file = analytics_capture_file_from_env();
84 Self::from_base_url_and_capture_file(base_url, capture_file)
85 }
86
87 fn from_base_url_and_capture_file(base_url: String, capture_file: Option<PathBuf>) -> Self {
88 #[cfg(debug_assertions)]
89 if let Some(path) = capture_file {
90 if let Err(err) = crate::analytics_capture::initialize(&path) {
91 tracing::error!(
92 path = %path.display(),
93 "failed to initialize analytics event capture; network delivery remains disabled: {err}"
94 );
95 }
96 tracing::warn!(
97 path = %path.display(),
98 "analytics event capture enabled; network delivery is disabled"
99 );
100 return Self::CaptureFile { path };
101 }
102
103 #[cfg(not(debug_assertions))]
104 let _ = capture_file;
105
106 let base_url = base_url.trim_end_matches('/');
107 Self::Http {
108 url: format!("{base_url}/codex/analytics-events/events"),
109 }
110 }
111}
112
113fn analytics_capture_file_from_env() -> Option<PathBuf> {
114 #[cfg(debug_assertions)]
115 {
116 std::env::var_os(crate::analytics_capture::ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR)
117 .filter(|value| !value.is_empty())
118 .map(PathBuf::from)
119 }
120
121 #[cfg(not(debug_assertions))]
122 None
123}
124
125impl AnalyticsEventsQueue {
126 fn new(auth_manager: Arc<AuthManager>, destination: AnalyticsEventsDestination) -> Self {
127 let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
128 tokio::spawn(async move {
129 let mut reducer = AnalyticsReducer::default();
130 while let Some(input) = receiver.recv().await {
131 let mut events = Vec::new();
132 reducer.ingest(input, &mut events).await;
133 send_track_events(&auth_manager, &destination, events).await;
134 }
135 });
136 Self {
137 sender,
138 app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
139 plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
140 }
141 }
142
143 fn try_send(&self, input: AnalyticsFact) {
144 if self.sender.try_send(input).is_err() {
145 tracing::warn!("dropping analytics events: queue is full");
147 }
148 }
149
150 pub(crate) fn should_enqueue_app_used(
151 &self,
152 tracking: &TrackEventsContext,
153 app: &AppInvocation,
154 ) -> bool {
155 let Some(connector_id) = app.connector_id.as_ref() else {
156 return true;
157 };
158 let mut emitted = self
159 .app_used_emitted_keys
160 .lock()
161 .unwrap_or_else(std::sync::PoisonError::into_inner);
162 if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
163 emitted.clear();
164 }
165 emitted.insert((tracking.turn_id.clone(), connector_id.clone()))
166 }
167
168 pub(crate) fn should_enqueue_plugin_used(
169 &self,
170 tracking: &TrackEventsContext,
171 plugin: &PluginTelemetryMetadata,
172 ) -> bool {
173 let mut emitted = self
174 .plugin_used_emitted_keys
175 .lock()
176 .unwrap_or_else(std::sync::PoisonError::into_inner);
177 if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
178 emitted.clear();
179 }
180 let Some(plugin_id) = plugin
181 .plugin_id
182 .as_ref()
183 .map(PluginId::as_key)
184 .or_else(|| plugin.remote_plugin_id.clone())
185 else {
186 return true;
187 };
188 emitted.insert((tracking.turn_id.clone(), plugin_id))
189 }
190}
191
192impl AnalyticsEventsClient {
193 pub fn new(
194 auth_manager: Arc<AuthManager>,
195 base_url: String,
196 analytics_enabled: Option<bool>,
197 ) -> Self {
198 let destination = AnalyticsEventsDestination::from_base_url(base_url);
199 Self {
200 queue: (analytics_enabled != Some(false))
201 .then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), destination)),
202 }
203 }
204
205 pub fn disabled() -> Self {
206 Self { queue: None }
207 }
208
209 pub fn track_skill_invocations(
210 &self,
211 tracking: TrackEventsContext,
212 invocations: Vec<SkillInvocation>,
213 ) {
214 if invocations.is_empty() {
215 return;
216 }
217 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(
218 SkillInvokedInput {
219 tracking,
220 invocations,
221 },
222 )));
223 }
224
225 pub fn track_initialize(
226 &self,
227 connection_id: u64,
228 params: InitializeParams,
229 product_client_id: String,
230 rpc_transport: AppServerRpcTransport,
231 ) {
232 self.record_fact(AnalyticsFact::Initialize {
233 connection_id,
234 params,
235 product_client_id,
236 runtime: current_runtime_metadata(),
237 rpc_transport,
238 });
239 }
240
241 pub fn track_subagent_thread_started(&self, input: SubAgentThreadStartedInput) {
242 self.record_fact(AnalyticsFact::Custom(
243 CustomAnalyticsFact::SubAgentThreadStarted(input),
244 ));
245 }
246
247 pub fn track_guardian_review(
248 &self,
249 tracking: &GuardianReviewTrackContext,
250 result: GuardianReviewAnalyticsResult,
251 completed_at_ms: u64,
252 ) {
253 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(
254 Box::new(tracking.event_params(result, completed_at_ms)),
255 )));
256 }
257
258 pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
259 if mentions.is_empty() {
260 return;
261 }
262 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned(
263 AppMentionedInput { tracking, mentions },
264 )));
265 }
266
267 pub fn track_request(
268 &self,
269 connection_id: u64,
270 request_id: RequestId,
271 request: &ClientRequest,
272 ) {
273 if !matches!(
274 request,
275 ClientRequest::TurnStart { .. } | ClientRequest::TurnSteer { .. }
276 ) {
277 return;
278 }
279 self.record_fact(AnalyticsFact::ClientRequest {
280 connection_id,
281 request_id,
282 request: Box::new(request.clone()),
283 });
284 }
285
286 pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
287 let Some(queue) = self.queue.as_ref() else {
288 return;
289 };
290 if !queue.should_enqueue_app_used(&tracking, &app) {
291 return;
292 }
293 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(
294 AppUsedInput { tracking, app },
295 )));
296 }
297
298 pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) {
299 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(
300 HookRunInput { tracking, hook },
301 )));
302 }
303
304 pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
305 let Some(queue) = self.queue.as_ref() else {
306 return;
307 };
308 if !queue.should_enqueue_plugin_used(&tracking, &plugin) {
309 return;
310 }
311 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed(
312 crate::facts::PluginUsedInput { tracking, plugin },
313 )));
314 }
315
316 pub fn track_plugin_install_requested(
317 &self,
318 tracking: TrackEventsContext,
319 request: PluginInstallRequested,
320 ) {
321 self.record_fact(AnalyticsFact::Custom(
322 CustomAnalyticsFact::PluginInstallRequested(PluginInstallRequestedInput {
323 tracking,
324 request,
325 }),
326 ));
327 }
328
329 pub fn track_compaction(&self, event: crate::facts::CodexCompactionEvent) {
330 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(
331 Box::new(event),
332 )));
333 }
334
335 pub fn track_goal_event(&self, event: CodexGoalEvent) {
336 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Goal(Box::new(
337 event,
338 ))));
339 }
340
341 pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) {
342 self.record_fact(AnalyticsFact::Custom(
343 CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)),
344 ));
345 }
346
347 pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) {
348 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(
349 Box::new(fact),
350 )));
351 }
352
353 pub fn track_turn_profile(&self, fact: TurnProfileFact) {
354 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(
355 Box::new(fact),
356 )));
357 }
358
359 pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) {
360 self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(
361 Box::new(fact),
362 )));
363 }
364
365 pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
366 self.record_fact(AnalyticsFact::Custom(
367 CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
368 plugin,
369 state: PluginState::Installed,
370 }),
371 ));
372 }
373
374 pub fn track_plugin_install_failed(
375 &self,
376 plugin: PluginTelemetryMetadata,
377 source: PluginInstallSource,
378 error_type: String,
379 sub_error_type: Option<String>,
380 ) {
381 self.record_fact(AnalyticsFact::Custom(
382 CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput {
383 plugin,
384 source,
385 error_type,
386 sub_error_type,
387 }),
388 ));
389 }
390
391 pub fn track_external_agent_config_import_completed(
392 &self,
393 input: ExternalAgentConfigImportCompletedInput,
394 ) {
395 self.record_fact(AnalyticsFact::Custom(
396 CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input),
397 ));
398 }
399
400 pub fn track_external_agent_config_import_failure(
401 &self,
402 input: ExternalAgentConfigImportFailureInput,
403 ) {
404 self.record_fact(AnalyticsFact::Custom(
405 CustomAnalyticsFact::ExternalAgentConfigImportFailure(input),
406 ));
407 }
408
409 pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
410 self.record_fact(AnalyticsFact::Custom(
411 CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
412 plugin,
413 state: PluginState::Uninstalled,
414 }),
415 ));
416 }
417
418 pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
419 self.record_fact(AnalyticsFact::Custom(
420 CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
421 plugin,
422 state: PluginState::Enabled,
423 }),
424 ));
425 }
426
427 pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
428 self.record_fact(AnalyticsFact::Custom(
429 CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
430 plugin,
431 state: PluginState::Disabled,
432 }),
433 ));
434 }
435
436 pub(crate) fn record_fact(&self, input: AnalyticsFact) {
437 if let Some(queue) = self.queue.as_ref() {
438 queue.try_send(input);
439 }
440 }
441
442 pub fn track_response(
443 &self,
444 connection_id: u64,
445 request_id: RequestId,
446 response: ClientResponsePayload,
447 ) {
448 self.track_response_inner(
449 connection_id,
450 request_id,
451 response,
452 None,
453 );
454 }
455
456 pub fn track_response_with_thread_originator(
457 &self,
458 connection_id: u64,
459 request_id: RequestId,
460 response: ClientResponsePayload,
461 thread_originator: String,
462 ) {
463 self.track_response_inner(connection_id, request_id, response, Some(thread_originator));
464 }
465
466 fn track_response_inner(
467 &self,
468 connection_id: u64,
469 request_id: RequestId,
470 response: ClientResponsePayload,
471 thread_originator: Option<String>,
472 ) {
473 if !matches!(
474 response,
475 ClientResponsePayload::ThreadStart(_)
476 | ClientResponsePayload::ThreadResume(_)
477 | ClientResponsePayload::ThreadFork(_)
478 | ClientResponsePayload::TurnStart(_)
479 | ClientResponsePayload::TurnSteer(_)
480 ) {
481 return;
482 }
483 self.record_fact(AnalyticsFact::ClientResponse {
484 connection_id,
485 request_id,
486 response: Box::new(response),
487 thread_originator,
488 });
489 }
490
491 pub fn track_error_response(
492 &self,
493 connection_id: u64,
494 request_id: RequestId,
495 error: JSONRPCErrorError,
496 error_type: Option<AnalyticsJsonRpcError>,
497 ) {
498 self.record_fact(AnalyticsFact::ErrorResponse {
499 connection_id,
500 request_id,
501 error,
502 error_type,
503 });
504 }
505
506 pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) {
507 self.record_fact(AnalyticsFact::ServerRequest {
508 connection_id,
509 request: Box::new(request),
510 });
511 }
512
513 pub fn track_server_response(&self, completed_at_ms: u64, response: ServerResponse) {
514 self.record_fact(AnalyticsFact::ServerResponse {
515 completed_at_ms,
516 response: Box::new(response),
517 });
518 }
519
520 pub fn track_effective_permissions_approval_response(
521 &self,
522 completed_at_ms: u64,
523 request_id: RequestId,
524 response: RequestPermissionsResponse,
525 ) {
526 self.record_fact(AnalyticsFact::EffectivePermissionsApprovalResponse {
527 completed_at_ms,
528 request_id,
529 response: Box::new(response),
530 });
531 }
532
533 pub fn track_server_request_aborted(&self, completed_at_ms: u64, request_id: RequestId) {
534 self.record_fact(AnalyticsFact::ServerRequestAborted {
535 completed_at_ms,
536 request_id,
537 });
538 }
539
540 pub fn track_notification(&self, notification: ServerNotification) {
541 if !matches!(
542 notification,
543 ServerNotification::TurnStarted(_)
544 | ServerNotification::TurnCompleted(_)
545 | ServerNotification::TurnDiffUpdated(_)
546 | ServerNotification::ItemStarted(_)
547 | ServerNotification::ItemCompleted(_)
548 | ServerNotification::ItemGuardianApprovalReviewStarted(_)
549 | ServerNotification::ItemGuardianApprovalReviewCompleted(_)
550 ) {
551 return;
552 }
553 self.record_fact(AnalyticsFact::Notification(Box::new(notification)));
554 }
555}
556
557async fn send_track_events(
558 auth_manager: &AuthManager,
559 destination: &AnalyticsEventsDestination,
560 mut events: Vec<TrackEventRequest>,
561) {
562 if events.is_empty() {
563 return;
564 }
565
566 let Some(auth) = auth_manager.auth().await else {
567 return;
568 };
569 if auth.is_api_key_auth() {
570 events.retain(TrackEventRequest::can_send_with_api_key_auth);
571 } else if !auth.uses_codex_backend() {
572 return;
573 }
574 if events.is_empty() {
575 return;
576 }
577
578 for events in track_event_request_batches(events) {
579 send_track_events_request(&auth, destination, events).await;
580 }
581}
582
583fn track_event_request_batches(events: Vec<TrackEventRequest>) -> Vec<Vec<TrackEventRequest>> {
584 let mut batches = Vec::new();
585 let mut current_batch = Vec::new();
586
587 for event in events {
588 if event.should_send_in_isolated_request() {
589 if !current_batch.is_empty() {
590 batches.push(current_batch);
591 current_batch = Vec::new();
592 }
593 batches.push(vec![event]);
594 } else {
595 current_batch.push(event);
596 }
597 }
598
599 if !current_batch.is_empty() {
600 batches.push(current_batch);
601 }
602
603 batches
604}
605
606async fn send_track_events_request(
607 auth: &CodexAuth,
608 destination: &AnalyticsEventsDestination,
609 events: Vec<TrackEventRequest>,
610) {
611 if events.is_empty() {
612 return;
613 }
614
615 let payload = TrackEventsRequest { events };
616
617 #[cfg(debug_assertions)]
618 if capture_track_events_request(destination, &payload) {
619 return;
620 }
621
622 let url = match destination {
623 AnalyticsEventsDestination::Http { url } => url,
624 #[cfg(debug_assertions)]
625 AnalyticsEventsDestination::CaptureFile { .. } => return,
626 };
627 let response = create_client()
628 .post(url)
629 .timeout(ANALYTICS_EVENTS_TIMEOUT)
630 .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())
631 .header("Content-Type", "application/json")
632 .json(&payload)
633 .send()
634 .await;
635
636 match response {
637 Ok(response) if response.status().is_success() => {}
638 Ok(response) => {
639 let status = response.status();
640 let body = response.text().await.unwrap_or_default();
641 tracing::warn!("events failed with status {status}: {body}");
642 }
643 Err(err) => {
644 tracing::warn!("failed to send events request: {err}");
645 }
646 }
647}
648
649#[cfg(debug_assertions)]
650fn capture_track_events_request(
651 destination: &AnalyticsEventsDestination,
652 payload: &TrackEventsRequest,
653) -> bool {
654 let AnalyticsEventsDestination::CaptureFile { path } = destination else {
655 return false;
656 };
657
658 if let Err(err) = crate::analytics_capture::append_payload(path, payload) {
659 tracing::error!(
660 path = %path.display(),
661 "failed to capture analytics events; network delivery remains disabled: {err}"
662 );
663 }
664 true
665}
666
667#[cfg(test)]
668#[path = "client_tests.rs"]
669mod tests;