Add command to view the telemetry log

Max Brunsfeld and Joseph Lyons created

Co-authored-by: Joseph Lyons <joseph@zed.dev>

Change summary

Cargo.lock                     |  1 
crates/client/Cargo.toml       |  1 
crates/client/src/client.rs    | 10 +++
crates/client/src/telemetry.rs | 73 ++++++++++++++++++++++++++++-------
crates/zed/src/menus.rs        |  5 ++
crates/zed/src/zed.rs          | 54 ++++++++++++++++++++++++++
6 files changed, 127 insertions(+), 17 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -959,6 +959,7 @@ dependencies = [
  "serde",
  "smol",
  "sum_tree",
+ "tempfile",
  "thiserror",
  "time 0.3.11",
  "tiny_http",

crates/client/Cargo.toml 🔗

@@ -35,6 +35,7 @@ tiny_http = "0.8"
 uuid = { version = "1.1.2", features = ["v4"] }
 url = "2.2"
 serde = { version = "*", features = ["derive"] }
+tempfile = "3"
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }

crates/client/src/client.rs 🔗

@@ -33,6 +33,7 @@ use std::{
     convert::TryFrom,
     fmt::Write as _,
     future::Future,
+    path::PathBuf,
     sync::{Arc, Weak},
     time::{Duration, Instant},
 };
@@ -332,10 +333,11 @@ impl Client {
         log::info!("set status on client {}: {:?}", self.id, status);
         let mut state = self.state.write();
         *state.status.0.borrow_mut() = status;
+        let user_id = state.credentials.as_ref().map(|c| c.user_id);
 
         match status {
             Status::Connected { .. } => {
-                self.telemetry.set_user_id(self.user_id());
+                self.telemetry.set_user_id(user_id);
                 state._reconnect_task = None;
             }
             Status::ConnectionLost => {
@@ -364,7 +366,7 @@ impl Client {
                 }));
             }
             Status::SignedOut | Status::UpgradeRequired => {
-                self.telemetry.set_user_id(self.user_id());
+                self.telemetry.set_user_id(user_id);
                 state._reconnect_task.take();
             }
             _ => {}
@@ -1060,6 +1062,10 @@ impl Client {
     pub fn report_event(&self, kind: &str, properties: Value) {
         self.telemetry.report_event(kind, properties)
     }
+
+    pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
+        self.telemetry.log_file_path()
+    }
 }
 
 impl AnyWeakEntityHandle {

crates/client/src/telemetry.rs 🔗

@@ -10,15 +10,18 @@ use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use serde::Serialize;
 use std::{
+    io::Write,
     mem,
+    path::PathBuf,
     sync::Arc,
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
+use tempfile::NamedTempFile;
 use util::{post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
 
 pub struct Telemetry {
-    client: Arc<dyn HttpClient>,
+    http_client: Arc<dyn HttpClient>,
     executor: Arc<Background>,
     session_id: u128,
     state: Mutex<TelemetryState>,
@@ -34,6 +37,7 @@ struct TelemetryState {
     queue: Vec<AmplitudeEvent>,
     next_event_id: usize,
     flush_task: Option<Task<()>>,
+    log_file: Option<NamedTempFile>,
 }
 
 const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
@@ -52,10 +56,13 @@ struct AmplitudeEventBatch {
 
 #[derive(Serialize)]
 struct AmplitudeEvent {
+    #[serde(skip_serializing_if = "Option::is_none")]
     user_id: Option<Arc<str>>,
     device_id: Option<Arc<str>>,
     event_type: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
     event_properties: Option<Map<String, Value>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
     user_properties: Option<Map<String, Value>>,
     os_name: &'static str,
     os_version: Option<Arc<str>>,
@@ -80,8 +87,8 @@ const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
 impl Telemetry {
     pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         let platform = cx.platform();
-        Arc::new(Self {
-            client,
+        let this = Arc::new(Self {
+            http_client: client,
             executor: cx.background().clone(),
             session_id: SystemTime::now()
                 .duration_since(UNIX_EPOCH)
@@ -101,9 +108,29 @@ impl Telemetry {
                 queue: Default::default(),
                 flush_task: Default::default(),
                 next_event_id: 0,
+                log_file: None,
                 user_id: None,
             }),
-        })
+        });
+
+        if AMPLITUDE_API_KEY.is_some() {
+            this.executor
+                .spawn({
+                    let this = this.clone();
+                    async move {
+                        if let Some(tempfile) = NamedTempFile::new().log_err() {
+                            this.state.lock().log_file = Some(tempfile);
+                        }
+                    }
+                })
+                .detach();
+        }
+
+        this
+    }
+
+    pub fn log_file_path(&self) -> Option<PathBuf> {
+        Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
     }
 
     pub fn start(self: &Arc<Self>, db: Arc<Db>) {
@@ -189,23 +216,39 @@ impl Telemetry {
         }
     }
 
-    fn flush(&self) {
+    fn flush(self: &Arc<Self>) {
         let mut state = self.state.lock();
         let events = mem::take(&mut state.queue);
         state.flush_task.take();
+        drop(state);
 
         if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
-            let client = self.client.clone();
+            let this = self.clone();
             self.executor
-                .spawn(async move {
-                    let batch = AmplitudeEventBatch { api_key, events };
-                    let body = serde_json::to_vec(&batch).log_err()?;
-                    let request = Request::post(AMPLITUDE_EVENTS_URL)
-                        .body(body.into())
-                        .log_err()?;
-                    client.send(request).await.log_err();
-                    Some(())
-                })
+                .spawn(
+                    async move {
+                        let mut json_bytes = Vec::new();
+
+                        if let Some(file) = &mut this.state.lock().log_file {
+                            let file = file.as_file_mut();
+                            for event in &events {
+                                json_bytes.clear();
+                                serde_json::to_writer(&mut json_bytes, event)?;
+                                file.write_all(&json_bytes)?;
+                                file.write(b"\n")?;
+                            }
+                        }
+
+                        let batch = AmplitudeEventBatch { api_key, events };
+                        json_bytes.clear();
+                        serde_json::to_writer(&mut json_bytes, &batch)?;
+                        let request =
+                            Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
+                        this.http_client.send(request).await?;
+                        Ok(())
+                    }
+                    .log_err(),
+                )
                 .detach();
         }
     }

crates/zed/src/menus.rs 🔗

@@ -332,6 +332,11 @@ pub fn menus() -> Vec<Menu<'static>> {
                     action: Box::new(command_palette::Toggle),
                 },
                 MenuItem::Separator,
+                MenuItem::Action {
+                    name: "View Telemetry Log",
+                    action: Box::new(crate::OpenTelemetryLog),
+                },
+                MenuItem::Separator,
                 MenuItem::Action {
                     name: "Documentation",
                     action: Box::new(crate::OpenBrowser {

crates/zed/src/zed.rs 🔗

@@ -56,6 +56,7 @@ actions!(
         DebugElements,
         OpenSettings,
         OpenLog,
+        OpenTelemetryLog,
         OpenKeymap,
         OpenDefaultSettings,
         OpenDefaultKeymap,
@@ -146,6 +147,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             open_log_file(workspace, app_state.clone(), cx);
         }
     });
+    cx.add_action({
+        let app_state = app_state.clone();
+        move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
+            open_telemetry_log_file(workspace, app_state.clone(), cx);
+        }
+    });
     cx.add_action({
         let app_state = app_state.clone();
         move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
@@ -504,6 +511,53 @@ fn open_log_file(
     });
 }
 
+fn open_telemetry_log_file(
+    workspace: &mut Workspace,
+    app_state: Arc<AppState>,
+    cx: &mut ViewContext<Workspace>,
+) {
+    workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
+        cx.spawn_weak(|workspace, mut cx| async move {
+            let workspace = workspace.upgrade(&cx)?;
+            let path = app_state.client.telemetry_log_file_path()?;
+            let log = app_state.fs.load(&path).await.log_err()?;
+            workspace.update(&mut cx, |workspace, cx| {
+                let project = workspace.project().clone();
+                let buffer = project
+                    .update(cx, |project, cx| project.create_buffer("", None, cx))
+                    .expect("creating buffers on a local workspace always succeeds");
+                buffer.update(cx, |buffer, cx| {
+                    buffer.set_language(app_state.languages.get_language("JSON"), cx);
+                    buffer.edit(
+                        [(
+                            0..0,
+                            concat!(
+                                "// Zed collects anonymous usage data to help us understand how people are using the app.\n",
+                                "// After the beta release, we'll provide the ability to opt out of this telemetry.\n",
+                                "\n"
+                            ),
+                        )],
+                        None,
+                        cx,
+                    );
+                    buffer.edit([(buffer.len()..buffer.len(), log)], None, cx);
+                });
+
+                let buffer = cx.add_model(|cx| {
+                    MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
+                });
+                workspace.add_item(
+                    Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
+                    cx,
+                );
+            });
+
+            Some(())
+        })
+        .detach();
+    });
+}
+
 fn open_bundled_config_file(
     workspace: &mut Workspace,
     app_state: Arc<AppState>,