1mod event_coalescer;
2
3use crate::{ChannelId, TelemetrySettings};
4use anyhow::Result;
5use clock::SystemClock;
6use collections::{HashMap, HashSet};
7use futures::Future;
8use gpui::{AppContext, BackgroundExecutor, Task};
9use http_client::{self, AsyncBody, HttpClient, HttpClientWithUrl, Method, Request};
10use once_cell::sync::Lazy;
11use parking_lot::Mutex;
12use release_channel::ReleaseChannel;
13use settings::{Settings, SettingsStore};
14use sha2::{Digest, Sha256};
15use std::fs::File;
16use std::io::Write;
17use std::time::Instant;
18use std::{env, mem, path::PathBuf, sync::Arc, time::Duration};
19use telemetry_events::{
20 ActionEvent, AppEvent, AssistantEvent, CallEvent, EditEvent, EditorEvent, Event,
21 EventRequestBody, EventWrapper, ExtensionEvent, InlineCompletionEvent, InlineCompletionRating,
22 InlineCompletionRatingEvent, ReplEvent, SettingEvent,
23};
24use util::{ResultExt, TryFutureExt};
25use worktree::{UpdatedEntriesSet, WorktreeId};
26
27use self::event_coalescer::EventCoalescer;
28
29pub struct Telemetry {
30 clock: Arc<dyn SystemClock>,
31 http_client: Arc<HttpClientWithUrl>,
32 executor: BackgroundExecutor,
33 state: Arc<Mutex<TelemetryState>>,
34}
35
36struct TelemetryState {
37 settings: TelemetrySettings,
38 system_id: Option<Arc<str>>, // Per system
39 installation_id: Option<Arc<str>>, // Per app installation (different for dev, nightly, preview, and stable)
40 session_id: Option<String>, // Per app launch
41 metrics_id: Option<Arc<str>>, // Per logged-in user
42 release_channel: Option<&'static str>,
43 architecture: &'static str,
44 events_queue: Vec<EventWrapper>,
45 flush_events_task: Option<Task<()>>,
46 log_file: Option<File>,
47 is_staff: Option<bool>,
48 first_event_date_time: Option<Instant>,
49 event_coalescer: EventCoalescer,
50 max_queue_size: usize,
51 worktree_id_map: WorktreeIdMap,
52
53 os_name: String,
54 app_version: String,
55 os_version: Option<String>,
56}
57
58#[derive(Debug)]
59struct WorktreeIdMap(HashMap<String, ProjectCache>);
60
61#[derive(Debug)]
62struct ProjectCache {
63 name: String,
64 worktree_ids_reported: HashSet<WorktreeId>,
65}
66
67impl ProjectCache {
68 fn new(name: String) -> Self {
69 Self {
70 name,
71 worktree_ids_reported: HashSet::default(),
72 }
73 }
74}
75
76#[cfg(debug_assertions)]
77const MAX_QUEUE_LEN: usize = 5;
78
79#[cfg(not(debug_assertions))]
80const MAX_QUEUE_LEN: usize = 50;
81
82#[cfg(debug_assertions)]
83const FLUSH_INTERVAL: Duration = Duration::from_secs(1);
84
85#[cfg(not(debug_assertions))]
86const FLUSH_INTERVAL: Duration = Duration::from_secs(60 * 5);
87static ZED_CLIENT_CHECKSUM_SEED: Lazy<Option<Vec<u8>>> = Lazy::new(|| {
88 option_env!("ZED_CLIENT_CHECKSUM_SEED")
89 .map(|s| s.as_bytes().into())
90 .or_else(|| {
91 env::var("ZED_CLIENT_CHECKSUM_SEED")
92 .ok()
93 .map(|s| s.as_bytes().into())
94 })
95});
96
97pub fn os_name() -> String {
98 #[cfg(target_os = "macos")]
99 {
100 "macOS".to_string()
101 }
102 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
103 {
104 format!("Linux {}", gpui::guess_compositor())
105 }
106
107 #[cfg(target_os = "windows")]
108 {
109 "Windows".to_string()
110 }
111}
112
113/// Note: This might do blocking IO! Only call from background threads
114pub fn os_version() -> String {
115 #[cfg(target_os = "macos")]
116 {
117 use cocoa::base::nil;
118 use cocoa::foundation::NSProcessInfo;
119
120 unsafe {
121 let process_info = cocoa::foundation::NSProcessInfo::processInfo(nil);
122 let version = process_info.operatingSystemVersion();
123 gpui::SemanticVersion::new(
124 version.majorVersion as usize,
125 version.minorVersion as usize,
126 version.patchVersion as usize,
127 )
128 .to_string()
129 }
130 }
131 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
132 {
133 use std::path::Path;
134
135 let content = if let Ok(file) = std::fs::read_to_string(&Path::new("/etc/os-release")) {
136 file
137 } else if let Ok(file) = std::fs::read_to_string(&Path::new("/usr/lib/os-release")) {
138 file
139 } else {
140 log::error!("Failed to load /etc/os-release, /usr/lib/os-release");
141 "".to_string()
142 };
143 let mut name = "unknown".to_string();
144 let mut version = "unknown".to_string();
145
146 for line in content.lines() {
147 if line.starts_with("ID=") {
148 name = line.trim_start_matches("ID=").trim_matches('"').to_string();
149 }
150 if line.starts_with("VERSION_ID=") {
151 version = line
152 .trim_start_matches("VERSION_ID=")
153 .trim_matches('"')
154 .to_string();
155 }
156 }
157
158 format!("{} {}", name, version)
159 }
160
161 #[cfg(target_os = "windows")]
162 {
163 let mut info = unsafe { std::mem::zeroed() };
164 let status = unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut info) };
165 if status.is_ok() {
166 gpui::SemanticVersion::new(
167 info.dwMajorVersion as _,
168 info.dwMinorVersion as _,
169 info.dwBuildNumber as _,
170 )
171 .to_string()
172 } else {
173 "unknown".to_string()
174 }
175 }
176}
177
178impl Telemetry {
179 pub fn new(
180 clock: Arc<dyn SystemClock>,
181 client: Arc<HttpClientWithUrl>,
182 cx: &mut AppContext,
183 ) -> Arc<Self> {
184 let release_channel =
185 ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
186
187 TelemetrySettings::register(cx);
188
189 let state = Arc::new(Mutex::new(TelemetryState {
190 settings: *TelemetrySettings::get_global(cx),
191 architecture: env::consts::ARCH,
192 release_channel,
193 system_id: None,
194 installation_id: None,
195 session_id: None,
196 metrics_id: None,
197 events_queue: Vec::new(),
198 flush_events_task: None,
199 log_file: None,
200 is_staff: None,
201 first_event_date_time: None,
202 event_coalescer: EventCoalescer::new(clock.clone()),
203 max_queue_size: MAX_QUEUE_LEN,
204 worktree_id_map: WorktreeIdMap(HashMap::from_iter([
205 (
206 "pnpm-lock.yaml".to_string(),
207 ProjectCache::new("pnpm".to_string()),
208 ),
209 (
210 "yarn.lock".to_string(),
211 ProjectCache::new("yarn".to_string()),
212 ),
213 (
214 "package.json".to_string(),
215 ProjectCache::new("node".to_string()),
216 ),
217 ])),
218
219 os_version: None,
220 os_name: os_name(),
221 app_version: release_channel::AppVersion::global(cx).to_string(),
222 }));
223 Self::log_file_path();
224
225 cx.background_executor()
226 .spawn({
227 let state = state.clone();
228 let os_version = os_version();
229 state.lock().os_version = Some(os_version.clone());
230 async move {
231 if let Some(tempfile) = File::create(Self::log_file_path()).log_err() {
232 state.lock().log_file = Some(tempfile);
233 }
234 }
235 })
236 .detach();
237
238 cx.observe_global::<SettingsStore>({
239 let state = state.clone();
240
241 move |cx| {
242 let mut state = state.lock();
243 state.settings = *TelemetrySettings::get_global(cx);
244 }
245 })
246 .detach();
247
248 // TODO: Replace all hardware stuff with nested SystemSpecs json
249 let this = Arc::new(Self {
250 clock,
251 http_client: client,
252 executor: cx.background_executor().clone(),
253 state,
254 });
255
256 // We should only ever have one instance of Telemetry, leak the subscription to keep it alive
257 // rather than store in TelemetryState, complicating spawn as subscriptions are not Send
258 std::mem::forget(cx.on_app_quit({
259 let this = this.clone();
260 move |_| this.shutdown_telemetry()
261 }));
262
263 this
264 }
265
266 #[cfg(any(test, feature = "test-support"))]
267 fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> {
268 Task::ready(())
269 }
270
271 // Skip calling this function in tests.
272 // TestAppContext ends up calling this function on shutdown and it panics when trying to find the TelemetrySettings
273 #[cfg(not(any(test, feature = "test-support")))]
274 fn shutdown_telemetry(self: &Arc<Self>) -> impl Future<Output = ()> {
275 self.report_app_event("close".to_string());
276 // TODO: close final edit period and make sure it's sent
277 Task::ready(())
278 }
279
280 pub fn log_file_path() -> PathBuf {
281 paths::logs_dir().join("telemetry.log")
282 }
283
284 pub fn start(
285 self: &Arc<Self>,
286 system_id: Option<String>,
287 installation_id: Option<String>,
288 session_id: String,
289 cx: &AppContext,
290 ) {
291 let mut state = self.state.lock();
292 state.system_id = system_id.map(|id| id.into());
293 state.installation_id = installation_id.map(|id| id.into());
294 state.session_id = Some(session_id);
295 state.app_version = release_channel::AppVersion::global(cx).to_string();
296 state.os_name = os_name();
297 }
298
299 pub fn metrics_enabled(self: &Arc<Self>) -> bool {
300 let state = self.state.lock();
301 let enabled = state.settings.metrics;
302 drop(state);
303 enabled
304 }
305
306 pub fn set_authenticated_user_info(
307 self: &Arc<Self>,
308 metrics_id: Option<String>,
309 is_staff: bool,
310 ) {
311 let mut state = self.state.lock();
312
313 if !state.settings.metrics {
314 return;
315 }
316
317 let metrics_id: Option<Arc<str>> = metrics_id.map(|id| id.into());
318 state.metrics_id.clone_from(&metrics_id);
319 state.is_staff = Some(is_staff);
320 drop(state);
321 }
322
323 pub fn report_editor_event(
324 self: &Arc<Self>,
325 file_extension: Option<String>,
326 vim_mode: bool,
327 operation: &'static str,
328 copilot_enabled: bool,
329 copilot_enabled_for_language: bool,
330 is_via_ssh: bool,
331 ) {
332 let event = Event::Editor(EditorEvent {
333 file_extension,
334 vim_mode,
335 operation: operation.into(),
336 copilot_enabled,
337 copilot_enabled_for_language,
338 is_via_ssh,
339 });
340
341 self.report_event(event)
342 }
343
344 pub fn report_inline_completion_event(
345 self: &Arc<Self>,
346 provider: String,
347 suggestion_accepted: bool,
348 file_extension: Option<String>,
349 ) {
350 let event = Event::InlineCompletion(InlineCompletionEvent {
351 provider,
352 suggestion_accepted,
353 file_extension,
354 });
355
356 self.report_event(event)
357 }
358
359 pub fn report_inline_completion_rating_event(
360 self: &Arc<Self>,
361 rating: InlineCompletionRating,
362 input_events: Arc<str>,
363 input_excerpt: Arc<str>,
364 output_excerpt: Arc<str>,
365 feedback: String,
366 ) {
367 let event = Event::InlineCompletionRating(InlineCompletionRatingEvent {
368 rating,
369 input_events,
370 input_excerpt,
371 output_excerpt,
372 feedback,
373 });
374 self.report_event(event);
375 }
376
377 pub fn report_assistant_event(self: &Arc<Self>, event: AssistantEvent) {
378 self.report_event(Event::Assistant(event));
379 }
380
381 pub fn report_call_event(
382 self: &Arc<Self>,
383 operation: &'static str,
384 room_id: Option<u64>,
385 channel_id: Option<ChannelId>,
386 ) {
387 let event = Event::Call(CallEvent {
388 operation: operation.to_string(),
389 room_id,
390 channel_id: channel_id.map(|cid| cid.0),
391 });
392
393 self.report_event(event)
394 }
395
396 pub fn report_app_event(self: &Arc<Self>, operation: String) -> Event {
397 let event = Event::App(AppEvent { operation });
398
399 self.report_event(event.clone());
400
401 event
402 }
403
404 pub fn report_setting_event(self: &Arc<Self>, setting: &'static str, value: String) {
405 let event = Event::Setting(SettingEvent {
406 setting: setting.to_string(),
407 value,
408 });
409
410 self.report_event(event)
411 }
412
413 pub fn report_extension_event(self: &Arc<Self>, extension_id: Arc<str>, version: Arc<str>) {
414 self.report_event(Event::Extension(ExtensionEvent {
415 extension_id,
416 version,
417 }))
418 }
419
420 pub fn log_edit_event(self: &Arc<Self>, environment: &'static str, is_via_ssh: bool) {
421 let mut state = self.state.lock();
422 let period_data = state.event_coalescer.log_event(environment);
423 drop(state);
424
425 if let Some((start, end, environment)) = period_data {
426 let event = Event::Edit(EditEvent {
427 duration: end
428 .saturating_duration_since(start)
429 .min(Duration::from_secs(60 * 60 * 24))
430 .as_millis() as i64,
431 environment: environment.to_string(),
432 is_via_ssh,
433 });
434
435 self.report_event(event);
436 }
437 }
438
439 pub fn report_action_event(self: &Arc<Self>, source: &'static str, action: String) {
440 let event = Event::Action(ActionEvent {
441 source: source.to_string(),
442 action,
443 });
444
445 self.report_event(event)
446 }
447
448 pub fn report_discovered_project_events(
449 self: &Arc<Self>,
450 worktree_id: WorktreeId,
451 updated_entries_set: &UpdatedEntriesSet,
452 ) {
453 let project_type_names: Vec<String> = {
454 let mut state = self.state.lock();
455 state
456 .worktree_id_map
457 .0
458 .iter_mut()
459 .filter_map(|(project_file_name, project_type_telemetry)| {
460 if project_type_telemetry
461 .worktree_ids_reported
462 .contains(&worktree_id)
463 {
464 return None;
465 }
466
467 let project_file_found = updated_entries_set.iter().any(|(path, _, _)| {
468 path.as_ref()
469 .file_name()
470 .and_then(|name| name.to_str())
471 .map(|name_str| name_str == project_file_name)
472 .unwrap_or(false)
473 });
474
475 if !project_file_found {
476 return None;
477 }
478
479 project_type_telemetry
480 .worktree_ids_reported
481 .insert(worktree_id);
482
483 Some(project_type_telemetry.name.clone())
484 })
485 .collect()
486 };
487
488 // Done on purpose to avoid calling `self.state.lock()` multiple times
489 for project_type_name in project_type_names {
490 self.report_app_event(format!("open {} project", project_type_name));
491 }
492 }
493
494 pub fn report_repl_event(
495 self: &Arc<Self>,
496 kernel_language: String,
497 kernel_status: String,
498 repl_session_id: String,
499 ) {
500 let event = Event::Repl(ReplEvent {
501 kernel_language,
502 kernel_status,
503 repl_session_id,
504 });
505
506 self.report_event(event)
507 }
508
509 fn report_event(self: &Arc<Self>, event: Event) {
510 let mut state = self.state.lock();
511
512 if !state.settings.metrics {
513 return;
514 }
515
516 if state.flush_events_task.is_none() {
517 let this = self.clone();
518 let executor = self.executor.clone();
519 state.flush_events_task = Some(self.executor.spawn(async move {
520 executor.timer(FLUSH_INTERVAL).await;
521 this.flush_events();
522 }));
523 }
524
525 let date_time = self.clock.utc_now();
526
527 let milliseconds_since_first_event = match state.first_event_date_time {
528 Some(first_event_date_time) => date_time
529 .saturating_duration_since(first_event_date_time)
530 .min(Duration::from_secs(60 * 60 * 24))
531 .as_millis() as i64,
532 None => {
533 state.first_event_date_time = Some(date_time);
534 0
535 }
536 };
537
538 let signed_in = state.metrics_id.is_some();
539 state.events_queue.push(EventWrapper {
540 signed_in,
541 milliseconds_since_first_event,
542 event,
543 });
544
545 if state.installation_id.is_some() && state.events_queue.len() >= state.max_queue_size {
546 drop(state);
547 self.flush_events();
548 }
549 }
550
551 pub fn metrics_id(self: &Arc<Self>) -> Option<Arc<str>> {
552 self.state.lock().metrics_id.clone()
553 }
554
555 pub fn system_id(self: &Arc<Self>) -> Option<Arc<str>> {
556 self.state.lock().system_id.clone()
557 }
558
559 pub fn installation_id(self: &Arc<Self>) -> Option<Arc<str>> {
560 self.state.lock().installation_id.clone()
561 }
562
563 pub fn is_staff(self: &Arc<Self>) -> Option<bool> {
564 self.state.lock().is_staff
565 }
566
567 fn build_request(
568 self: &Arc<Self>,
569 // We take in the JSON bytes buffer so we can reuse the existing allocation.
570 mut json_bytes: Vec<u8>,
571 event_request: EventRequestBody,
572 ) -> Result<Request<AsyncBody>> {
573 json_bytes.clear();
574 serde_json::to_writer(&mut json_bytes, &event_request)?;
575
576 let checksum = calculate_json_checksum(&json_bytes).unwrap_or("".to_string());
577
578 Ok(Request::builder()
579 .method(Method::POST)
580 .uri(
581 self.http_client
582 .build_zed_api_url("/telemetry/events", &[])?
583 .as_ref(),
584 )
585 .header("Content-Type", "application/json")
586 .header("x-zed-checksum", checksum)
587 .body(json_bytes.into())?)
588 }
589
590 pub fn flush_events(self: &Arc<Self>) {
591 let mut state = self.state.lock();
592 state.first_event_date_time = None;
593 let mut events = mem::take(&mut state.events_queue);
594 state.flush_events_task.take();
595 drop(state);
596 if events.is_empty() {
597 return;
598 }
599
600 let this = self.clone();
601 self.executor
602 .spawn(
603 async move {
604 let mut json_bytes = Vec::new();
605
606 if let Some(file) = &mut this.state.lock().log_file {
607 for event in &mut events {
608 json_bytes.clear();
609 serde_json::to_writer(&mut json_bytes, event)?;
610 file.write_all(&json_bytes)?;
611 file.write_all(b"\n")?;
612 }
613 }
614
615 let request_body = {
616 let state = this.state.lock();
617
618 EventRequestBody {
619 system_id: state.system_id.as_deref().map(Into::into),
620 installation_id: state.installation_id.as_deref().map(Into::into),
621 session_id: state.session_id.clone(),
622 metrics_id: state.metrics_id.as_deref().map(Into::into),
623 is_staff: state.is_staff,
624 app_version: state.app_version.clone(),
625 os_name: state.os_name.clone(),
626 os_version: state.os_version.clone(),
627 architecture: state.architecture.to_string(),
628
629 release_channel: state.release_channel.map(Into::into),
630 events,
631 }
632 };
633
634 let request = this.build_request(json_bytes, request_body)?;
635 let response = this.http_client.send(request).await?;
636 if response.status() != 200 {
637 log::error!("Failed to send events: HTTP {:?}", response.status());
638 }
639 anyhow::Ok(())
640 }
641 .log_err(),
642 )
643 .detach();
644 }
645}
646
647pub fn calculate_json_checksum(json: &impl AsRef<[u8]>) -> Option<String> {
648 let Some(checksum_seed) = &*ZED_CLIENT_CHECKSUM_SEED else {
649 return None;
650 };
651
652 let mut summer = Sha256::new();
653 summer.update(checksum_seed);
654 summer.update(json);
655 summer.update(checksum_seed);
656 let mut checksum = String::new();
657 for byte in summer.finalize().as_slice() {
658 use std::fmt::Write;
659 write!(&mut checksum, "{:02x}", byte).unwrap();
660 }
661
662 Some(checksum)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use clock::FakeSystemClock;
669 use gpui::TestAppContext;
670 use http_client::FakeHttpClient;
671
672 #[gpui::test]
673 fn test_telemetry_flush_on_max_queue_size(cx: &mut TestAppContext) {
674 init_test(cx);
675 let clock = Arc::new(FakeSystemClock::new());
676 let http = FakeHttpClient::with_200_response();
677 let system_id = Some("system_id".to_string());
678 let installation_id = Some("installation_id".to_string());
679 let session_id = "session_id".to_string();
680
681 cx.update(|cx| {
682 let telemetry = Telemetry::new(clock.clone(), http, cx);
683
684 telemetry.state.lock().max_queue_size = 4;
685 telemetry.start(system_id, installation_id, session_id, cx);
686
687 assert!(is_empty_state(&telemetry));
688
689 let first_date_time = clock.utc_now();
690 let operation = "test".to_string();
691
692 let event = telemetry.report_app_event(operation.clone());
693 assert_eq!(
694 event,
695 Event::App(AppEvent {
696 operation: operation.clone(),
697 })
698 );
699 assert_eq!(telemetry.state.lock().events_queue.len(), 1);
700 assert!(telemetry.state.lock().flush_events_task.is_some());
701 assert_eq!(
702 telemetry.state.lock().first_event_date_time,
703 Some(first_date_time)
704 );
705
706 clock.advance(Duration::from_millis(100));
707
708 let event = telemetry.report_app_event(operation.clone());
709 assert_eq!(
710 event,
711 Event::App(AppEvent {
712 operation: operation.clone(),
713 })
714 );
715 assert_eq!(telemetry.state.lock().events_queue.len(), 2);
716 assert!(telemetry.state.lock().flush_events_task.is_some());
717 assert_eq!(
718 telemetry.state.lock().first_event_date_time,
719 Some(first_date_time)
720 );
721
722 clock.advance(Duration::from_millis(100));
723
724 let event = telemetry.report_app_event(operation.clone());
725 assert_eq!(
726 event,
727 Event::App(AppEvent {
728 operation: operation.clone(),
729 })
730 );
731 assert_eq!(telemetry.state.lock().events_queue.len(), 3);
732 assert!(telemetry.state.lock().flush_events_task.is_some());
733 assert_eq!(
734 telemetry.state.lock().first_event_date_time,
735 Some(first_date_time)
736 );
737
738 clock.advance(Duration::from_millis(100));
739
740 // Adding a 4th event should cause a flush
741 let event = telemetry.report_app_event(operation.clone());
742 assert_eq!(
743 event,
744 Event::App(AppEvent {
745 operation: operation.clone(),
746 })
747 );
748
749 assert!(is_empty_state(&telemetry));
750 });
751 }
752
753 #[gpui::test]
754 async fn test_telemetry_flush_on_flush_interval(
755 executor: BackgroundExecutor,
756 cx: &mut TestAppContext,
757 ) {
758 init_test(cx);
759 let clock = Arc::new(FakeSystemClock::new());
760 let http = FakeHttpClient::with_200_response();
761 let system_id = Some("system_id".to_string());
762 let installation_id = Some("installation_id".to_string());
763 let session_id = "session_id".to_string();
764
765 cx.update(|cx| {
766 let telemetry = Telemetry::new(clock.clone(), http, cx);
767 telemetry.state.lock().max_queue_size = 4;
768 telemetry.start(system_id, installation_id, session_id, cx);
769
770 assert!(is_empty_state(&telemetry));
771
772 let first_date_time = clock.utc_now();
773 let operation = "test".to_string();
774
775 let event = telemetry.report_app_event(operation.clone());
776 assert_eq!(
777 event,
778 Event::App(AppEvent {
779 operation: operation.clone(),
780 })
781 );
782 assert_eq!(telemetry.state.lock().events_queue.len(), 1);
783 assert!(telemetry.state.lock().flush_events_task.is_some());
784 assert_eq!(
785 telemetry.state.lock().first_event_date_time,
786 Some(first_date_time)
787 );
788
789 let duration = Duration::from_millis(1);
790
791 // Test 1 millisecond before the flush interval limit is met
792 executor.advance_clock(FLUSH_INTERVAL - duration);
793
794 assert!(!is_empty_state(&telemetry));
795
796 // Test the exact moment the flush interval limit is met
797 executor.advance_clock(duration);
798
799 assert!(is_empty_state(&telemetry));
800 });
801 }
802
803 // TODO:
804 // Test settings
805 // Update FakeHTTPClient to keep track of the number of requests and assert on it
806
807 fn init_test(cx: &mut TestAppContext) {
808 cx.update(|cx| {
809 let settings_store = SettingsStore::test(cx);
810 cx.set_global(settings_store);
811 });
812 }
813
814 fn is_empty_state(telemetry: &Telemetry) -> bool {
815 telemetry.state.lock().events_queue.is_empty()
816 && telemetry.state.lock().flush_events_task.is_none()
817 && telemetry.state.lock().first_event_date_time.is_none()
818 }
819}