agent_ui: Add additional telemetry for thread errors (#47971)

Katie Geer , Michael Benfield , and Claude Opus 4.5 created

This PR extends error telemetry coverage in the agent panel, building on
#46836 which added telemetry for thread errors.


`"Agent Panel Error Shown"` (extended)

Now also fires for:

- **Load errors** - when an agent fails to start 
- **Configuration errors** - when LLM provider setup is incomplete 

`"Agent Token Limit Warning"` (new event)

Fires when a thread approaches or exceeds its token/context limit. This
is tracked separately from errors because it's an informational warning,
not a failure.

## Implementation Notes

- All telemetry fires **once per error occurrence**, not on every render
- Load error telemetry fires in `handle_load_error()` when the error is
first received
- Token limit telemetry uses a flag to track state transitions (Normal →
Warning → Exceeded)
- Configuration error telemetry tracks the last emitted error kind to
avoid duplicates

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Michael Benfield <mbenfield@zed.dev>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

Change summary

crates/acp_thread/src/acp_thread.rs                  |   2 
crates/agent_ui/src/acp/thread_view.rs               |  83 +++++++++
crates/agent_ui/src/acp/thread_view/active_thread.rs |   2 
crates/agent_ui/src/agent_panel.rs                   | 112 +++++++++----
4 files changed, 157 insertions(+), 42 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -932,7 +932,7 @@ impl TokenUsage {
     }
 }
 
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
 pub enum TokenUsageRatio {
     Normal,
     Warning,

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -797,12 +797,13 @@ impl AcpServerView {
             }
             _ => {}
         }
-        if let Some(load_err) = err.downcast_ref::<LoadError>() {
-            self.server_state = ServerState::LoadError(load_err.clone());
+        let load_error = if let Some(load_err) = err.downcast_ref::<LoadError>() {
+            load_err.clone()
         } else {
-            self.server_state =
-                ServerState::LoadError(LoadError::Other(format!("{:#}", err).into()))
-        }
+            LoadError::Other(format!("{:#}", err).into())
+        };
+        self.emit_load_error_telemetry(&load_error);
+        self.server_state = ServerState::LoadError(load_error);
         cx.notify();
     }
 
@@ -1069,6 +1070,7 @@ impl AcpServerView {
             }
             AcpThreadEvent::TokenUsageUpdated => {
                 self.update_turn_tokens(cx);
+                self.emit_token_limit_telemetry_if_needed(thread, cx);
             }
             AcpThreadEvent::AvailableCommandsUpdated(available_commands) => {
                 let mut available_commands = available_commands.clone();
@@ -1628,6 +1630,77 @@ impl AcpServerView {
             .into_any_element()
     }
 
+    fn emit_token_limit_telemetry_if_needed(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(active_thread) = self.as_active_thread() else {
+            return;
+        };
+
+        let (ratio, agent_telemetry_id, session_id) = {
+            let thread_data = thread.read(cx);
+            let Some(token_usage) = thread_data.token_usage() else {
+                return;
+            };
+            (
+                token_usage.ratio(),
+                thread_data.connection().telemetry_id(),
+                thread_data.session_id().clone(),
+            )
+        };
+
+        let kind = match ratio {
+            acp_thread::TokenUsageRatio::Normal => {
+                active_thread.update(cx, |active, _cx| {
+                    active.last_token_limit_telemetry = None;
+                });
+                return;
+            }
+            acp_thread::TokenUsageRatio::Warning => "warning",
+            acp_thread::TokenUsageRatio::Exceeded => "exceeded",
+        };
+
+        let should_skip = active_thread
+            .read(cx)
+            .last_token_limit_telemetry
+            .as_ref()
+            .is_some_and(|last| *last >= ratio);
+        if should_skip {
+            return;
+        }
+
+        active_thread.update(cx, |active, _cx| {
+            active.last_token_limit_telemetry = Some(ratio);
+        });
+
+        telemetry::event!(
+            "Agent Token Limit Warning",
+            agent = agent_telemetry_id,
+            session_id = session_id,
+            kind = kind,
+        );
+    }
+
+    fn emit_load_error_telemetry(&self, error: &LoadError) {
+        let error_kind = match error {
+            LoadError::Unsupported { .. } => "unsupported",
+            LoadError::FailedToInstall(_) => "failed_to_install",
+            LoadError::Exited { .. } => "exited",
+            LoadError::Other(_) => "other",
+        };
+
+        let agent_name = self.agent.name();
+
+        telemetry::event!(
+            "Agent Panel Error Shown",
+            agent = agent_name,
+            kind = error_kind,
+            message = error.to_string(),
+        );
+    }
+
     fn render_load_error(
         &self,
         e: &LoadError,

crates/agent_ui/src/acp/thread_view/active_thread.rs 🔗

@@ -180,6 +180,7 @@ pub struct AcpThreadView {
     pub(super) thread_error: Option<ThreadError>,
     pub thread_error_markdown: Option<Entity<Markdown>>,
     pub token_limit_callout_dismissed: bool,
+    pub last_token_limit_telemetry: Option<acp_thread::TokenUsageRatio>,
     thread_feedback: ThreadFeedbackState,
     pub list_state: ListState,
     pub prompt_capabilities: Rc<RefCell<PromptCapabilities>>,
@@ -356,6 +357,7 @@ impl AcpThreadView {
             thread_error: None,
             thread_error_markdown: None,
             token_limit_callout_dismissed: false,
+            last_token_limit_telemetry: None,
             thread_feedback: Default::default(),
             expanded_tool_calls: HashSet::default(),
             expanded_tool_call_raw_inputs: HashSet::default(),

crates/agent_ui/src/agent_panel.rs 🔗

@@ -440,6 +440,7 @@ pub struct AgentPanel {
     onboarding: Entity<AgentPanelOnboarding>,
     selected_agent: AgentType,
     show_trust_workspace_message: bool,
+    last_configuration_error_telemetry: Option<String>,
 }
 
 impl AgentPanel {
@@ -660,6 +661,7 @@ impl AgentPanel {
             thread_store,
             selected_agent: AgentType::default(),
             show_trust_workspace_message: false,
+            last_configuration_error_telemetry: None,
         };
 
         // Initial sync of agent servers from extensions
@@ -2694,6 +2696,33 @@ impl AgentPanel {
         )
     }
 
+    fn emit_configuration_error_telemetry_if_needed(
+        &mut self,
+        configuration_error: Option<&ConfigurationError>,
+    ) {
+        let error_kind = configuration_error.map(|err| match err {
+            ConfigurationError::NoProvider => "no_provider",
+            ConfigurationError::ModelNotFound => "model_not_found",
+            ConfigurationError::ProviderNotAuthenticated(_) => "provider_not_authenticated",
+        });
+
+        let error_kind_string = error_kind.map(String::from);
+
+        if self.last_configuration_error_telemetry == error_kind_string {
+            return;
+        }
+
+        self.last_configuration_error_telemetry = error_kind_string;
+
+        if let Some(kind) = error_kind {
+            let message = configuration_error
+                .map(|err| err.to_string())
+                .unwrap_or_default();
+
+            telemetry::event!("Agent Panel Error Shown", kind = kind, message = message,);
+        }
+    }
+
     fn render_configuration_error(
         &self,
         border_bottom: bool,
@@ -2978,46 +3007,57 @@ impl Render for AgentPanel {
             .child(self.render_toolbar(window, cx))
             .children(self.render_workspace_trust_message(cx))
             .children(self.render_onboarding(window, cx))
-            .map(|parent| match &self.active_view {
-                ActiveView::Uninitialized => parent,
-                ActiveView::AgentThread { thread_view, .. } => parent
-                    .child(thread_view.clone())
-                    .child(self.render_drag_target(cx)),
-                ActiveView::History { kind } => match kind {
-                    HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
-                    HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
-                },
-                ActiveView::TextThread {
-                    text_thread_editor,
-                    buffer_search_bar,
-                    ..
-                } => {
+            .map(|parent| {
+                // Emit configuration error telemetry before entering the match to avoid borrow conflicts
+                if matches!(&self.active_view, ActiveView::TextThread { .. }) {
                     let model_registry = LanguageModelRegistry::read_global(cx);
                     let configuration_error =
                         model_registry.configuration_error(model_registry.default_model(), cx);
-                    parent
-                        .map(|this| {
-                            if !self.should_render_onboarding(cx)
-                                && let Some(err) = configuration_error.as_ref()
-                            {
-                                this.child(self.render_configuration_error(
-                                    true,
-                                    err,
-                                    &self.focus_handle(cx),
-                                    cx,
-                                ))
-                            } else {
-                                this
-                            }
-                        })
-                        .child(self.render_text_thread(
-                            text_thread_editor,
-                            buffer_search_bar,
-                            window,
-                            cx,
-                        ))
+                    self.emit_configuration_error_telemetry_if_needed(configuration_error.as_ref());
+                }
+
+                match &self.active_view {
+                    ActiveView::Uninitialized => parent,
+                    ActiveView::AgentThread { thread_view, .. } => parent
+                        .child(thread_view.clone())
+                        .child(self.render_drag_target(cx)),
+                    ActiveView::History { kind } => match kind {
+                        HistoryKind::AgentThreads => parent.child(self.acp_history.clone()),
+                        HistoryKind::TextThreads => parent.child(self.text_thread_history.clone()),
+                    },
+                    ActiveView::TextThread {
+                        text_thread_editor,
+                        buffer_search_bar,
+                        ..
+                    } => {
+                        let model_registry = LanguageModelRegistry::read_global(cx);
+                        let configuration_error =
+                            model_registry.configuration_error(model_registry.default_model(), cx);
+
+                        parent
+                            .map(|this| {
+                                if !self.should_render_onboarding(cx)
+                                    && let Some(err) = configuration_error.as_ref()
+                                {
+                                    this.child(self.render_configuration_error(
+                                        true,
+                                        err,
+                                        &self.focus_handle(cx),
+                                        cx,
+                                    ))
+                                } else {
+                                    this
+                                }
+                            })
+                            .child(self.render_text_thread(
+                                text_thread_editor,
+                                buffer_search_bar,
+                                window,
+                                cx,
+                            ))
+                    }
+                    ActiveView::Configuration => parent.children(self.configuration.clone()),
                 }
-                ActiveView::Configuration => parent.children(self.configuration.clone()),
             })
             .children(self.render_trial_end_upsell(window, cx));