From e71124ed6160d22bd086eedfb7c81a94fbbc97d0 Mon Sep 17 00:00:00 2001 From: Moira Huang Date: Fri, 31 Jul 2026 23:15:56 -0700 Subject: [PATCH] telemetry for onboarding --- app/src/tui/mod.rs | 76 ++- app/src/tui/mod_tests.rs | 8 + app/src/tui/telemetry.rs | 464 ++++++++++++++++++ app/src/tui/telemetry_tests.rs | 178 +++++++ crates/warp_core/src/execution_mode.rs | 6 +- crates/warp_core/src/execution_mode_tests.rs | 7 + crates/warp_server_client/src/auth/session.rs | 19 +- .../src/auth/session_tests.rs | 13 +- crates/warp_tui/src/root_view.rs | 23 +- crates/warp_tui/src/session.rs | 1 + 10 files changed, 778 insertions(+), 17 deletions(-) create mode 100644 app/src/tui/telemetry.rs create mode 100644 app/src/tui/telemetry_tests.rs create mode 100644 crates/warp_core/src/execution_mode_tests.rs diff --git a/app/src/tui/mod.rs b/app/src/tui/mod.rs index ecd23b62b7c..c3bbca9942d 100644 --- a/app/src/tui/mod.rs +++ b/app/src/tui/mod.rs @@ -6,6 +6,7 @@ //! leaves device authorization behind an explicit welcome-screen action. The //! authentication gate remains visible until the browser flow completes. mod mcp; +mod telemetry; mod user_info; use std::env; @@ -15,9 +16,13 @@ pub use mcp::{ TuiMcpServerStatus, TuiMcpSnapshot, TuiMcpSyncedTemplateProvenance, TuiMcpTemplateVariable, TuiMcpTransport, TuiMcpVariableValue, }; +use telemetry::{ + AbandonmentPhase, AuthenticationEntrypoint, TuiOnboardingTelemetry, TuiOnboardingTelemetryEvent, +}; use url::Url; pub use user_info::{TuiUserInfoManager, TuiUserInfoManagerEvent, TuiUserInfoSnapshot}; use warp_core::channel::ChannelState; +use warp_core::telemetry::TelemetryEvent as _; use warpui::{AppContext, Entity, SingletonEntity}; use crate::TuiMountFn; @@ -66,6 +71,7 @@ enum TuiAuthBrowserFlow { pub struct TuiLoginModel { phase: TuiLoginPhase, browser_flow: TuiAuthBrowserFlow, + telemetry: TuiOnboardingTelemetry, } impl TuiLoginModel { @@ -78,6 +84,33 @@ impl TuiLoginModel { start_tui_device_login(ctx); } + /// Starts device authorization and records that the generated URL should be copied. + pub fn start_device_login_and_copy_url(ctx: &mut AppContext) { + start_tui_device_login_with_entrypoint(AuthenticationEntrypoint::CopyUrl, ctx); + } + + /// Records the outcome of copying the current authentication URL. + pub fn record_login_url_copied(succeeded: bool, ctx: &mut AppContext) { + let event = + Self::handle(ctx).update(ctx, |model, _| model.telemetry.login_url_copied(succeeded)); + send_tui_onboarding_event(event, ctx); + } + + /// Records that the user exited while the authentication UI was visible. + pub fn record_authentication_abandoned(ctx: &mut AppContext) { + let event = Self::handle(ctx).update(ctx, |model, _| { + let phase = AbandonmentPhase::from_login_phase(&model.phase)?; + model.telemetry.abandoned(phase) + }); + send_tui_onboarding_event(event, ctx); + } + + /// Records that the terminal became usable after interactive authentication. + pub fn record_terminal_shown(ctx: &mut AppContext) { + let event = Self::handle(ctx).update(ctx, |model, _| model.telemetry.completed()); + send_tui_onboarding_event(event, ctx); + } + /// Opens the current device-authorization URL. pub fn open_login_url(browser_url: &str, ctx: &mut AppContext) { let is_current_url = matches!( @@ -99,7 +132,12 @@ impl TuiLoginModel { TuiLoginModel::as_ref(ctx).phase(), TuiLoginPhase::BrowserOpenFailed { .. } ); - if !ctx.try_open_url(browser_url) { + let browser_opened = ctx.try_open_url(browser_url); + let event = TuiLoginModel::handle(ctx).update(ctx, |model, _| { + model.telemetry.browser_launch(browser_opened) + }); + send_tui_onboarding_event(event, ctx); + if !browser_opened { TuiLoginModel::handle(ctx).update(ctx, |model, _| { if model.browser_flow == TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationOpened { model.browser_flow = TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending; @@ -135,6 +173,7 @@ impl TuiLoginModel { Self { phase: TuiLoginPhase::SignedOutWelcome, browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization, + telemetry: TuiOnboardingTelemetry::new(false), } } @@ -145,6 +184,7 @@ impl TuiLoginModel { message: message.into(), }, browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization, + telemetry: TuiOnboardingTelemetry::new(false), } } @@ -153,6 +193,7 @@ impl TuiLoginModel { Self { phase: TuiLoginPhase::AwaitingLogin { browser_url }, browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization, + telemetry: TuiOnboardingTelemetry::new(false), } } } @@ -178,6 +219,7 @@ pub(crate) fn init(mount: TuiMountFn, ctx: &mut AppContext) { ctx.add_singleton_model(move |_| TuiLoginModel { phase: initial_phase, browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization, + telemetry: TuiOnboardingTelemetry::new(logged_in), }); ctx.add_singleton_model(TuiMcpManager::new); ctx.add_singleton_model(TuiUserInfoManager::new); @@ -209,6 +251,9 @@ fn handle_auth_manager_event(event: &AuthManagerEvent, ctx: &mut AppContext) { verification_url_complete, user_code, } => { + let event = TuiLoginModel::handle(ctx) + .update(ctx, |model, _| model.telemetry.device_authorization_ready()); + send_tui_onboarding_event(event, ctx); // Prefer the "complete" URL (device code pre-filled) for opening. let url_to_open = verification_url_complete .as_deref() @@ -246,6 +291,9 @@ fn handle_auth_manager_event(event: &AuthManagerEvent, ctx: &mut AppContext) { activate_global_mcp_servers(ctx); } AuthManagerEvent::AuthFailed(err) => { + let event = TuiLoginModel::handle(ctx) + .update(ctx, |model, _| model.telemetry.authentication_failed(err)); + send_tui_onboarding_event(event, ctx); let should_finish_web_logout = matches!( TuiLoginModel::as_ref(ctx).browser_flow, TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending @@ -346,7 +394,14 @@ fn activate_global_mcp_servers(ctx: &mut AppContext) { /// Starts device authorization from a signed-out screen, preserving any required web logout. pub fn start_tui_device_login(ctx: &mut AppContext) { - let should_authorize = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| { + start_tui_device_login_with_entrypoint(AuthenticationEntrypoint::OpenBrowser, ctx); +} + +fn start_tui_device_login_with_entrypoint( + entrypoint: AuthenticationEntrypoint, + ctx: &mut AppContext, +) { + let (should_authorize, event) = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| { match model.phase { TuiLoginPhase::SignedOutWelcome => { model.browser_flow = TuiAuthBrowserFlow::DirectDeviceAuthorization; @@ -354,13 +409,15 @@ pub fn start_tui_device_login(ctx: &mut AppContext) { TuiLoginPhase::Failed { .. } => {} TuiLoginPhase::AwaitingLogin { .. } | TuiLoginPhase::BrowserOpenFailed { .. } - | TuiLoginPhase::LoggedIn => return false, + | TuiLoginPhase::LoggedIn => return (false, None), } model.phase = TuiLoginPhase::AwaitingLogin { browser_url: None }; + let event = model.telemetry.authentication_started(entrypoint); ctx.notify(); ctx.emit(TuiLoginEvent::PhaseChanged); - true + (true, Some(event)) }); + send_tui_onboarding_event(event, ctx); if should_authorize { authorize_device(ctx); } @@ -376,13 +433,16 @@ pub fn log_out_tui(ctx: &mut AppContext) { } fn set_logged_out_phase(ctx: &mut AppContext) { - TuiLoginModel::handle(ctx).update(ctx, |model, ctx| { + let event = TuiLoginModel::handle(ctx).update(ctx, |model, ctx| { model.phase = TuiLoginPhase::AwaitingLogin { browser_url: None }; model.browser_flow = TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending; + let event = model.telemetry.post_logout_authentication_started(); ctx.notify(); ctx.emit(TuiLoginEvent::PhaseChanged); ctx.emit(TuiLoginEvent::LoggedOut); + event }); + send_tui_onboarding_event(Some(event), ctx); } /// Updates the shared [`TuiLoginModel`] phase and notifies observers, so the @@ -403,6 +463,12 @@ fn set_login_phase(ctx: &mut AppContext, phase: TuiLoginPhase) { }); } +fn send_tui_onboarding_event(event: Option, ctx: &mut AppContext) { + if let Some(event) = event { + warp_core::send_telemetry_from_app_ctx!(event, ctx); + } +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/app/src/tui/mod_tests.rs b/app/src/tui/mod_tests.rs index 841750e489c..60352ef0506 100644 --- a/app/src/tui/mod_tests.rs +++ b/app/src/tui/mod_tests.rs @@ -2,8 +2,10 @@ use std::cell::Cell; use std::rc::Rc; use warp_core::channel::ChannelState; +use warp_core::telemetry::testing::MockTelemetryContextProvider; use warpui::{App, SingletonEntity}; +use super::telemetry::TuiOnboardingTelemetry; use super::{ TuiAuthBrowserFlow, TuiLoginEvent, TuiLoginModel, TuiLoginPhase, handle_auth_manager_event, set_logged_out_phase, set_login_phase, start_tui_device_login, @@ -14,9 +16,11 @@ use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::server::server_api::ServerApiProvider; use crate::server::server_api::auth::UserAuthenticationError; fn login_model(phase: TuiLoginPhase) -> TuiLoginModel { + let logged_in = matches!(phase, TuiLoginPhase::LoggedIn); TuiLoginModel { phase, browser_flow: TuiAuthBrowserFlow::DirectDeviceAuthorization, + telemetry: TuiOnboardingTelemetry::new(logged_in), } } @@ -106,6 +110,7 @@ fn explicit_start_device_login_preserves_pending_logout_on_retry() { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AuthManager::new_for_test); app.add_singleton_model(|_| TuiLoginModel::signed_out_for_test()); + app.update(MockTelemetryContextProvider::register); let phase_changed_events = Rc::new(Cell::new(0)); let phase_changed_events_for_subscription = phase_changed_events.clone(); @@ -232,6 +237,7 @@ fn post_logout_device_auth_opens_logout_with_device_continuation() { app.add_singleton_model(|_| TuiLoginModel { phase: TuiLoginPhase::AwaitingLogin { browser_url: None }, browser_flow: TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending, + telemetry: TuiOnboardingTelemetry::new(true), }); app.update(|ctx| { @@ -305,6 +311,7 @@ fn post_logout_device_code_failure_still_opens_web_logout() { app.add_singleton_model(|_| TuiLoginModel { phase: TuiLoginPhase::AwaitingLogin { browser_url: None }, browser_flow: TuiAuthBrowserFlow::LogoutThenDeviceAuthorizationPending, + telemetry: TuiOnboardingTelemetry::new(true), }); let browser_opened = Rc::new(Cell::new(false)); let browser_opened_for_callback = browser_opened.clone(); @@ -380,6 +387,7 @@ fn emits_logged_in_event_when_login_completes() { fn emits_logged_out_event_and_resets_login_details() { App::test((), |mut app| async move { app.add_singleton_model(|_| login_model(TuiLoginPhase::LoggedIn)); + app.update(MockTelemetryContextProvider::register); let logged_out_events = Rc::new(Cell::new(0)); let logged_out_events_for_subscription = logged_out_events.clone(); diff --git a/app/src/tui/telemetry.rs b/app/src/tui/telemetry.rs new file mode 100644 index 00000000000..77c68d76816 --- /dev/null +++ b/app/src/tui/telemetry.rs @@ -0,0 +1,464 @@ +use instant::Instant; +use serde_json::{Value, json}; +use strum_macros::{EnumDiscriminants, EnumIter}; +use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc}; + +use super::TuiLoginPhase; +use crate::server::server_api::auth::UserAuthenticationError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AuthenticationEntrypoint { + OpenBrowser, + CopyUrl, +} + +impl AuthenticationEntrypoint { + fn as_str(self) -> &'static str { + match self { + Self::OpenBrowser => "open_browser", + Self::CopyUrl => "copy_url", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Journey { + InitialLogin, + PostLogout, +} + +impl Journey { + fn as_str(self) -> &'static str { + match self { + Self::InitialLogin => "initial_login", + Self::PostLogout => "post_logout", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AuthenticationAttempt { + Initial, + Retry, +} + +impl AuthenticationAttempt { + fn as_str(self) -> &'static str { + match self { + Self::Initial => "initial", + Self::Retry => "retry", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum BrowserLaunchTrigger { + Initial, + Retry, + PostLogout, +} + +impl BrowserLaunchTrigger { + fn as_str(self) -> &'static str { + match self { + Self::Initial => "initial", + Self::Retry => "retry", + Self::PostLogout => "post_logout", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Outcome { + Succeeded, + Failed, +} + +impl Outcome { + fn from_succeeded(succeeded: bool) -> Self { + if succeeded { + Self::Succeeded + } else { + Self::Failed + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AuthenticationFailureStage { + DeviceCodeRequest, + Authentication, +} + +impl AuthenticationFailureStage { + fn as_str(self) -> &'static str { + match self { + Self::DeviceCodeRequest => "device_code_request", + Self::Authentication => "authentication", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AuthenticationFailureReason { + DeniedAccessToken, + UserAccountDisabled, + InvalidState, + MissingState, + DeviceCodeRequestTimeout, + Unexpected, +} + +impl AuthenticationFailureReason { + fn from_error(error: &UserAuthenticationError) -> Self { + match error { + UserAuthenticationError::DeniedAccessToken(_) => Self::DeniedAccessToken, + UserAuthenticationError::UserAccountDisabled(_) => Self::UserAccountDisabled, + UserAuthenticationError::InvalidStateParameter => Self::InvalidState, + UserAuthenticationError::MissingStateParameter => Self::MissingState, + UserAuthenticationError::DeviceCodeRequestTimedOut { .. } => { + Self::DeviceCodeRequestTimeout + } + UserAuthenticationError::Unexpected(_) => Self::Unexpected, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::DeniedAccessToken => "denied_access_token", + Self::UserAccountDisabled => "user_account_disabled", + Self::InvalidState => "invalid_state", + Self::MissingState => "missing_state", + Self::DeviceCodeRequestTimeout => "device_code_request_timeout", + Self::Unexpected => "unexpected", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum AbandonmentPhase { + Welcome, + RequestingLink, + WaitingForLogin, + BrowserOpenFailed, + AuthenticationFailed, +} + +impl AbandonmentPhase { + pub(super) fn from_login_phase(phase: &TuiLoginPhase) -> Option { + match phase { + TuiLoginPhase::SignedOutWelcome => Some(Self::Welcome), + TuiLoginPhase::AwaitingLogin { browser_url: None } => Some(Self::RequestingLink), + TuiLoginPhase::AwaitingLogin { + browser_url: Some(_), + } => Some(Self::WaitingForLogin), + TuiLoginPhase::BrowserOpenFailed { .. } => Some(Self::BrowserOpenFailed), + TuiLoginPhase::Failed { .. } => Some(Self::AuthenticationFailed), + TuiLoginPhase::LoggedIn => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Welcome => "welcome", + Self::RequestingLink => "requesting_link", + Self::WaitingForLogin => "waiting_for_login", + Self::BrowserOpenFailed => "browser_open_failed", + Self::AuthenticationFailed => "authentication_failed", + } + } +} + +#[derive(Debug, PartialEq, Eq, EnumDiscriminants)] +#[strum_discriminants(derive(EnumIter))] +pub(super) enum TuiOnboardingTelemetryEvent { + AuthenticationStarted { + journey: Journey, + entrypoint: AuthenticationEntrypoint, + attempt: AuthenticationAttempt, + }, + DeviceAuthorizationReady, + BrowserLaunch { + journey: Journey, + trigger: BrowserLaunchTrigger, + outcome: Outcome, + }, + LoginUrlCopied { + outcome: Outcome, + }, + AuthenticationFailed { + journey: Journey, + stage: AuthenticationFailureStage, + reason: AuthenticationFailureReason, + duration_ms: u64, + }, + Abandoned { + journey: Journey, + phase: AbandonmentPhase, + duration_ms: u64, + }, + Completed { + journey: Journey, + duration_ms: u64, + }, +} + +impl TelemetryEvent for TuiOnboardingTelemetryEvent { + fn name(&self) -> &'static str { + TuiOnboardingTelemetryEventDiscriminants::from(self).name() + } + + fn payload(&self) -> Option { + match self { + Self::AuthenticationStarted { + journey, + entrypoint, + attempt, + } => Some(json!({ + "journey": journey.as_str(), + "entrypoint": entrypoint.as_str(), + "attempt": attempt.as_str(), + })), + Self::DeviceAuthorizationReady => None, + Self::BrowserLaunch { + journey, + trigger, + outcome, + } => Some(json!({ + "journey": journey.as_str(), + "trigger": trigger.as_str(), + "outcome": outcome.as_str(), + })), + Self::LoginUrlCopied { outcome } => Some(json!({ + "outcome": outcome.as_str(), + })), + Self::AuthenticationFailed { + journey, + stage, + reason, + duration_ms, + } => Some(json!({ + "journey": journey.as_str(), + "stage": stage.as_str(), + "reason": reason.as_str(), + "duration_ms": duration_ms, + })), + Self::Abandoned { + journey, + phase, + duration_ms, + } => Some(json!({ + "journey": journey.as_str(), + "phase": phase.as_str(), + "duration_ms": duration_ms, + })), + Self::Completed { + journey, + duration_ms, + } => Some(json!({ + "journey": journey.as_str(), + "duration_ms": duration_ms, + })), + } + } + + fn description(&self) -> &'static str { + TuiOnboardingTelemetryEventDiscriminants::from(self).description() + } + + fn enablement_state(&self) -> EnablementState { + TuiOnboardingTelemetryEventDiscriminants::from(self).enablement_state() + } + + fn contains_ugc(&self) -> bool { + false + } + + fn event_descs() -> impl Iterator> { + warp_core::telemetry::enum_events::() + } +} + +impl TelemetryEventDesc for TuiOnboardingTelemetryEventDiscriminants { + fn name(&self) -> &'static str { + match self { + Self::AuthenticationStarted => "TUI.Onboarding.AuthenticationStarted", + Self::DeviceAuthorizationReady => "TUI.Onboarding.DeviceAuthorizationReady", + Self::BrowserLaunch => "TUI.Onboarding.BrowserLaunch", + Self::LoginUrlCopied => "TUI.Onboarding.LoginUrlCopied", + Self::AuthenticationFailed => "TUI.Onboarding.AuthenticationFailed", + Self::Abandoned => "TUI.Onboarding.Abandoned", + Self::Completed => "TUI.Onboarding.Completed", + } + } + + fn description(&self) -> &'static str { + match self { + Self::AuthenticationStarted => "TUI browser authentication started", + Self::DeviceAuthorizationReady => "TUI device authorization URL became available", + Self::BrowserLaunch => "TUI attempted to launch the authentication browser", + Self::LoginUrlCopied => "TUI attempted to copy the authentication URL", + Self::AuthenticationFailed => "TUI authentication failed", + Self::Abandoned => "User exited while the TUI authentication UI was visible", + Self::Completed => "TUI displayed the terminal after interactive authentication", + } + } + + fn enablement_state(&self) -> EnablementState { + EnablementState::Always + } +} + +warp_core::register_telemetry_event!(TuiOnboardingTelemetryEvent); + +struct ActiveFlow { + journey: Journey, + started_at: Instant, + attempt_started_at: Option, + attempts: usize, + device_authorization_ready: bool, +} + +impl ActiveFlow { + fn new(journey: Journey, started_at: Instant) -> Self { + Self { + journey, + started_at, + attempt_started_at: None, + attempts: 0, + device_authorization_ready: false, + } + } + + fn browser_launch_trigger(&self) -> BrowserLaunchTrigger { + if self.attempts > 1 { + BrowserLaunchTrigger::Retry + } else { + match self.journey { + Journey::InitialLogin => BrowserLaunchTrigger::Initial, + Journey::PostLogout => BrowserLaunchTrigger::PostLogout, + } + } + } +} + +pub(super) struct TuiOnboardingTelemetry { + flow: Option, +} + +impl TuiOnboardingTelemetry { + pub(super) fn new(logged_in: bool) -> Self { + Self { + flow: (!logged_in).then(|| ActiveFlow::new(Journey::InitialLogin, Instant::now())), + } + } + + pub(super) fn authentication_started( + &mut self, + entrypoint: AuthenticationEntrypoint, + ) -> TuiOnboardingTelemetryEvent { + let now = Instant::now(); + let flow = self + .flow + .get_or_insert_with(|| ActiveFlow::new(Journey::InitialLogin, now)); + let attempt = if flow.attempts == 0 { + AuthenticationAttempt::Initial + } else { + AuthenticationAttempt::Retry + }; + flow.attempts += 1; + flow.attempt_started_at = Some(now); + flow.device_authorization_ready = false; + TuiOnboardingTelemetryEvent::AuthenticationStarted { + journey: flow.journey, + entrypoint, + attempt, + } + } + + pub(super) fn post_logout_authentication_started(&mut self) -> TuiOnboardingTelemetryEvent { + self.flow = Some(ActiveFlow::new(Journey::PostLogout, Instant::now())); + self.authentication_started(AuthenticationEntrypoint::OpenBrowser) + } + + pub(super) fn device_authorization_ready(&mut self) -> Option { + let flow = self.flow.as_mut()?; + flow.attempt_started_at?; + if flow.device_authorization_ready { + return None; + } + flow.device_authorization_ready = true; + Some(TuiOnboardingTelemetryEvent::DeviceAuthorizationReady) + } + + pub(super) fn browser_launch(&self, succeeded: bool) -> Option { + let flow = self.flow.as_ref()?; + flow.attempt_started_at?; + Some(TuiOnboardingTelemetryEvent::BrowserLaunch { + journey: flow.journey, + trigger: flow.browser_launch_trigger(), + outcome: Outcome::from_succeeded(succeeded), + }) + } + + pub(super) fn login_url_copied(&self, succeeded: bool) -> Option { + self.flow.as_ref()?; + Some(TuiOnboardingTelemetryEvent::LoginUrlCopied { + outcome: Outcome::from_succeeded(succeeded), + }) + } + + pub(super) fn authentication_failed( + &mut self, + error: &UserAuthenticationError, + ) -> Option { + let flow = self.flow.as_mut()?; + let attempt_started_at = flow.attempt_started_at.take()?; + let stage = if flow.device_authorization_ready { + AuthenticationFailureStage::Authentication + } else { + AuthenticationFailureStage::DeviceCodeRequest + }; + Some(TuiOnboardingTelemetryEvent::AuthenticationFailed { + journey: flow.journey, + stage, + reason: AuthenticationFailureReason::from_error(error), + duration_ms: elapsed_ms(attempt_started_at), + }) + } + + pub(super) fn abandoned( + &mut self, + phase: AbandonmentPhase, + ) -> Option { + let flow = self.flow.take()?; + Some(TuiOnboardingTelemetryEvent::Abandoned { + journey: flow.journey, + phase, + duration_ms: elapsed_ms(flow.started_at), + }) + } + + pub(super) fn completed(&mut self) -> Option { + let flow = self.flow.take()?; + Some(TuiOnboardingTelemetryEvent::Completed { + journey: flow.journey, + duration_ms: elapsed_ms(flow.started_at), + }) + } +} + +fn elapsed_ms(started_at: Instant) -> u64 { + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +#[path = "telemetry_tests.rs"] +mod tests; diff --git a/app/src/tui/telemetry_tests.rs b/app/src/tui/telemetry_tests.rs new file mode 100644 index 00000000000..c7bc8420c39 --- /dev/null +++ b/app/src/tui/telemetry_tests.rs @@ -0,0 +1,178 @@ +use anyhow::anyhow; +use serde_json::json; +use warp_core::telemetry::TelemetryEvent as _; + +use super::{ + AbandonmentPhase, AuthenticationAttempt, AuthenticationEntrypoint, AuthenticationFailureReason, + AuthenticationFailureStage, BrowserLaunchTrigger, Journey, Outcome, TuiOnboardingTelemetry, + TuiOnboardingTelemetryEvent, +}; +use crate::server::server_api::auth::UserAuthenticationError; + +#[test] +fn event_names_and_payloads_are_stable() { + let events = [ + ( + TuiOnboardingTelemetryEvent::AuthenticationStarted { + journey: Journey::InitialLogin, + entrypoint: AuthenticationEntrypoint::CopyUrl, + attempt: AuthenticationAttempt::Retry, + }, + "TUI.Onboarding.AuthenticationStarted", + Some(json!({ + "journey": "initial_login", + "entrypoint": "copy_url", + "attempt": "retry", + })), + ), + ( + TuiOnboardingTelemetryEvent::DeviceAuthorizationReady, + "TUI.Onboarding.DeviceAuthorizationReady", + None, + ), + ( + TuiOnboardingTelemetryEvent::BrowserLaunch { + journey: Journey::PostLogout, + trigger: BrowserLaunchTrigger::PostLogout, + outcome: Outcome::Failed, + }, + "TUI.Onboarding.BrowserLaunch", + Some(json!({ + "journey": "post_logout", + "trigger": "post_logout", + "outcome": "failed", + })), + ), + ( + TuiOnboardingTelemetryEvent::LoginUrlCopied { + outcome: Outcome::Succeeded, + }, + "TUI.Onboarding.LoginUrlCopied", + Some(json!({ "outcome": "succeeded" })), + ), + ( + TuiOnboardingTelemetryEvent::AuthenticationFailed { + journey: Journey::InitialLogin, + stage: AuthenticationFailureStage::Authentication, + reason: AuthenticationFailureReason::InvalidState, + duration_ms: 42, + }, + "TUI.Onboarding.AuthenticationFailed", + Some(json!({ + "journey": "initial_login", + "stage": "authentication", + "reason": "invalid_state", + "duration_ms": 42, + })), + ), + ( + TuiOnboardingTelemetryEvent::Abandoned { + journey: Journey::InitialLogin, + phase: AbandonmentPhase::BrowserOpenFailed, + duration_ms: 43, + }, + "TUI.Onboarding.Abandoned", + Some(json!({ + "journey": "initial_login", + "phase": "browser_open_failed", + "duration_ms": 43, + })), + ), + ( + TuiOnboardingTelemetryEvent::Completed { + journey: Journey::PostLogout, + duration_ms: 44, + }, + "TUI.Onboarding.Completed", + Some(json!({ + "journey": "post_logout", + "duration_ms": 44, + })), + ), + ]; + + for (event, expected_name, expected_payload) in events { + assert_eq!(event.name(), expected_name); + assert_eq!(event.payload(), expected_payload); + assert!(!event.contains_ugc()); + } +} + +#[test] +fn flow_tracks_retries_and_deduplicates_terminal_outcomes() { + let mut telemetry = TuiOnboardingTelemetry::new(false); + + assert!(matches!( + telemetry.authentication_started(AuthenticationEntrypoint::OpenBrowser), + TuiOnboardingTelemetryEvent::AuthenticationStarted { + journey: Journey::InitialLogin, + attempt: AuthenticationAttempt::Initial, + .. + } + )); + assert_eq!( + telemetry.device_authorization_ready(), + Some(TuiOnboardingTelemetryEvent::DeviceAuthorizationReady) + ); + assert_eq!(telemetry.device_authorization_ready(), None); + assert!(matches!( + telemetry.authentication_failed(&UserAuthenticationError::Unexpected(anyhow!( + "raw detail that must not enter telemetry" + ))), + Some(TuiOnboardingTelemetryEvent::AuthenticationFailed { + stage: AuthenticationFailureStage::Authentication, + reason: AuthenticationFailureReason::Unexpected, + .. + }) + )); + + assert!(matches!( + telemetry.authentication_started(AuthenticationEntrypoint::CopyUrl), + TuiOnboardingTelemetryEvent::AuthenticationStarted { + attempt: AuthenticationAttempt::Retry, + .. + } + )); + assert!(matches!( + telemetry.browser_launch(true), + Some(TuiOnboardingTelemetryEvent::BrowserLaunch { + trigger: BrowserLaunchTrigger::Retry, + outcome: Outcome::Succeeded, + .. + }) + )); + assert!(matches!( + telemetry.completed(), + Some(TuiOnboardingTelemetryEvent::Completed { + journey: Journey::InitialLogin, + .. + }) + )); + assert_eq!(telemetry.completed(), None); + assert_eq!(telemetry.abandoned(AbandonmentPhase::WaitingForLogin), None); +} + +#[test] +fn post_logout_failure_uses_low_cardinality_device_request_dimensions() { + let mut telemetry = TuiOnboardingTelemetry::new(true); + + assert!(matches!( + telemetry.post_logout_authentication_started(), + TuiOnboardingTelemetryEvent::AuthenticationStarted { + journey: Journey::PostLogout, + attempt: AuthenticationAttempt::Initial, + .. + } + )); + assert!(matches!( + telemetry.authentication_failed(&UserAuthenticationError::DeviceCodeRequestTimedOut { + attempts: 2 + }), + Some(TuiOnboardingTelemetryEvent::AuthenticationFailed { + journey: Journey::PostLogout, + stage: AuthenticationFailureStage::DeviceCodeRequest, + reason: AuthenticationFailureReason::DeviceCodeRequestTimeout, + .. + }) + )); +} diff --git a/crates/warp_core/src/execution_mode.rs b/crates/warp_core/src/execution_mode.rs index 9759fee62ab..be981c5e671 100644 --- a/crates/warp_core/src/execution_mode.rs +++ b/crates/warp_core/src/execution_mode.rs @@ -24,7 +24,7 @@ impl ExecutionMode { pub fn client_id(&self) -> &'static str { match self { ExecutionMode::App => "warp-app", - ExecutionMode::Tui => "warp-tui", + ExecutionMode::Tui => "warp-agent-cli", ExecutionMode::Sdk => "warp-cli", ExecutionMode::RemoteServerDaemon => "warp-remote-server-daemon", } @@ -142,3 +142,7 @@ impl SingletonEntity for AppExecutionMode {} pub fn current_client_id() -> Option<&'static str> { GLOBAL_EXECUTION_MODE.get().map(|mode| mode.client_id()) } + +#[cfg(test)] +#[path = "execution_mode_tests.rs"] +mod tests; diff --git a/crates/warp_core/src/execution_mode_tests.rs b/crates/warp_core/src/execution_mode_tests.rs new file mode 100644 index 00000000000..a66d63b5d82 --- /dev/null +++ b/crates/warp_core/src/execution_mode_tests.rs @@ -0,0 +1,7 @@ +use super::ExecutionMode; + +#[test] +fn execution_modes_report_distinct_agent_and_sdk_client_ids() { + assert_eq!(ExecutionMode::Tui.client_id(), "warp-agent-cli"); + assert_eq!(ExecutionMode::Sdk.client_id(), "warp-cli"); +} diff --git a/crates/warp_server_client/src/auth/session.rs b/crates/warp_server_client/src/auth/session.rs index 1482bb02a86..3d43fced71d 100644 --- a/crates/warp_server_client/src/auth/session.rs +++ b/crates/warp_server_client/src/auth/session.rs @@ -8,6 +8,7 @@ use instant::Duration; use oauth2::TokenResponse as _; use url::Url; use warp_core::channel::ChannelState; +use warp_core::execution_mode::current_client_id; use warp_server_auth::auth_state::AuthState; use warp_server_auth::credentials::{ AuthToken, Credentials, FirebaseToken, LoginToken, RefreshToken, @@ -18,6 +19,8 @@ use warpui_core::r#async::{BoxFuture, Timer}; use super::UserAuthenticationError; const FETCH_ACCESS_TOKEN_TIMEOUT: Duration = Duration::from_secs(5); +const WARP_AGENT_CLI_CLIENT_ID: &str = "warp-agent-cli"; +const WARP_CLI_CLIENT_ID: &str = "warp-cli"; /// Authentication and authenticated-transport conditions observed by shared client code. #[derive(Clone)] @@ -205,9 +208,11 @@ impl AuthSession { .join("/api/v1/oauth/device/auth") .expect("Invalid device URL"); - oauth2::basic::BasicClient::new(oauth2::ClientId::new("warp-cli".to_string())) - .set_token_uri(oauth2::TokenUrl::from_url(token_url)) - .set_device_authorization_url(oauth2::DeviceAuthorizationUrl::from_url(device_url)) + oauth2::basic::BasicClient::new(oauth2::ClientId::new( + device_oauth_client_id(current_client_id()).to_string(), + )) + .set_token_uri(oauth2::TokenUrl::from_url(token_url)) + .set_device_authorization_url(oauth2::DeviceAuthorizationUrl::from_url(device_url)) } fn fetch_auth_tokens( @@ -281,6 +286,14 @@ impl AuthSession { } } +fn device_oauth_client_id(reported_client_id: Option<&str>) -> &'static str { + if reported_client_id == Some(WARP_AGENT_CLI_CLIENT_ID) { + WARP_AGENT_CLI_CLIENT_ID + } else { + WARP_CLI_CLIENT_ID + } +} + #[cfg(test)] #[path = "session_tests.rs"] mod tests; diff --git a/crates/warp_server_client/src/auth/session_tests.rs b/crates/warp_server_client/src/auth/session_tests.rs index 856f0644180..fb00bcdb1b1 100644 --- a/crates/warp_server_client/src/auth/session_tests.rs +++ b/crates/warp_server_client/src/auth/session_tests.rs @@ -6,7 +6,7 @@ use warp_server_auth::auth_state::AuthState; use warp_server_auth::credentials::{AuthToken, Credentials, LoginToken}; use warp_server_auth::user::FirebaseAuthTokens; -use super::AuthSession; +use super::{AuthSession, device_oauth_client_id}; fn session_with_state( auth_state: Arc, @@ -65,3 +65,14 @@ fn api_key_exchange_defers_owner_type_until_user_properties_are_fetched() { } if key == "api-key" )); } + +#[test] +fn device_oauth_client_distinguishes_warp_agent_cli_from_sdk_callers() { + assert_eq!( + device_oauth_client_id(Some("warp-agent-cli")), + "warp-agent-cli" + ); + assert_eq!(device_oauth_client_id(Some("warp-cli")), "warp-cli"); + assert_eq!(device_oauth_client_id(Some("warp-app")), "warp-cli"); + assert_eq!(device_oauth_client_id(None), "warp-cli"); +} diff --git a/crates/warp_tui/src/root_view.rs b/crates/warp_tui/src/root_view.rs index 677a5ee92cb..ebb614451f2 100644 --- a/crates/warp_tui/src/root_view.rs +++ b/crates/warp_tui/src/root_view.rs @@ -169,11 +169,14 @@ impl RootTuiView { } match copy(url) { - Ok(()) => self.login_copy_hint.show_success( - "Login URL copied to clipboard".to_owned(), - ctx, - |view| &mut view.login_copy_hint, - ), + Ok(()) => { + self.login_copy_hint.show_success( + "Login URL copied to clipboard".to_owned(), + ctx, + |view| &mut view.login_copy_hint, + ); + TuiLoginModel::record_login_url_copied(true, ctx); + } Err(error) => { log::warn!("Failed to copy TUI login URL: {error}"); self.login_copy_hint.show_error( @@ -181,6 +184,7 @@ impl RootTuiView { ctx, |view| &mut view.login_copy_hint, ); + TuiLoginModel::record_login_url_copied(false, ctx); } } } @@ -316,7 +320,12 @@ impl TypedActionView for RootTuiView { fn handle_action(&mut self, action: &RootTuiAction, ctx: &mut ViewContext) { match action { - RootTuiAction::ExitApp => ctx.terminate_app(TerminationMode::ForceTerminate, None), + RootTuiAction::ExitApp => { + if matches!(self.state, RootTuiState::Auth) { + TuiLoginModel::record_authentication_abandoned(ctx); + } + ctx.terminate_app(TerminationMode::ForceTerminate, None); + } RootTuiAction::StartDeviceLogin => { if matches!( TuiLoginModel::as_ref(ctx).phase(), @@ -333,7 +342,7 @@ impl TypedActionView for RootTuiView { ) { self.reset_login_copy_state(); self.copy_login_url_when_available = true; - TuiLoginModel::start_device_login(ctx); + TuiLoginModel::start_device_login_and_copy_url(ctx); } } RootTuiAction::OpenLoginUrl(url) => { diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 25de44b10b0..a3ddafb7705 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -356,6 +356,7 @@ fn ensure_terminal_session( }); } root.update(ctx, |root, ctx| root.show_terminal(ctx)); + TuiLoginModel::record_terminal_shown(ctx); } #[cfg(test)]