1mod event_coalescer;
2
3use crate::TelemetrySettings;
4use anyhow::{Context as _, Result};
5use clock::SystemClock;
6use fs::Fs;
7use futures::channel::mpsc;
8use futures::{Future, StreamExt};
9use gpui::{App, AppContext as _, BackgroundExecutor, Task};
10use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request};
11use parking_lot::Mutex;
12use regex::Regex;
13use release_channel::ReleaseChannel;
14use settings::{Settings, SettingsStore};
15use sha2::{Digest, Sha256};
16use std::collections::HashSet;
17use std::fs::File;
18use std::io::Write;
19use std::sync::LazyLock;
20use std::time::Instant;
21use std::{env, mem, path::PathBuf, sync::Arc, time::Duration};
22use telemetry_events::{AssistantEventData, AssistantPhase, Event, EventRequestBody, EventWrapper};
23
24pub struct TelemetrySubscription {
25 pub historical_events: Result<HistoricalEvents>,
26 pub queued_events: Vec<EventWrapper>,
27 pub live_events: mpsc::UnboundedReceiver<EventWrapper>,
28}
29
30pub struct HistoricalEvents {
31 pub events: Vec<EventWrapper>,
32 pub parse_error_count: usize,
33}
34use util::ResultExt as _;
35use worktree::{UpdatedEntriesSet, WorktreeId};
36
37use self::event_coalescer::EventCoalescer;
38
39pub struct Telemetry {
40 clock: Arc<dyn SystemClock>,
41 http_client: Arc<HttpClientWithUrl>,
42 executor: BackgroundExecutor,
43 state: Arc<Mutex<TelemetryState>>,
44}
45
46struct TelemetryState {
47 settings: TelemetrySettings,
48 system_id: Option<Arc<str>>, // Per system
49 installation_id: Option<Arc<str>>, // Per app installation (different for dev, nightly, preview, and stable)
50 session_id: Option<String>, // Per app launch
51 metrics_id: Option<Arc<str>>, // Per logged-in user
52 release_channel: Option<&'static str>,
53 architecture: &'static str,
54 events_queue: Vec<EventWrapper>,
55 flush_events_task: Option<Task<()>>,
56
57 log_file: Option<File>,
58 is_staff: Option<bool>,
59 first_event_date_time: Option<Instant>,
60 event_coalescer: EventCoalescer,
61 max_queue_size: usize,
62 worktrees_with_project_type_events_sent: HashSet<WorktreeId>,
63
64 os_name: String,
65 app_version: String,
66 os_version: Option<String>,
67
68 subscribers: Vec<mpsc::UnboundedSender<EventWrapper>>,
69}
70
71#[cfg(debug_assertions)]
72const MAX_QUEUE_LEN: usize = 5;
73
74#[cfg(not(debug_assertions))]
75const MAX_QUEUE_LEN: usize = 50;
76
77#[cfg(debug_assertions)]
78const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
79
80#[cfg(not(debug_assertions))]
81const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5);
82static ZED_CLIENT_CHECKSUM_SEED: LazyLock<Option<Vec<u8>>> = LazyLock::new(|| {
83 option_env!("ZED_CLIENT_CHECKSUM_SEED")
84 .map(|s| s.as_bytes().into())
85 .or_else(|| {
86 env::var("ZED_CLIENT_CHECKSUM_SEED")
87 .ok()
88 .map(|s| s.as_bytes().into())
89 })
90});
91
92pub static MINIDUMP_ENDPOINT: LazyLock<Option<String>> = LazyLock::new(|| {
93 option_env!("ZED_MINIDUMP_ENDPOINT")
94 .map(str::to_string)
95 .or_else(|| env::var("ZED_MINIDUMP_ENDPOINT").ok())
96});
97
98static DOTNET_PROJECT_FILES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
99 Regex::new(r"^(global\.json|Directory\.Build\.props|.*\.(csproj|fsproj|vbproj|sln))$").unwrap()
100});
101
102#[cfg(target_os = "macos")]
103static MACOS_VERSION_REGEX: LazyLock<Regex> =
104 LazyLock::new(|| Regex::new(r"(\s*\(Build [^)]*[0-9]\))").unwrap());
105
106pub fn os_name() -> String {
107 #[cfg(target_os = "macos")]
108 {
109 "macOS".to_string()
110 }
111 #[cfg(target_os = "linux")]
112 {
113 format!("Linux {}", gpui::guess_compositor())
114 }
115 #[cfg(target_os = "freebsd")]
116 {
117 format!("FreeBSD {}", gpui::guess_compositor())
118 }
119
120 #[cfg(target_os = "windows")]
121 {
122 "Windows".to_string()
123 }
124}
125
126/// Note: This might do blocking IO! Only call from background threads
127pub fn os_version() -> String {
128 #[cfg(target_os = "macos")]
129 {
130 use objc2_foundation::NSProcessInfo;
131 let process_info = NSProcessInfo::processInfo();
132 let version_nsstring = unsafe { process_info.operatingSystemVersionString() };
133 // "Version 15.6.1 (Build 24G90)" -> "15.6.1 (Build 24G90)"
134 let version_string = version_nsstring.to_string().replace("Version ", "");
135 // "15.6.1 (Build 24G90)" -> "15.6.1"
136 // "26.0.0 (Build 25A5349a)" -> unchanged (Beta or Rapid Security Response; ends with letter)
137 MACOS_VERSION_REGEX
138 .replace_all(&version_string, "")
139 .to_string()
140 }
141 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
142 {
143 use std::path::Path;
144
145 let content = if let Ok(file) = std::fs::read_to_string(&Path::new("/etc/os-release")) {
146 file
147 } else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) {
148 file
149 } else if let Ok(file) = std::fs::read_to_string(&Path::new("/var/run/os-release")) {
150 file
151 } else {
152 log::error!(
153 "Failed to load /etc/os-release, /usr/lib/os-release, or /var/run/os-release"
154 );
155 "".to_string()
156 };
157 let mut name = "unknown";
158 let mut version = "unknown";
159
160 for line in content.lines() {
161 match line.split_once('=') {
162 Some(("ID", val)) => name = val.trim_matches('"'),
163 Some(("VERSION_ID", val)) => version = val.trim_matches('"'),
164 _ => {}
165 }
166 }
167
168 format!("{} {}", name, version)
169 }
170
171 #[cfg(target_os = "windows")]
172 {
173 let mut info = unsafe { std::mem::zeroed() };
174 let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) };
175 if status.is_ok() {
176 semver::Version::new(
177 info.dwMajorVersion as _,
178 info.dwMinorVersion as _,
179 info.dwBuildNumber as _,
180 )
181 .to_string()
182 } else {
183 "unknown".to_string()
184 }
185 }
186}
187
188impl Telemetry {
189 pub fn new(
190 clock: Arc<dyn SystemClock>,
191 client: Arc<HttpClientWithUrl>,
192 cx: &mut App,
193 ) -> Arc<Self> {
194 let release_channel =
195 ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
196
197 let state = Arc::new(Mutex::new(TelemetryState {
198 settings: *TelemetrySettings::get_global(cx),
199 architecture: env::consts::ARCH,
200 release_channel,
201 system_id: None,
202 installation_id: None,
203 session_id: None,
204 metrics_id: None,
205 events_queue: Vec::new(),
206 flush_events_task: None,
207 log_file: None,
208 is_staff: None,
209 first_event_date_time: None,
210 event_coalescer: EventCoalescer::new(clock.clone()),
211 max_queue_size: MAX_QUEUE_LEN,
212 worktrees_with_project_type_events_sent: HashSet::new(),
213
214 os_version: None,
215 os_name: os_name(),
216 app_version: release_channel::AppVersion::global(cx).to_string(),
217 subscribers: Vec::new(),
218 }));
219
220 cx.background_spawn({
221 let state = state.clone();
222 let os_version = os_version();
223 state.lock().os_version = Some(os_version);
224 async move {
225 if let Some(tempfile) = File::create(Self::log_file_path()).ok() {
226 state.lock().log_file = Some(tempfile);
227 }
228 }
229 })
230 .detach();
231
232 cx.observe_global::<SettingsStore>({
233 let state = state.clone();
234
235 move |cx| {
236 let mut state = state.lock();
237 state.settings = *TelemetrySettings::get_global(cx);
238 }
239 })
240 .detach();
241
242 let this = Arc::new(Self {
243 clock,
244 http_client: client,
245 executor: cx.background_executor().clone(),
246 state,
247 });
248
249 let (tx, mut rx) = mpsc::unbounded();
250 ::telemetry::init(tx);
251
252 cx.background_spawn({
253 let this = Arc::downgrade(&this);
254 async move {
255 while let Some(event) = rx.next().await {
256 let Some(state) = this.upgrade() else { break };
257 state.report_event(Event::Flexible(event))
258 }
259 }
260 })
261 .detach();
262
263 // We should only ever have one instance of Telemetry, leak the subscription to keep it alive
264 // rather than store in TelemetryState, complicating spawn as subscriptions are not Send
265 std::mem::forget(cx.on_app_quit({
266 let this = this.clone();
267 move |_| this.shutdown_telemetry()
268 }));
269
270 this
271 }
272
273 #[cfg(any(test, feature = "test-support"))]
274 fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
275 Task::ready(())
276 }
277
278 // Skip calling this function in tests.
279 // TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings
280 #[cfg(not(any(test, feature = "test-support")))]
281 fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> + use<> {
282 telemetry::event!("App Closed");
283 // TODO: close final edit period and make sure it's sent
284 Task::ready(())
285 }
286
287 pub fn log_file_path() -> PathBuf {
288 paths::logs_dir().join("telemetry.log")
289 }
290
291 pub async fn subscribe_with_history(
292 self: &Arc<Self>,
293 fs: Arc<dyn Fs>,
294 ) -> TelemetrySubscription {
295 let historical_events = self.read_log_file(fs).await;
296
297 let mut state = self.state.lock();
298 let queued_events: Vec<EventWrapper> = state.events_queue.clone();
299
300 let (tx, rx) = mpsc::unbounded();
301 state.subscribers.push(tx);
302
303 drop(state);
304
305 TelemetrySubscription {
306 historical_events,
307 queued_events,
308 live_events: rx,
309 }
310 }
311
312 async fn read_log_file(self: &Arc<Self>, fs: Arc<dyn Fs>) -> anyhow::Result<HistoricalEvents> {
313 const MAX_LOG_READ: usize = 5 * 1024 * 1024;
314
315 let path = Self::log_file_path();
316
317 let content = fs
318 .load_bytes(&path)
319 .await
320 .with_context(|| format!("failed to load telemetry log from {:?}", path))?;
321
322 let start_offset = if content.len() > MAX_LOG_READ {
323 let skip = content.len() - MAX_LOG_READ;
324 content[skip..]
325 .iter()
326 .position(|&b| b == b'\n')
327 .map(|pos| skip + pos + 1)
328 .unwrap_or(skip)
329 } else {
330 0
331 };
332
333 let content_str = std::str::from_utf8(&content[start_offset..])
334 .context("telemetry log file contains invalid UTF-8")?;
335
336 let mut events = Vec::new();
337 let mut parse_error_count = 0;
338
339 for line in content_str.lines() {
340 if line.trim().is_empty() {
341 continue;
342 }
343 match serde_json::from_str::<EventWrapper>(line) {
344 Ok(event) => events.push(event),
345 Err(_) => parse_error_count += 1,
346 }
347 }
348
349 Ok(HistoricalEvents {
350 events,
351 parse_error_count,
352 })
353 }
354
355 pub fn has_checksum_seed(&self) -> bool {
356 ZED_CLIENT_CHECKSUM_SEED.is_some()
357 }
358
359 pub fn start(
360 self: &Arc<Self>,
361 system_id: Option<String>,
362 installation_id: Option<String>,
363 session_id: String,
364 cx: &App,
365 ) {
366 let mut state = self.state.lock();
367 state.system_id = system_id.map(|id| id.into());
368 state.installation_id = installation_id.map(|id| id.into());
369 state.session_id = Some(session_id);
370 state.app_version = release_channel::AppVersion::global(cx).to_string();
371 state.os_name = os_name();
372 }
373
374 pub fn metrics_enabled(self: &Arc<Self>) -> bool {
375 self.state.lock().settings.metrics
376 }
377
378 pub fn diagnostics_enabled(self: &Arc<Self>) -> bool {
379 self.state.lock().settings.diagnostics
380 }
381
382 pub fn set_authenticated_user_info(
383 self: &Arc<Self>,
384 metrics_id: Option<String>,
385 is_staff: bool,
386 ) {
387 let mut state = self.state.lock();
388
389 if !state.settings.metrics {
390 return;
391 }
392
393 let metrics_id: Option<Arc<str>> = metrics_id.map(|id| id.into());
394 state.metrics_id.clone_from(&metrics_id);
395 state.is_staff = Some(is_staff);
396 drop(state);
397 }
398
399 pub fn report_assistant_event(self: &Arc<Self>, event: AssistantEventData) {
400 let event_type = match event.phase {
401 AssistantPhase::Response => "Assistant Responded",
402 AssistantPhase::Invoked => "Assistant Invoked",
403 AssistantPhase::Accepted => "Assistant Response Accepted",
404 AssistantPhase::Rejected => "Assistant Response Rejected",
405 };
406
407 telemetry::event!(
408 event_type,
409 conversation_id = event.conversation_id,
410 kind = event.kind,
411 phase = event.phase,
412 message_id = event.message_id,
413 model = event.model,
414 model_provider = event.model_provider,
415 response_latency = event.response_latency,
416 error_message = event.error_message,
417 language_name = event.language_name,
418 );
419 }
420
421 pub fn log_edit_event(self: &Arc<Self>, environment: &'static str, is_via_ssh: bool) {
422 static LAST_EVENT_TIME: Mutex<Option<Instant>> = Mutex::new(None);
423
424 let mut state = self.state.lock();
425 let period_data = state.event_coalescer.log_event(environment);
426 drop(state);
427
428 if let Some(mut last_event) = LAST_EVENT_TIME.try_lock() {
429 let current_time = std::time::Instant::now();
430 let last_time = last_event.get_or_insert(current_time);
431
432 if current_time.duration_since(*last_time) > Duration::from_secs(60 * 10) {
433 *last_time = current_time;
434 } else {
435 return;
436 }
437
438 if let Some((start, end, environment)) = period_data {
439 let duration = end
440 .saturating_duration_since(start)
441 .min(Duration::from_secs(60 * 60 * 24))
442 .as_millis() as i64;
443
444 telemetry::event!(
445 "Editor Edited",
446 duration = duration,
447 environment = environment,
448 is_via_ssh = is_via_ssh
449 );
450 }
451 }
452 }
453
454 pub fn report_discovered_project_type_events(
455 self: &Arc<Self>,
456 worktree_id: WorktreeId,
457 updated_entries_set: &UpdatedEntriesSet,
458 ) {
459 let Some(project_types) = self.detect_project_types(worktree_id, updated_entries_set)
460 else {
461 return;
462 };
463
464 for project_type in project_types {
465 telemetry::event!("Project Opened", project_type = project_type);
466 }
467 }
468
469 fn detect_project_types(
470 self: &Arc<Self>,
471 worktree_id: WorktreeId,
472 updated_entries_set: &UpdatedEntriesSet,
473 ) -> Option<Vec<String>> {
474 let mut state = self.state.lock();
475
476 if state
477 .worktrees_with_project_type_events_sent
478 .contains(&worktree_id)
479 {
480 return None;
481 }
482
483 let mut project_types: HashSet<&str> = HashSet::new();
484
485 for (path, _, _) in updated_entries_set.iter() {
486 let Some(file_name) = path.file_name() else {
487 continue;
488 };
489
490 let project_type = if file_name == "pnpm-lock.yaml" {
491 Some("pnpm")
492 } else if file_name == "yarn.lock" {
493 Some("yarn")
494 } else if file_name == "package.json" {
495 Some("node")
496 } else if DOTNET_PROJECT_FILES_REGEX.is_match(file_name) {
497 Some("dotnet")
498 } else {
499 None
500 };
501
502 if let Some(project_type) = project_type {
503 project_types.insert(project_type);
504 };
505 }
506
507 if !project_types.is_empty() {
508 state
509 .worktrees_with_project_type_events_sent
510 .insert(worktree_id);
511 }
512
513 let mut project_types: Vec<_> = project_types.into_iter().map(String::from).collect();
514 project_types.sort();
515 Some(project_types)
516 }
517
518 fn report_event(self: &Arc<Self>, mut event: Event) {
519 let mut state = self.state.lock();
520 // RUST_LOG=telemetry=trace to debug telemetry events
521 log::trace!(target: "telemetry", "{:?}", event);
522
523 if !state.settings.metrics {
524 return;
525 }
526
527 match &mut event {
528 Event::Flexible(event) => event
529 .event_properties
530 .insert("event_source".into(), "zed".into()),
531 };
532
533 if state.flush_events_task.is_none() {
534 let this = self.clone();
535 state.flush_events_task = Some(self.executor.spawn(async move {
536 this.executor.timer(FLUSH_INTERVAL).await;
537 this.flush_events().detach();
538 }));
539 }
540
541 let date_time = self.clock.utc_now();
542
543 let milliseconds_since_first_event = match state.first_event_date_time {
544 Some(first_event_date_time) => date_time
545 .saturating_duration_since(first_event_date_time)
546 .min(Duration::from_secs(60 * 60 * 24))
547 .as_millis() as i64,
548 None => {
549 state.first_event_date_time = Some(date_time);
550 0
551 }
552 };
553
554 let signed_in = state.metrics_id.is_some();
555 let event_wrapper = EventWrapper {
556 signed_in,
557 milliseconds_since_first_event,
558 event,
559 };
560
561 state
562 .subscribers
563 .retain(|tx| tx.unbounded_send(event_wrapper.clone()).is_ok());
564
565 state.events_queue.push(event_wrapper);
566
567 if state.installation_id.is_some() && state.events_queue.len() >= state.max_queue_size {
568 drop(state);
569 self.flush_events().detach();
570 }
571 }
572
573 pub fn metrics_id(self: &Arc<Self>) -> Option<Arc<str>> {
574 self.state.lock().metrics_id.clone()
575 }
576
577 pub fn system_id(self: &Arc<Self>) -> Option<Arc<str>> {
578 self.state.lock().system_id.clone()
579 }
580
581 pub fn installation_id(self: &Arc<Self>) -> Option<Arc<str>> {
582 self.state.lock().installation_id.clone()
583 }
584
585 pub fn is_staff(self: &Arc<Self>) -> Option<bool> {
586 self.state.lock().is_staff
587 }
588
589 fn build_request(
590 self: &Arc<Self>,
591 // We take in the JSON bytes buffer so we can reuse the existing allocation.
592 mut json_bytes: Vec<u8>,
593 event_request: &EventRequestBody,
594 ) -> Result<Request<AsyncBody>> {
595 json_bytes.clear();
596 serde_json::to_writer(&mut json_bytes, event_request)?;
597
598 let checksum = calculate_json_checksum(&json_bytes).unwrap_or_default();
599
600 Ok(Request::builder()
601 .method(Method::POST)
602 .uri(
603 self.http_client
604 .build_zed_api_url("/telemetry/events", &[])?
605 .as_ref(),
606 )
607 .header("Content-Type", "application/json")
608 .header("x-zed-checksum", checksum)
609 .body(json_bytes.into())?)
610 }
611
612 pub async fn flush_events_inner(self: &Arc<Self>) -> Result<()> {
613 let (json_bytes, request_body) = {
614 let mut state = self.state.lock();
615 state.first_event_date_time = None;
616 let events = mem::take(&mut state.events_queue);
617 state.flush_events_task.take();
618 if events.is_empty() {
619 return Ok(());
620 }
621
622 let mut json_bytes = Vec::new();
623
624 if let Some(file) = &mut state.log_file {
625 for event in &events {
626 json_bytes.clear();
627 serde_json::to_writer(&mut json_bytes, event)?;
628 file.write_all(&json_bytes)?;
629 file.write_all(b"\n")?;
630 }
631 }
632
633 (
634 json_bytes,
635 EventRequestBody {
636 system_id: state.system_id.as_deref().map(Into::into),
637 installation_id: state.installation_id.as_deref().map(Into::into),
638 session_id: state.session_id.clone(),
639 metrics_id: state.metrics_id.as_deref().map(Into::into),
640 is_staff: state.is_staff,
641 app_version: state.app_version.clone(),
642 os_name: state.os_name.clone(),
643 os_version: state.os_version.clone(),
644 architecture: state.architecture.to_string(),
645
646 release_channel: state.release_channel.map(Into::into),
647 events,
648 },
649 )
650 };
651
652 let request = self.build_request(json_bytes, &request_body)?;
653 let response = self.http_client.send(request).await?;
654 if response.status() != 200 {
655 log::error!("Failed to send events: HTTP {:?}", response.status());
656 }
657
658 anyhow::Ok(())
659 }
660
661 pub fn flush_events(self: &Arc<Self>) -> Task<()> {
662 let this = self.clone();
663 self.executor.spawn(async move {
664 this.flush_events_inner().await.log_err();
665 })
666 }
667}
668
669pub fn calculate_json_checksum(json: &impl AsRef<[u8]>) -> Option<String> {
670 let Some(checksum_seed) = &*ZED_CLIENT_CHECKSUM_SEED else {
671 return None;
672 };
673
674 let mut summer = Sha256::new();
675 summer.update(checksum_seed);
676 summer.update(json);
677 summer.update(checksum_seed);
678 let mut checksum = String::new();
679 for byte in summer.finalize().as_slice() {
680 use std::fmt::Write;
681 write!(&mut checksum, "{:02x}", byte).unwrap();
682 }
683
684 Some(checksum)
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use clock::FakeSystemClock;
691
692 use gpui::TestAppContext;
693 use http_client::FakeHttpClient;
694 use std::collections::HashMap;
695 use telemetry_events::FlexibleEvent;
696 use util::rel_path::RelPath;
697 use worktree::{PathChange, ProjectEntryId, WorktreeId};
698
699 #[gpui::test]
700 async fn test_telemetry_flush_on_max_queue_size(
701 executor: BackgroundExecutor,
702 cx: &mut TestAppContext,
703 ) {
704 init_test(cx);
705 let clock = Arc::new(FakeSystemClock::new());
706 let http = FakeHttpClient::with_200_response();
707 let system_id = Some("system_id".to_string());
708 let installation_id = Some("installation_id".to_string());
709 let session_id = "session_id".to_string();
710
711 let (telemetry, first_date_time, event) = cx.update(|cx| {
712 let telemetry = Telemetry::new(clock.clone(), http, cx);
713
714 telemetry.state.lock().max_queue_size = 4;
715 telemetry.start(system_id, installation_id, session_id, cx);
716
717 assert!(is_empty_state(&telemetry));
718
719 let first_date_time = clock.utc_now();
720 let event_properties = HashMap::from_iter([(
721 "test_key".to_string(),
722 serde_json::Value::String("test_value".to_string()),
723 )]);
724
725 let event = FlexibleEvent {
726 event_type: "test".to_string(),
727 event_properties,
728 };
729
730 (telemetry, first_date_time, event)
731 });
732
733 cx.update(|_cx| {
734 telemetry.report_event(Event::Flexible(event.clone()));
735 assert_eq!(telemetry.state.lock().events_queue.len(), 1);
736 assert!(telemetry.state.lock().flush_events_task.is_some());
737 assert_eq!(
738 telemetry.state.lock().first_event_date_time,
739 Some(first_date_time)
740 );
741
742 clock.advance(Duration::from_millis(100));
743
744 telemetry.report_event(Event::Flexible(event.clone()));
745 assert_eq!(telemetry.state.lock().events_queue.len(), 2);
746 assert!(telemetry.state.lock().flush_events_task.is_some());
747 assert_eq!(
748 telemetry.state.lock().first_event_date_time,
749 Some(first_date_time)
750 );
751
752 clock.advance(Duration::from_millis(100));
753
754 telemetry.report_event(Event::Flexible(event.clone()));
755 assert_eq!(telemetry.state.lock().events_queue.len(), 3);
756 assert!(telemetry.state.lock().flush_events_task.is_some());
757 assert_eq!(
758 telemetry.state.lock().first_event_date_time,
759 Some(first_date_time)
760 );
761
762 clock.advance(Duration::from_millis(100));
763
764 // Adding a 4th event should cause a flush
765 telemetry.report_event(Event::Flexible(event));
766 });
767
768 // Run the spawned flush task to completion
769 executor.run_until_parked();
770
771 cx.update(|_cx| {
772 assert!(is_empty_state(&telemetry));
773 });
774 }
775
776 #[gpui::test]
777 async fn test_telemetry_flush_on_flush_interval(
778 executor: BackgroundExecutor,
779 cx: &mut TestAppContext,
780 ) {
781 init_test(cx);
782 let clock = Arc::new(FakeSystemClock::new());
783 let http = FakeHttpClient::with_200_response();
784 let system_id = Some("system_id".to_string());
785 let installation_id = Some("installation_id".to_string());
786 let session_id = "session_id".to_string();
787
788 cx.update(|cx| {
789 let telemetry = Telemetry::new(clock.clone(), http, cx);
790 telemetry.state.lock().max_queue_size = 4;
791 telemetry.start(system_id, installation_id, session_id, cx);
792
793 assert!(is_empty_state(&telemetry));
794 let first_date_time = clock.utc_now();
795
796 let event_properties = HashMap::from_iter([(
797 "test_key".to_string(),
798 serde_json::Value::String("test_value".to_string()),
799 )]);
800
801 let event = FlexibleEvent {
802 event_type: "test".to_string(),
803 event_properties,
804 };
805
806 telemetry.report_event(Event::Flexible(event));
807 assert_eq!(telemetry.state.lock().events_queue.len(), 1);
808 assert!(telemetry.state.lock().flush_events_task.is_some());
809 assert_eq!(
810 telemetry.state.lock().first_event_date_time,
811 Some(first_date_time)
812 );
813
814 let duration = Duration::from_millis(1);
815
816 // Test 1 millisecond before the flush interval limit is met
817 executor.advance_clock(FLUSH_INTERVAL - duration);
818
819 assert!(!is_empty_state(&telemetry));
820
821 // Test the exact moment the flush interval limit is met
822 executor.advance_clock(duration);
823
824 assert!(is_empty_state(&telemetry));
825 });
826 }
827
828 #[gpui::test]
829 fn test_project_discovery_does_not_double_report(cx: &mut gpui::TestAppContext) {
830 init_test(cx);
831
832 let clock = Arc::new(FakeSystemClock::new());
833 let http = FakeHttpClient::with_200_response();
834 let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
835 let worktree_id = 1;
836
837 // Scan of empty worktree finds nothing
838 test_project_discovery_helper(telemetry.clone(), vec![], Some(vec![]), worktree_id);
839
840 // Files added, second scan of worktree 1 finds project type
841 test_project_discovery_helper(
842 telemetry.clone(),
843 vec!["package.json"],
844 Some(vec!["node"]),
845 worktree_id,
846 );
847
848 // Third scan of worktree does not double report, as we already reported
849 test_project_discovery_helper(telemetry, vec!["package.json"], None, worktree_id);
850 }
851
852 #[gpui::test]
853 fn test_pnpm_project_discovery(cx: &mut gpui::TestAppContext) {
854 init_test(cx);
855
856 let clock = Arc::new(FakeSystemClock::new());
857 let http = FakeHttpClient::with_200_response();
858 let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
859
860 test_project_discovery_helper(
861 telemetry,
862 vec!["package.json", "pnpm-lock.yaml"],
863 Some(vec!["node", "pnpm"]),
864 1,
865 );
866 }
867
868 #[gpui::test]
869 fn test_yarn_project_discovery(cx: &mut gpui::TestAppContext) {
870 init_test(cx);
871
872 let clock = Arc::new(FakeSystemClock::new());
873 let http = FakeHttpClient::with_200_response();
874 let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
875
876 test_project_discovery_helper(
877 telemetry,
878 vec!["package.json", "yarn.lock"],
879 Some(vec!["node", "yarn"]),
880 1,
881 );
882 }
883
884 #[gpui::test]
885 fn test_dotnet_project_discovery(cx: &mut gpui::TestAppContext) {
886 init_test(cx);
887
888 let clock = Arc::new(FakeSystemClock::new());
889 let http = FakeHttpClient::with_200_response();
890 let telemetry = cx.update(|cx| Telemetry::new(clock.clone(), http, cx));
891
892 // Using different worktrees, as production code blocks from reporting a
893 // project type for the same worktree multiple times
894
895 test_project_discovery_helper(
896 telemetry.clone(),
897 vec!["global.json"],
898 Some(vec!["dotnet"]),
899 1,
900 );
901 test_project_discovery_helper(
902 telemetry.clone(),
903 vec!["Directory.Build.props"],
904 Some(vec!["dotnet"]),
905 2,
906 );
907 test_project_discovery_helper(
908 telemetry.clone(),
909 vec!["file.csproj"],
910 Some(vec!["dotnet"]),
911 3,
912 );
913 test_project_discovery_helper(
914 telemetry.clone(),
915 vec!["file.fsproj"],
916 Some(vec!["dotnet"]),
917 4,
918 );
919 test_project_discovery_helper(
920 telemetry.clone(),
921 vec!["file.vbproj"],
922 Some(vec!["dotnet"]),
923 5,
924 );
925 test_project_discovery_helper(telemetry.clone(), vec!["file.sln"], Some(vec!["dotnet"]), 6);
926
927 // Each worktree should only send a single project type event, even when
928 // encountering multiple files associated with that project type
929 test_project_discovery_helper(
930 telemetry,
931 vec!["global.json", "Directory.Build.props"],
932 Some(vec!["dotnet"]),
933 7,
934 );
935 }
936
937 // TODO:
938 // Test settings
939 // Update FakeHTTPClient to keep track of the number of requests and assert on it
940
941 fn init_test(cx: &mut TestAppContext) {
942 cx.update(|cx| {
943 let settings_store = SettingsStore::test(cx);
944 cx.set_global(settings_store);
945 });
946 }
947
948 fn is_empty_state(telemetry: &Telemetry) -> bool {
949 telemetry.state.lock().events_queue.is_empty()
950 && telemetry.state.lock().flush_events_task.is_none()
951 && telemetry.state.lock().first_event_date_time.is_none()
952 }
953
954 fn test_project_discovery_helper(
955 telemetry: Arc<Telemetry>,
956 file_paths: Vec<&str>,
957 expected_project_types: Option<Vec<&str>>,
958 worktree_id_num: usize,
959 ) {
960 let worktree_id = WorktreeId::from_usize(worktree_id_num);
961 let entries: Vec<_> = file_paths
962 .into_iter()
963 .enumerate()
964 .filter_map(|(i, path)| {
965 Some((
966 Arc::from(RelPath::unix(path).ok()?),
967 ProjectEntryId::from_proto(i as u64 + 1),
968 PathChange::Added,
969 ))
970 })
971 .collect();
972 let updated_entries: UpdatedEntriesSet = Arc::from(entries.as_slice());
973
974 let detected_project_types = telemetry.detect_project_types(worktree_id, &updated_entries);
975
976 let expected_project_types =
977 expected_project_types.map(|types| types.iter().map(|&t| t.to_string()).collect());
978
979 assert_eq!(detected_project_types, expected_project_types);
980 }
981}