Skip to main content

codex_analytics/
client.rs

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            //TODO: add a metric for this
146            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            /*thread_originator*/ 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;