diff --git a/Cargo.lock b/Cargo.lock index 9db557a83d664517823ff37904f21621a10b5aab..2393bd29d5a6beb8806af2019f9fd3544a1a7ee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20808,12 +20808,14 @@ dependencies = [ "task", "tasks_ui", "telemetry", + "telemetry_events", "tempfile", "terminal_view", "theme", "theme_extension", "theme_selector", "time", + "time_format", "title_bar", "toolchain_selector", "tracing", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 50cf12b977a62d56bf9d4a036165917a5dfff2fc..d9ef55056049e387d931bc9fe59e0327b4ce1637 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -27,6 +27,7 @@ collections.workspace = true credentials_provider.workspace = true derive_more.workspace = true feature_flags.workspace = true +fs.workspace = true futures.workspace = true gpui.workspace = true gpui_tokio.workspace = true diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 68b6c302fb20b1afe78a89dada745538d8150d0d..19eba7dc82a04a6fb23b08ecca6562bfa2c7481d 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1,10 +1,11 @@ mod event_coalescer; use crate::TelemetrySettings; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clock::SystemClock; +use fs::Fs; use futures::channel::mpsc; -use futures::{Future, FutureExt, StreamExt}; +use futures::{Future, StreamExt}; use gpui::{App, AppContext as _, BackgroundExecutor, Task}; use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request}; use parking_lot::Mutex; @@ -19,7 +20,18 @@ use std::sync::LazyLock; use std::time::Instant; use std::{env, mem, path::PathBuf, sync::Arc, time::Duration}; use telemetry_events::{AssistantEventData, AssistantPhase, Event, EventRequestBody, EventWrapper}; -use util::TryFutureExt; + +pub struct TelemetrySubscription { + pub historical_events: Result, + pub queued_events: Vec, + pub live_events: mpsc::UnboundedReceiver, +} + +pub struct HistoricalEvents { + pub events: Vec, + pub parse_error_count: usize, +} +use util::ResultExt as _; use worktree::{UpdatedEntriesSet, WorktreeId}; use self::event_coalescer::EventCoalescer; @@ -41,6 +53,7 @@ struct TelemetryState { architecture: &'static str, events_queue: Vec, flush_events_task: Option>, + log_file: Option, is_staff: Option, first_event_date_time: Option, @@ -51,6 +64,8 @@ struct TelemetryState { os_name: String, app_version: String, os_version: Option, + + subscribers: Vec>, } #[cfg(debug_assertions)] @@ -199,8 +214,8 @@ impl Telemetry { os_version: None, os_name: os_name(), app_version: release_channel::AppVersion::global(cx).to_string(), + subscribers: Vec::new(), })); - Self::log_file_path(); cx.background_spawn({ let state = state.clone(); @@ -273,6 +288,70 @@ impl Telemetry { paths::logs_dir().join("telemetry.log") } + pub async fn subscribe_with_history( + self: &Arc, + fs: Arc, + ) -> TelemetrySubscription { + let historical_events = self.read_log_file(fs).await; + + let mut state = self.state.lock(); + let queued_events: Vec = state.events_queue.clone(); + + let (tx, rx) = mpsc::unbounded(); + state.subscribers.push(tx); + + drop(state); + + TelemetrySubscription { + historical_events, + queued_events, + live_events: rx, + } + } + + async fn read_log_file(self: &Arc, fs: Arc) -> anyhow::Result { + const MAX_LOG_READ: usize = 5 * 1024 * 1024; + + let path = Self::log_file_path(); + + let content = fs + .load_bytes(&path) + .await + .with_context(|| format!("failed to load telemetry log from {:?}", path))?; + + let start_offset = if content.len() > MAX_LOG_READ { + let skip = content.len() - MAX_LOG_READ; + content[skip..] + .iter() + .position(|&b| b == b'\n') + .map(|pos| skip + pos + 1) + .unwrap_or(skip) + } else { + 0 + }; + + let content_str = std::str::from_utf8(&content[start_offset..]) + .context("telemetry log file contains invalid UTF-8")?; + + let mut events = Vec::new(); + let mut parse_error_count = 0; + + for line in content_str.lines() { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(event) => events.push(event), + Err(_) => parse_error_count += 1, + } + } + + Ok(HistoricalEvents { + events, + parse_error_count, + }) + } + pub fn has_checksum_seed(&self) -> bool { ZED_CLIENT_CHECKSUM_SEED.is_some() } @@ -473,11 +552,17 @@ impl Telemetry { }; let signed_in = state.metrics_id.is_some(); - state.events_queue.push(EventWrapper { + let event_wrapper = EventWrapper { signed_in, milliseconds_since_first_event, event, - }); + }; + + state + .subscribers + .retain(|tx| tx.unbounded_send(event_wrapper.clone()).is_ok()); + + state.events_queue.push(event_wrapper); if state.installation_id.is_some() && state.events_queue.len() >= state.max_queue_size { drop(state); @@ -524,59 +609,60 @@ impl Telemetry { .body(json_bytes.into())?) } - pub fn flush_events(self: &Arc) -> Task<()> { - let mut state = self.state.lock(); - state.first_event_date_time = None; - let events = mem::take(&mut state.events_queue); - state.flush_events_task.take(); - drop(state); - if events.is_empty() { - return Task::ready(()); - } + pub async fn flush_events_inner(self: &Arc) -> Result<()> { + let (json_bytes, request_body) = { + let mut state = self.state.lock(); + state.first_event_date_time = None; + let events = mem::take(&mut state.events_queue); + state.flush_events_task.take(); + if events.is_empty() { + return Ok(()); + } - let this = self.clone(); - self.executor.spawn( - async move { - let mut json_bytes = Vec::new(); - - if let Some(file) = &mut this.state.lock().log_file { - for event in &events { - json_bytes.clear(); - serde_json::to_writer(&mut json_bytes, event)?; - file.write_all(&json_bytes)?; - file.write_all(b"\n")?; - } - } + let mut json_bytes = Vec::new(); - let request_body = { - let state = this.state.lock(); - - EventRequestBody { - system_id: state.system_id.as_deref().map(Into::into), - installation_id: state.installation_id.as_deref().map(Into::into), - session_id: state.session_id.clone(), - metrics_id: state.metrics_id.as_deref().map(Into::into), - is_staff: state.is_staff, - app_version: state.app_version.clone(), - os_name: state.os_name.clone(), - os_version: state.os_version.clone(), - architecture: state.architecture.to_string(), - - release_channel: state.release_channel.map(Into::into), - events, - } - }; - - let request = this.build_request(json_bytes, &request_body)?; - let response = this.http_client.send(request).await?; - if response.status() != 200 { - log::error!("Failed to send events: HTTP {:?}", response.status()); + if let Some(file) = &mut state.log_file { + for event in &events { + json_bytes.clear(); + serde_json::to_writer(&mut json_bytes, event)?; + file.write_all(&json_bytes)?; + file.write_all(b"\n")?; } - anyhow::Ok(()) } - .log_err() - .map(|_| ()), - ) + + ( + json_bytes, + EventRequestBody { + system_id: state.system_id.as_deref().map(Into::into), + installation_id: state.installation_id.as_deref().map(Into::into), + session_id: state.session_id.clone(), + metrics_id: state.metrics_id.as_deref().map(Into::into), + is_staff: state.is_staff, + app_version: state.app_version.clone(), + os_name: state.os_name.clone(), + os_version: state.os_version.clone(), + architecture: state.architecture.to_string(), + + release_channel: state.release_channel.map(Into::into), + events, + }, + ) + }; + + let request = self.build_request(json_bytes, &request_body)?; + let response = self.http_client.send(request).await?; + if response.status() != 200 { + log::error!("Failed to send events: HTTP {:?}", response.status()); + } + + anyhow::Ok(()) + } + + pub fn flush_events(self: &Arc) -> Task<()> { + let this = self.clone(); + self.executor.spawn(async move { + this.flush_events_inner().await.log_err(); + }) } } @@ -602,6 +688,7 @@ pub fn calculate_json_checksum(json: &impl AsRef<[u8]>) -> Option { mod tests { use super::*; use clock::FakeSystemClock; + use gpui::TestAppContext; use http_client::FakeHttpClient; use std::collections::HashMap; @@ -610,7 +697,10 @@ mod tests { use worktree::{PathChange, ProjectEntryId, WorktreeId}; #[gpui::test] - fn test_telemetry_flush_on_max_queue_size(cx: &mut TestAppContext) { + async fn test_telemetry_flush_on_max_queue_size( + executor: BackgroundExecutor, + cx: &mut TestAppContext, + ) { init_test(cx); let clock = Arc::new(FakeSystemClock::new()); let http = FakeHttpClient::with_200_response(); @@ -618,7 +708,7 @@ mod tests { let installation_id = Some("installation_id".to_string()); let session_id = "session_id".to_string(); - cx.update(|cx| { + let (telemetry, first_date_time, event) = cx.update(|cx| { let telemetry = Telemetry::new(clock.clone(), http, cx); telemetry.state.lock().max_queue_size = 4; @@ -637,6 +727,10 @@ mod tests { event_properties, }; + (telemetry, first_date_time, event) + }); + + cx.update(|_cx| { telemetry.report_event(Event::Flexible(event.clone())); assert_eq!(telemetry.state.lock().events_queue.len(), 1); assert!(telemetry.state.lock().flush_events_task.is_some()); @@ -669,6 +763,12 @@ mod tests { // Adding a 4th event should cause a flush telemetry.report_event(Event::Flexible(event)); + }); + + // Run the spawned flush task to completion + executor.run_until_parked(); + + cx.update(|_cx| { assert!(is_empty_state(&telemetry)); }); } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index da2841c46b39ec0bc6402f92a93492275e36d68f..a2bd81bf94a1c56bfacdd6c38cec38c4763fbbc1 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -189,11 +189,13 @@ tab_switcher.workspace = true task.workspace = true tasks_ui.workspace = true telemetry.workspace = true +telemetry_events.workspace = true terminal_view.workspace = true theme.workspace = true theme_extension.workspace = true theme_selector.workspace = true time.workspace = true +time_format.workspace = true title_bar.workspace = true ztracing.workspace = true tracing.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 99c97d1d45570d680356eab86b8891c9830d978a..16f3987ff483816872491803fe479fec7676183f 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -602,6 +602,7 @@ fn main() { language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); acp_tools::init(cx); + zed::telemetry_log::init(cx); edit_prediction_ui::init(cx); web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3586a931a7d5edeeab2fa365e106c2d618571aff..dfd96e69ee59b1cb15b1927b0e4f2a910cd5756c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -6,6 +6,7 @@ mod migrate; mod open_listener; mod open_url_modal; mod quick_action_bar; +pub mod telemetry_log; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub mod visual_tests; #[cfg(target_os = "windows")] @@ -194,11 +195,6 @@ pub fn init(cx: &mut App) { ); }); }) - .on_action(|_: &zed_actions::OpenTelemetryLog, cx| { - with_active_or_new_workspace(cx, |workspace, window, cx| { - open_telemetry_log_file(workspace, window, cx); - }); - }) .on_action(|&zed_actions::OpenKeymapFile, cx| { with_active_or_new_workspace(cx, |_, window, cx| { open_settings_file( @@ -1224,6 +1220,9 @@ fn initialize_pane( toolbar.add_item(dap_log_item, window, cx); let acp_tools_item = cx.new(|_| acp_tools::AcpToolsToolbarItemView::new()); toolbar.add_item(acp_tools_item, window, cx); + let telemetry_log_item = + cx.new(|cx| telemetry_log::TelemetryLogToolbarItemView::new(window, cx)); + toolbar.add_item(telemetry_log_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); @@ -1982,74 +1981,6 @@ fn open_local_file( } } -fn open_telemetry_log_file( - workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, -) { - const HEADER: &str = concat!( - "// Zed collects anonymous usage data to help us understand how people are using the app.\n", - "// Telemetry can be disabled via the `settings.json` file.\n", - "// Here is the data that has been reported for the current session:\n", - ); - workspace - .with_local_workspace(window, cx, move |workspace, window, cx| { - let app_state = workspace.app_state().clone(); - cx.spawn_in(window, async move |workspace, cx| { - async fn fetch_log_string(app_state: &Arc) -> Option { - let path = client::telemetry::Telemetry::log_file_path(); - app_state.fs.load(&path).await.log_err() - } - - let log = fetch_log_string(&app_state) - .await - .unwrap_or_else(|| "// No data has been collected yet".to_string()); - - const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024; - let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN); - if let Some(newline_offset) = log[start_offset..].find('\n') { - start_offset += newline_offset + 1; - } - let log_suffix = &log[start_offset..]; - let content = format!("{}\n{}", HEADER, log_suffix); - let json = app_state - .languages - .language_for_name("JSON") - .await - .log_err(); - - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project().clone(); - let buffer = project.update(cx, |project, cx| { - project.create_local_buffer(&content, json, false, cx) - }); - let buffer = cx.new(|cx| { - MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into()) - }); - workspace.add_item_to_active_pane( - Box::new(cx.new(|cx| { - let mut editor = - Editor::for_multibuffer(buffer, Some(project), window, cx); - editor.set_read_only(true); - editor.set_breadcrumb_header("Telemetry Log".into()); - editor - })), - None, - true, - window, - cx, - ); - }) - .log_err()?; - - Some(()) - }) - .detach(); - }) - .detach(); -} - fn open_bundled_file( workspace: &Workspace, text: Cow<'static, str>, diff --git a/crates/zed/src/zed/telemetry_log.rs b/crates/zed/src/zed/telemetry_log.rs new file mode 100644 index 0000000000000000000000000000000000000000..06e13ef5d86fb665151b13ce01de5a60def9ba15 --- /dev/null +++ b/crates/zed/src/zed/telemetry_log.rs @@ -0,0 +1,618 @@ +use std::collections::VecDeque; +use std::sync::Arc; + +use time::OffsetDateTime; + +use client::telemetry::Telemetry; +use collections::{HashMap, HashSet}; +use fs::Fs; +use futures::StreamExt; +use gpui::{ + App, Empty, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, + StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*, +}; +use language::LanguageRegistry; +use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle}; +use project::Project; +use settings::Settings; +use telemetry_events::{Event, EventWrapper}; +use theme::ThemeSettings; +use ui::{ + Icon, IconButton, IconName, IconSize, Label, TextSize, Tooltip, WithScrollbar, prelude::*, +}; +use workspace::{ + Item, ItemHandle, Toast, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, + notifications::NotificationId, +}; + +const MAX_EVENTS: usize = 10_000; + +pub fn init(cx: &mut App) { + cx.observe_new( + |workspace: &mut Workspace, _window, _cx: &mut Context| { + workspace.register_action( + |workspace, _: &zed_actions::OpenTelemetryLog, window, cx| { + let telemetry_log = + cx.new(|cx| TelemetryLogView::new(workspace.project().clone(), window, cx)); + + cx.subscribe(&telemetry_log, |workspace, _, event, cx| { + let TelemetryLogEvent::ShowToast(toast) = event; + workspace.show_toast(toast.clone(), cx); + }) + .detach(); + + workspace.add_item_to_active_pane( + Box::new(telemetry_log), + None, + true, + window, + cx, + ); + }, + ); + }, + ) + .detach(); +} + +pub struct TelemetryLogView { + project: Entity, + focus_handle: FocusHandle, + events: VecDeque, + list_state: ListState, + expanded: HashSet, + search_query: String, + filtered_indices: Vec, + _subscription: Task<()>, +} + +struct TelemetryLogEntry { + received_at: OffsetDateTime, + event_type: SharedString, + event_properties: HashMap, + signed_in: bool, + collapsed_md: Option>, + expanded_md: Option>, +} + +impl TelemetryLogEntry { + fn props_as_json_object(&self) -> serde_json::Value { + serde_json::Value::Object( + self.event_properties + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + ) + } +} + +impl TelemetryLogView { + pub fn new(project: Entity, _window: &mut Window, cx: &mut Context) -> Self { + let telemetry = client::Client::global(cx).telemetry().clone(); + let fs = ::global(cx); + + let list_state = ListState::new(0, ListAlignment::Bottom, px(2048.)); + + let subscription = cx.spawn(async move |this, cx| { + let subscription = telemetry.subscribe_with_history(fs).await; + + this.update(cx, |this, cx| { + let historical_events = match subscription.historical_events { + Ok(historical) => { + if historical.parse_error_count > 0 { + this.show_parse_error_toast(historical.parse_error_count, cx); + } + historical.events + } + Err(err) => { + this.show_read_error_toast(&err, cx); + Vec::new() + } + }; + + this.push_events( + historical_events + .into_iter() + .chain(subscription.queued_events), + cx, + ); + }) + .ok(); + + let mut live_events = subscription.live_events; + while let Some(event_wrapper) = live_events.next().await { + let result = this.update(cx, |this, cx| { + this.push_event(event_wrapper, cx); + }); + if result.is_err() { + break; + } + } + }); + + Self { + project, + focus_handle: cx.focus_handle(), + events: VecDeque::with_capacity(MAX_EVENTS), + list_state, + expanded: HashSet::default(), + search_query: String::new(), + filtered_indices: Vec::new(), + _subscription: subscription, + } + } + + fn event_wrapper_to_entry( + event_wrapper: &EventWrapper, + language_registry: &Arc, + cx: &mut App, + ) -> TelemetryLogEntry { + let (event_type, std_event_properties): ( + SharedString, + std::collections::HashMap, + ) = match &event_wrapper.event { + Event::Flexible(flexible) => ( + flexible.event_type.clone().into(), + flexible.event_properties.clone(), + ), + }; + + let event_properties: HashMap = + std_event_properties.into_iter().collect(); + + let entry = TelemetryLogEntry { + received_at: OffsetDateTime::now_utc(), + event_type, + event_properties, + signed_in: event_wrapper.signed_in, + collapsed_md: None, + expanded_md: None, + }; + + let collapsed_md = if !entry.event_properties.is_empty() { + Some(collapsed_params_md( + &entry.props_as_json_object(), + language_registry, + cx, + )) + } else { + None + }; + + TelemetryLogEntry { + collapsed_md, + ..entry + } + } + + fn push_event(&mut self, event_wrapper: EventWrapper, cx: &mut Context) { + self.push_events(std::iter::once(event_wrapper), cx); + } + + fn push_events( + &mut self, + event_wrappers: impl Iterator, + cx: &mut Context, + ) { + let language_registry = self.project.read(cx).languages().clone(); + + for event_wrapper in event_wrappers { + let entry = Self::event_wrapper_to_entry(&event_wrapper, &language_registry, cx); + self.events.push_back(entry); + } + + while self.events.len() > MAX_EVENTS { + self.events.pop_front(); + } + + self.expanded.retain(|&idx| idx < self.events.len()); + + self.recompute_filtered_indices(); + cx.notify(); + } + + fn entry_matches_filter(&self, entry: &TelemetryLogEntry) -> bool { + if self.search_query.is_empty() { + return true; + } + + let query_lower = self.search_query.to_lowercase(); + + if entry.event_type.to_lowercase().contains(&query_lower) { + return true; + } + + for (key, value) in &entry.event_properties { + if key.to_lowercase().contains(&query_lower) { + return true; + } + let value_str = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + if value_str.to_lowercase().contains(&query_lower) { + return true; + } + } + + false + } + + fn recompute_filtered_indices(&mut self) { + self.filtered_indices.clear(); + for (idx, entry) in self.events.iter().enumerate() { + if self.entry_matches_filter(entry) { + self.filtered_indices.push(idx); + } + } + self.list_state.reset(self.filtered_indices.len()); + } + + pub fn set_search_query(&mut self, query: String, cx: &mut Context) { + self.search_query = query; + self.recompute_filtered_indices(); + cx.notify(); + } + + fn clear_events(&mut self, cx: &mut Context) { + self.events.clear(); + self.expanded.clear(); + self.filtered_indices.clear(); + self.list_state.reset(0); + cx.notify(); + } + + fn show_read_error_toast(&self, error: &anyhow::Error, cx: &mut Context) { + struct TelemetryLogReadError; + cx.emit(TelemetryLogEvent::ShowToast(Toast::new( + NotificationId::unique::(), + format!("Failed to read telemetry log: {}", error), + ))); + } + + fn show_parse_error_toast(&self, count: usize, cx: &mut Context) { + struct TelemetryLogParseError; + let message = if count == 1 { + "1 telemetry log entry failed to parse".to_string() + } else { + format!("{} telemetry log entries failed to parse", count) + }; + cx.emit(TelemetryLogEvent::ShowToast(Toast::new( + NotificationId::unique::(), + message, + ))); + } + + fn render_entry( + &mut self, + filtered_index: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(&event_index) = self.filtered_indices.get(filtered_index) else { + return Empty.into_any(); + }; + + let Some(entry) = self.events.get(event_index) else { + return Empty.into_any(); + }; + + let base_size = TextSize::Editor.rems(cx); + let text_style = window.text_style(); + let theme = cx.theme().clone(); + let colors = theme.colors(); + let border_color = colors.border; + let element_background = colors.element_background; + let selection_background_color = colors.element_selection_background; + let syntax = theme.syntax().clone(); + let expanded = self.expanded.contains(&event_index); + + let local_timezone = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let timestamp_str = time_format::format_localized_timestamp( + entry.received_at, + OffsetDateTime::now_utc(), + local_timezone, + time_format::TimestampFormat::EnhancedAbsolute, + ); + + let event_type = entry.event_type.clone(); + let signed_in = entry.signed_in; + + let collapsed_md = entry.collapsed_md.clone(); + + let expanded_md = + if expanded && entry.expanded_md.is_none() && !entry.event_properties.is_empty() { + let language_registry = self.project.read(cx).languages().clone(); + let md = expanded_params_md(&entry.props_as_json_object(), &language_registry, cx); + if let Some(entry_mut) = self.events.get_mut(event_index) { + entry_mut.expanded_md = Some(md.clone()); + } + Some(md) + } else if expanded { + self.events + .get(event_index) + .and_then(|e| e.expanded_md.clone()) + } else { + None + }; + + let params_md = if expanded { expanded_md } else { collapsed_md }; + + let theme_settings = ThemeSettings::get_global(cx); + let buffer_font_family = theme_settings.buffer_font.family.clone(); + + v_flex() + .id(filtered_index) + .group("telemetry-entry") + .cursor_pointer() + .font_buffer(cx) + .w_full() + .py_3() + .pl_4() + .pr_5() + .gap_2() + .items_start() + .text_size(base_size) + .border_color(border_color) + .border_b_1() + .hover(|this| this.bg(element_background.opacity(0.5))) + .on_click(cx.listener(move |this, _, _, cx| { + if this.expanded.contains(&event_index) { + this.expanded.remove(&event_index); + } else { + this.expanded.insert(event_index); + if let Some(filtered_idx) = this + .filtered_indices + .iter() + .position(|&idx| idx == event_index) + { + this.list_state.scroll_to_reveal_item(filtered_idx); + } + } + cx.notify() + })) + .child( + h_flex() + .w_full() + .gap_2() + .flex_shrink_0() + .child( + Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child( + Label::new(timestamp_str) + .buffer_font(cx) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child(Label::new(event_type).buffer_font(cx).color(Color::Default)) + .child(div().flex_1()) + .when(signed_in, |this| { + this.child( + div() + .child(ui::Chip::new("signed in")) + .visible_on_hover("telemetry-entry"), + ) + }), + ) + .when_some(params_md, |this, params| { + this.child( + div().pl_6().w_full().child( + MarkdownElement::new( + params, + MarkdownStyle { + base_text_style: text_style, + selection_background_color, + syntax: syntax.clone(), + code_block_overflow_x_scroll: expanded, + code_block: StyleRefinement { + text: TextStyleRefinement { + font_family: Some(buffer_font_family.clone()), + font_size: Some((base_size * 0.8).into()), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ) + .code_block_renderer(CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: expanded, + border: false, + }), + ), + ) + }) + .into_any() + } +} + +fn collapsed_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string(params).unwrap_or_default(); + let mut spaced_out_json = String::with_capacity(params_json.len() + params_json.len() / 4); + + for ch in params_json.chars() { + match ch { + '{' => spaced_out_json.push_str("{ "), + '}' => spaced_out_json.push_str(" }"), + ':' => spaced_out_json.push_str(": "), + ',' => spaced_out_json.push_str(", "), + c => spaced_out_json.push(c), + } + } + + let params_md = format!("```json\n{}\n```", spaced_out_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +fn expanded_params_md( + params: &serde_json::Value, + language_registry: &Arc, + cx: &mut App, +) -> Entity { + let params_json = serde_json::to_string_pretty(params).unwrap_or_default(); + let params_md = format!("```json\n{}\n```", params_json); + cx.new(|cx| Markdown::new(params_md.into(), Some(language_registry.clone()), None, cx)) +} + +pub enum TelemetryLogEvent { + ShowToast(Toast), +} + +impl EventEmitter for TelemetryLogView {} + +impl Item for TelemetryLogView { + type Event = TelemetryLogEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "Telemetry Log".into() + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::Sparkle)) + } +} + +impl Focusable for TelemetryLogView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for TelemetryLogView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .bg(cx.theme().colors().editor_background) + .child(if self.filtered_indices.is_empty() { + h_flex() + .size_full() + .justify_center() + .items_center() + .child(if self.events.is_empty() { + "No telemetry events recorded yet" + } else { + "No events match the current filter" + }) + .into_any() + } else { + div() + .size_full() + .flex_grow() + .child( + list(self.list_state.clone(), cx.processor(Self::render_entry)) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .size_full(), + ) + .vertical_scrollbar_for(&self.list_state, window, cx) + .into_any() + }) + } +} + +pub struct TelemetryLogToolbarItemView { + telemetry_log: Option>, + search_editor: Entity, +} + +impl TelemetryLogToolbarItemView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search_editor = cx.new(|cx| { + let mut editor = editor::Editor::single_line(window, cx); + editor.set_placeholder_text("Filter events...", window, cx); + editor + }); + + cx.subscribe( + &search_editor, + |this, editor, event: &editor::EditorEvent, cx| { + if let editor::EditorEvent::BufferEdited { .. } = event { + let query = editor.read(cx).text(cx); + if let Some(telemetry_log) = &this.telemetry_log { + telemetry_log.update(cx, |log, cx| { + log.set_search_query(query, cx); + }); + } + } + }, + ) + .detach(); + + Self { + telemetry_log: None, + search_editor, + } + } +} + +impl Render for TelemetryLogToolbarItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(telemetry_log) = self.telemetry_log.as_ref() else { + return Empty.into_any_element(); + }; + + let telemetry_log_clone = telemetry_log.clone(); + let has_events = !telemetry_log.read(cx).events.is_empty(); + + h_flex() + .gap_2() + .child(div().w(px(200.)).child(self.search_editor.clone())) + .child( + IconButton::new("clear_events", IconName::Trash) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Clear Events")) + .disabled(!has_events) + .on_click(cx.listener(move |_this, _, _window, cx| { + telemetry_log_clone.update(cx, |log, cx| { + log.clear_events(cx); + }); + })), + ) + .child( + IconButton::new("open_log_file", IconName::File) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Open Raw Log File")) + .on_click(|_, _window, cx| { + let path = Telemetry::log_file_path(); + cx.open_url(&format!("file://{}", path.display())); + }), + ) + .into_any() + } +} + +impl EventEmitter for TelemetryLogToolbarItemView {} + +impl ToolbarItemView for TelemetryLogToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> ToolbarItemLocation { + if let Some(item) = active_pane_item + && let Some(telemetry_log) = item.downcast::() + { + self.telemetry_log = Some(telemetry_log); + cx.notify(); + return ToolbarItemLocation::PrimaryRight; + } + if self.telemetry_log.take().is_some() { + cx.notify(); + } + ToolbarItemLocation::Hidden + } +} diff --git a/docs/telemetry-log-revamp-plan.md b/docs/telemetry-log-revamp-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..22839e40fee5c4d16074134e754f8b1c1ca56343 --- /dev/null +++ b/docs/telemetry-log-revamp-plan.md @@ -0,0 +1,306 @@ +# Telemetry Log View Revamp Plan + +## Overview + +This document outlines the plan to revamp the telemetry log view to make it more useful for project managers and developers who need to understand what telemetry data Zed is collecting and sending. + +## Current State + +### How Telemetry Works Today + +1. **Event Creation**: Events are created throughout the codebase via `telemetry::event!()` macro (~51 unique event types) +2. **Event Flow**: Events are sent through an unbounded channel to `Telemetry::report_event()` +3. **Queuing**: Events are queued until: + - Queue reaches max size (5 in debug, 50 in release) + - Flush interval elapses (1 second in debug, 5 minutes in release) +4. **Logging**: On flush, events are written to `/telemetry.log` as JSON (one `EventWrapper` per line) +5. **Sending**: Events are sent to the server in an `EventRequestBody` + +### Current Telemetry Log View (`open_telemetry_log_file`) + +Location: `crates/zed/src/zed.rs:1985-2050` + +**Problems:** + +- **No live updates**: Reads file once when opened, creates a local buffer +- **Raw JSON**: Hard to read, especially for non-developers +- **No filtering**: Shows all events, no way to filter by type +- **Missing context**: Log file only contains `EventWrapper` (no session/system metadata) +- **Truncation**: Only shows last 5MB of log file + +## Goals + +1. **Live updates**: Show new telemetry events as they happen +2. **Better formatting**: Human-readable display with collapsible JSON +3. **Filtering**: Filter by event type to focus on specific categories (e.g., Agent events) + +## Proposed Solution + +### Architecture + +Create a new `TelemetryLogView` workspace item (similar to `AcpTools`) that subscribes to telemetry events in real-time. + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Telemetry Flow │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ telemetry::event!() ──► mpsc channel ──► Telemetry::report_event()│ +│ │ │ │ +│ │ ▼ │ +│ │ events_queue │ +│ │ │ │ +│ │ ▼ │ +│ │ flush_events() │ +│ │ │ │ +│ │ ┌────────┴────────┐ │ +│ │ ▼ ▼ │ +│ │ telemetry.log HTTP POST │ +│ │ │ +│ ▼ │ +│ TelemetryLogView (NEW) │ +│ - Real-time display │ +│ - Filtering │ +│ - Pretty formatting │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Chosen Approach: Broadcast Channel with Synchronized Initialization + +Modify the telemetry system to support subscribers, with a careful synchronization strategy to ensure no events are lost when opening the view. + +#### Synchronization Strategy + +When the user opens the telemetry log view, we need to capture: + +1. Historical events (already flushed to disk) +2. Queued events (in memory, not yet flushed) +3. Future events (arriving after we subscribe) + +**The synchronization sequence:** + +``` +User opens telemetry log view + │ + ▼ + ┌─────────────────────────────────────┐ + │ TAKE STATE LOCK │ + ├─────────────────────────────────────┤ + │ 1. Read historical data from │ + │ telemetry.log file │ + │ │ + │ 2. Read out the unflushed │ + │ events_queue (clone events) │ + │ │ + │ 3. Hook up broadcast channel │ + │ to receive live events │ + ├─────────────────────────────────────┤ + │ DROP STATE LOCK │ + └─────────────────────────────────────┘ + │ + ▼ + View is now synchronized + - Has all historical events + - Has all queued events + - Will receive all future events +``` + +This lock-based approach ensures atomicity: no events can be added to the queue or flushed while we're setting up, guaranteeing we don't miss or duplicate any events. + +#### Implementation + +```rust +// In crates/client/src/telemetry.rs + +struct TelemetryState { + // ... existing fields ... + settings: TelemetrySettings, + events_queue: Vec, + log_file: Option, + // ... etc ... + + /// Subscribers receiving live event updates (new field) + subscribers: Vec>, +} + +impl Telemetry { + /// Subscribe to telemetry events with full history. + /// Returns historical events and a channel for live events. + /// + /// The state lock is held during this operation to ensure no events + /// are lost between reading history and subscribing to live events. + pub fn subscribe_with_history( + self: &Arc, + ) -> (Vec, mpsc::UnboundedReceiver) { + let mut state = self.state.lock(); + + // 1. Read historical events from log file + let historical = Self::read_log_file(); + + // 2. Clone the unflushed queue + let queued: Vec = state.events_queue.clone(); + + // 3. Set up broadcast channel (stored on state, not static) + let (tx, rx) = mpsc::unbounded(); + state.subscribers.push(tx); + + // Combine historical + queued + let mut all_events = historical; + all_events.extend(queued); + + (all_events, rx) + } + + fn read_log_file() -> Vec { + let path = Self::log_file_path(); + // Read last 5MB of file (same limit as current implementation) + // Parse each line as EventWrapper JSON + // ... implementation details ... + } + + // Modified report_event to broadcast to subscribers + fn report_event(self: &Arc, event: Event) { + let mut state = self.state.lock(); + + // ... existing queue logic ... + + // Broadcast to subscribers (accessing field on state) + state.subscribers.retain(|tx| { + tx.unbounded_send(event_wrapper.clone()).is_ok() + }); + } +} +``` + +## Implementation Plan + +### Phase 1: Core Infrastructure + +**Files to create:** + +- `crates/zed/src/telemetry_log.rs` - View implementation in the zed crate + +**Files to modify:** + +- `crates/client/src/telemetry.rs` - Add subscriber support and broadcast mechanism +- `crates/zed/src/zed.rs` - Register new action and view, replace existing `open_telemetry_log_file` + +**Tasks:** + +1. Add `subscribers` field to `TelemetryState` and broadcast mechanism +2. Implement `subscribe_with_history` on `Telemetry` +3. Create `TelemetryLogView` in zed crate + +### Phase 2: View Implementation + +**Reference:** `crates/acp_tools/src/acp_tools.rs` + +**Components:** + +1. `TelemetryLogView` - Main view struct implementing `Item`, `Render`, `Focusable` +2. `TelemetryLogToolbarItemView` - Toolbar with filter controls +3. `TelemetryLogEntry` - Individual event display + +**Key features to implement:** + +```rust +const MAX_EVENTS: usize = 10_000; + +struct TelemetryLogView { + focus_handle: FocusHandle, + events: VecDeque, // Bounded to MAX_EVENTS + list_state: ListState, + expanded: HashSet, + search_query: String, // Text search filter + _subscription: Task<()>, +} + +struct TelemetryLogEntry { + received_at: Instant, // For "4s ago" display + event_type: SharedString, + event_properties: HashMap, + signed_in: bool, + collapsed_md: Option>, + expanded_md: Option>, +} +``` + +### Phase 3: Filtering UI + +**Toolbar components:** + +1. **Search input** - Text search within event type and properties +2. **Clear button** - Clear displayed events +3. **Open log file button** - Open the raw `telemetry.log` file + +### Phase 4: Polish & Integration + +1. **Keyboard shortcuts**: Add keybinding for opening telemetry log + +## Data Model + +### TelemetryLogEntry (displayed in view) + +```rust +pub struct TelemetryLogEntry { + /// When the event was received (local time) + pub received_at: DateTime, + + /// The event type name (e.g., "Agent Message Sent") + pub event_type: String, + + /// Event properties as key-value pairs + pub properties: HashMap, + + /// Whether user was signed in when event fired + pub signed_in: bool, +} +``` + +### Display Format + +**Timestamps:** Show relative time (e.g., "4s ago") with exact timestamp in tooltip on hover. + +**Collapsed view (one line per event):** + +``` +▼ 4s ago Agent Message Sent + { agent: "claude-code", session: "abc123", message_count: 5 } + +▶ 6s ago Editor Edited +``` + +**Expanded view (click to expand):** + +``` +▼ 4s ago Agent Message Sent + { + "agent": "claude-code", + "session": "abc123", + "message_count": 5, + "thread_id": "thread_xyz..." + } +``` + +## File Structure + +``` +crates/ +├── client/ +│ └── src/ +│ └── telemetry.rs # Add subscribers field and subscribe_with_history() +│ +└── zed/ + └── src/ + ├── zed.rs # Register OpenTelemetryLog action (replace existing) + └── telemetry_log.rs # NEW: View implementation +``` + +## Testing Strategy + +1. **Unit tests**: Test search filtering logic, event parsing +2. **Integration tests**: Test subscription mechanism +3. **Manual testing**: + - Open view, trigger various actions, verify events appear + - Test search filtering + - Test with high event volume (rapid actions)