From 847e9e06912378558c58f0f5d82e32f3ce995366 Mon Sep 17 00:00:00 2001 From: James Chu Date: Thu, 23 Apr 2026 17:08:36 -0400 Subject: [PATCH] Emit usage-limit prompt analytics from TUI --- codex-rs/tui/src/app.rs | 4 ++ codex-rs/tui/src/app/background_requests.rs | 34 ++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 6 ++ codex-rs/tui/src/app_event.rs | 8 +++ codex-rs/tui/src/chatwidget.rs | 18 +++++ codex-rs/tui/src/chatwidget/tests.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 67 ++++++++++++++++++- 7 files changed, 138 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bffa46e478..c43c86e215 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -112,9 +112,13 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadStartSource; +use codex_app_server_protocol::TrackUsageLimitBannerAction; +use codex_app_server_protocol::TrackUsageLimitBannerParams; +use codex_app_server_protocol::TrackUsageLimitBannerResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UsageLimitBannerType; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 80879dbd79..6cb544ad27 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -59,6 +59,20 @@ impl App { }); } + pub(super) fn track_usage_limit_banner( + &mut self, + app_server: &AppServerSession, + action: TrackUsageLimitBannerAction, + banner_type: UsageLimitBannerType, + ) { + let request_handle = app_server.request_handle(); + tokio::spawn(async move { + if let Err(err) = track_usage_limit_banner(request_handle, action, banner_type).await { + tracing::warn!("failed to track usage limit banner event: {err:#}"); + } + }); + } + /// Starts the initial skills refresh without delaying the first interactive frame. /// /// Startup only needs skill metadata to populate skill mentions and the skills UI; the prompt can be @@ -451,6 +465,26 @@ pub(super) async fn send_add_credits_nudge_email( Ok(response.status) } +pub(super) async fn track_usage_limit_banner( + request_handle: AppServerRequestHandle, + action: TrackUsageLimitBannerAction, + banner_type: UsageLimitBannerType, +) -> Result<()> { + let request_id = RequestId::String(format!("usage-limit-banner-{}", Uuid::new_v4())); + let _: TrackUsageLimitBannerResponse = request_handle + .request_typed(ClientRequest::TrackUsageLimitBanner { + request_id, + params: TrackUsageLimitBannerParams { + action, + banner_type, + }, + }) + .await + .wrap_err("account/usageLimitBanner/track failed in TUI")?; + + Ok(()) +} + pub(super) async fn fetch_skills_list( request_handle: AppServerRequestHandle, cwd: PathBuf, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ac5930f93c..35eba7ad0a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -564,6 +564,12 @@ impl App { self.send_add_credits_nudge_email(app_server, credit_type); } } + AppEvent::TrackUsageLimitBanner { + action, + banner_type, + } => { + self.track_usage_limit_banner(app_server, action, banner_type); + } AppEvent::AddCreditsNudgeEmailFinished { result } => { self.chat_widget .finish_add_credits_nudge_email_request(result); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index e6ce8e9224..cd8a6fe2a8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::TrackUsageLimitBannerAction; +use codex_app_server_protocol::UsageLimitBannerType; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; @@ -234,6 +236,12 @@ pub(crate) enum AppEvent { credit_type: AddCreditsNudgeCreditType, }, + /// Track an analytics event for the workspace-owner nudge prompt. + TrackUsageLimitBanner { + action: TrackUsageLimitBannerAction, + banner_type: UsageLimitBannerType, + }, + /// Result of notifying the workspace owner. AddCreditsNudgeEmailFinished { result: Result, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8513fe8816..b487321f28 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -106,10 +106,12 @@ use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::TrackUsageLimitBannerAction; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnPlanStepStatus; use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::UsageLimitBannerType; use codex_chatgpt::connectors; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; @@ -8581,7 +8583,19 @@ impl ChatWidget { "Request a limit increase from your owner to continue using codex. Request increase?", ), }; + let banner_type = match credit_type { + AddCreditsNudgeCreditType::Credits => { + UsageLimitBannerType::WorkspaceMemberCreditsDepleted + } + AddCreditsNudgeCreditType::UsageLimit => { + UsageLimitBannerType::WorkspaceMemberUsageLimitReached + } + }; let send_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::TrackUsageLimitBanner { + action: TrackUsageLimitBannerAction::CtaClicked, + banner_type, + }); tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); })]; let items = vec![ @@ -8609,6 +8623,10 @@ impl ChatWidget { initial_selected_idx: Some(1), ..Default::default() }); + self.app_event_tx.send(AppEvent::TrackUsageLimitBanner { + action: TrackUsageLimitBannerAction::Shown, + banner_type, + }); } pub(crate) fn start_add_credits_nudge_email_request( diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 376e0771e8..caef74684e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -88,11 +88,13 @@ pub(super) use codex_app_server_protocol::ServerNotification; pub(super) use codex_app_server_protocol::SkillSummary; pub(super) use codex_app_server_protocol::ThreadClosedNotification; pub(super) use codex_app_server_protocol::ThreadItem as AppServerThreadItem; +pub(super) use codex_app_server_protocol::TrackUsageLimitBannerAction; pub(super) use codex_app_server_protocol::Turn as AppServerTurn; pub(super) use codex_app_server_protocol::TurnCompletedNotification; pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError; pub(super) use codex_app_server_protocol::TurnStartedNotification; pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +pub(super) use codex_app_server_protocol::UsageLimitBannerType; pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput; pub(super) use codex_app_server_protocol::WarningNotification; pub(super) use codex_config::AppRequirementToml; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d7aefbe4b4..6b1807b63d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -603,8 +603,24 @@ async fn workspace_member_credits_depleted_prompts_and_sends_credits() { ); let popup = render_bottom_popup(&chat, /*width*/ 90); assert_chatwidget_snapshot!("workspace_member_credits_depleted_prompt", popup); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::Shown, + UsageLimitBannerType::WorkspaceMemberCreditsDepleted + ) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::CtaClicked, + UsageLimitBannerType::WorkspaceMemberCreditsDepleted + ) + ); let event = next_send_add_credits_nudge_email_event(&mut rx); assert_eq!(event, AddCreditsNudgeCreditType::Credits); } @@ -623,8 +639,24 @@ async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() { ); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!("workspace_member_usage_limit_prompt", popup); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::Shown, + UsageLimitBannerType::WorkspaceMemberUsageLimitReached + ) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::CtaClicked, + UsageLimitBannerType::WorkspaceMemberUsageLimitReached + ) + ); let event = next_send_add_credits_nudge_email_event(&mut rx); assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit); } @@ -776,6 +808,14 @@ async fn workspace_owner_nudge_default_no_dismisses_without_sending() { RateLimitErrorKind::Generic, "Usage limit reached.".to_string(), ); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::Shown, + UsageLimitBannerType::WorkspaceMemberCreditsDepleted + ) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); @@ -793,6 +833,14 @@ async fn workspace_owner_nudge_reappears_after_dismissing_no() { RateLimitErrorKind::UsageLimit, "Usage limit reached.".to_string(), ); + let event = next_track_usage_limit_banner_event(&mut rx); + assert_eq!( + event, + ( + TrackUsageLimitBannerAction::Shown, + UsageLimitBannerType::WorkspaceMemberUsageLimitReached + ) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_no_owner_nudge_or_rate_limit_refresh(&mut rx); @@ -892,6 +940,21 @@ fn next_send_add_credits_nudge_email_event( panic!("expected SendAddCreditsNudgeEmail app event"); } +fn next_track_usage_limit_banner_event( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> (TrackUsageLimitBannerAction, UsageLimitBannerType) { + while let Ok(event) = rx.try_recv() { + if let AppEvent::TrackUsageLimitBanner { + action, + banner_type, + } = event + { + return (action, banner_type); + } + } + panic!("expected TrackUsageLimitBanner app event"); +} + fn assert_no_owner_nudge_or_rate_limit_refresh( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) { @@ -899,7 +962,9 @@ fn assert_no_owner_nudge_or_rate_limit_refresh( assert!( !matches!( event, - AppEvent::SendAddCreditsNudgeEmail { .. } | AppEvent::RefreshRateLimits { .. } + AppEvent::SendAddCreditsNudgeEmail { .. } + | AppEvent::RefreshRateLimits { .. } + | AppEvent::TrackUsageLimitBanner { .. } ), "unexpected event: {event:?}" );