Add keymap editor UI telemetry events (#34571)

Anthony Eid and Ben Kunkle created

- Search queries
- Keybinding update or removed
- Copy action name
- Copy context name

cc @katie-z-geer 

Release Notes:

- N/A

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

Cargo.lock                            |  1 
crates/settings/src/keymap_file.rs    | 52 ++++++++++++++++++
crates/settings_ui/Cargo.toml         |  1 
crates/settings_ui/src/keybindings.rs | 81 +++++++++++++++++++++++++++-
4 files changed, 132 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14720,6 +14720,7 @@ dependencies = [
  "serde",
  "serde_json",
  "settings",
+ "telemetry",
  "theme",
  "tree-sitter-json",
  "tree-sitter-rust",

crates/settings/src/keymap_file.rs 🔗

@@ -847,6 +847,7 @@ impl KeymapFile {
     }
 }
 
+#[derive(Clone)]
 pub enum KeybindUpdateOperation<'a> {
     Replace {
         /// Describes the keybind to create
@@ -865,6 +866,47 @@ pub enum KeybindUpdateOperation<'a> {
     },
 }
 
+impl KeybindUpdateOperation<'_> {
+    pub fn generate_telemetry(
+        &self,
+    ) -> (
+        // The keybind that is created
+        String,
+        // The keybinding that was removed
+        String,
+        // The source of the keybinding
+        String,
+    ) {
+        let (new_binding, removed_binding, source) = match &self {
+            KeybindUpdateOperation::Replace {
+                source,
+                target,
+                target_keybind_source,
+            } => (Some(source), Some(target), Some(*target_keybind_source)),
+            KeybindUpdateOperation::Add { source, .. } => (Some(source), None, None),
+            KeybindUpdateOperation::Remove {
+                target,
+                target_keybind_source,
+            } => (None, Some(target), Some(*target_keybind_source)),
+        };
+
+        let new_binding = new_binding
+            .map(KeybindUpdateTarget::telemetry_string)
+            .unwrap_or("null".to_owned());
+        let removed_binding = removed_binding
+            .map(KeybindUpdateTarget::telemetry_string)
+            .unwrap_or("null".to_owned());
+
+        let source = source
+            .as_ref()
+            .map(KeybindSource::name)
+            .map(ToOwned::to_owned)
+            .unwrap_or("null".to_owned());
+
+        (new_binding, removed_binding, source)
+    }
+}
+
 impl<'a> KeybindUpdateOperation<'a> {
     pub fn add(source: KeybindUpdateTarget<'a>) -> Self {
         Self::Add { source, from: None }
@@ -905,6 +947,16 @@ impl<'a> KeybindUpdateTarget<'a> {
         keystrokes.pop();
         keystrokes
     }
+
+    fn telemetry_string(&self) -> String {
+        format!(
+            "action_name: {}, context: {}, action_arguments: {}, keystrokes: {}",
+            self.action_name,
+            self.context.unwrap_or("global"),
+            self.action_arguments.unwrap_or("none"),
+            self.keystrokes_unparsed()
+        )
+    }
 }
 
 #[derive(Clone, Copy, PartialEq, Eq)]

crates/settings_ui/Cargo.toml 🔗

@@ -34,6 +34,7 @@ search.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
+telemetry.workspace = true
 theme.workspace = true
 tree-sitter-json.workspace = true
 tree-sitter-rust.workspace = true

crates/settings_ui/src/keybindings.rs 🔗

@@ -1,6 +1,7 @@
 use std::{
     ops::{Not as _, Range},
     sync::Arc,
+    time::Duration,
 };
 
 use anyhow::{Context as _, anyhow};
@@ -12,7 +13,7 @@ use gpui::{
     Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context,
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero,
     KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy,
-    ScrollWheelEvent, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div,
+    ScrollWheelEvent, StyledText, Subscription, Task, WeakEntity, actions, anchored, deferred, div,
 };
 use language::{Language, LanguageConfig, ToOffset as _};
 use notifications::status_toast::{StatusToast, ToastIcon};
@@ -151,6 +152,13 @@ impl SearchMode {
             SearchMode::KeyStroke { .. } => SearchMode::Normal,
         }
     }
+
+    fn exact_match(&self) -> bool {
+        match self {
+            SearchMode::Normal => false,
+            SearchMode::KeyStroke { exact_match } => *exact_match,
+        }
+    }
 }
 
 #[derive(Default, PartialEq, Copy, Clone)]
@@ -249,6 +257,7 @@ struct KeymapEditor {
     keybinding_conflict_state: ConflictState,
     filter_state: FilterState,
     search_mode: SearchMode,
+    search_query_debounce: Option<Task<()>>,
     // corresponds 1 to 1 with keybindings
     string_match_candidates: Arc<Vec<StringMatchCandidate>>,
     matches: Vec<StringMatch>,
@@ -347,6 +356,7 @@ impl KeymapEditor {
             context_menu: None,
             previous_edit: None,
             humanized_action_names,
+            search_query_debounce: None,
         };
 
         this.on_keymap_changed(cx);
@@ -371,10 +381,32 @@ impl KeymapEditor {
         }
     }
 
-    fn on_query_changed(&self, cx: &mut Context<Self>) {
+    fn on_query_changed(&mut self, cx: &mut Context<Self>) {
         let action_query = self.current_action_query(cx);
         let keystroke_query = self.current_keystroke_query(cx);
+        let exact_match = self.search_mode.exact_match();
+
+        let timer = cx.background_executor().timer(Duration::from_secs(1));
+        self.search_query_debounce = Some(cx.background_spawn({
+            let action_query = action_query.clone();
+            let keystroke_query = keystroke_query.clone();
+            async move {
+                timer.await;
 
+                let keystroke_query = keystroke_query
+                    .into_iter()
+                    .map(|keystroke| keystroke.unparse())
+                    .collect::<Vec<String>>()
+                    .join(" ");
+
+                telemetry::event!(
+                    "Keystroke Search Completed",
+                    action_query = action_query,
+                    keystroke_query = keystroke_query,
+                    keystroke_exact_match = exact_match
+                )
+            }
+        }));
         cx.spawn(async move |this, cx| {
             Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?;
             this.update(cx, |this, cx| {
@@ -474,6 +506,7 @@ impl KeymapEditor {
             }
             this.selected_index.take();
             this.matches = matches;
+
             cx.notify();
         })
     }
@@ -864,6 +897,26 @@ impl KeymapEditor {
             return;
         };
         let keymap_editor = cx.entity();
+
+        let arguments = keybind
+            .action_arguments
+            .as_ref()
+            .map(|arguments| arguments.text.clone());
+        let context = keybind
+            .context
+            .as_ref()
+            .map(|context| context.local_str().unwrap_or("global"));
+        let source = keybind.source.as_ref().map(|source| source.1.clone());
+
+        telemetry::event!(
+            "Edit Keybinding Modal Opened",
+            keystroke = keybind.keystroke_text,
+            action = keybind.action_name,
+            source = source,
+            context = context,
+            arguments = arguments,
+        );
+
         self.workspace
             .update(cx, |workspace, cx| {
                 let fs = workspace.app_state().fs.clone();
@@ -899,7 +952,7 @@ impl KeymapEditor {
             return;
         };
 
-        let Ok(fs) = self
+        let std::result::Result::Ok(fs) = self
             .workspace
             .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
         else {
@@ -929,6 +982,8 @@ impl KeymapEditor {
         let Some(context) = context else {
             return;
         };
+
+        telemetry::event!("Keybinding Context Copied", context = context.clone());
         cx.write_to_clipboard(gpui::ClipboardItem::new_string(context.clone()));
     }
 
@@ -944,6 +999,8 @@ impl KeymapEditor {
         let Some(action) = action else {
             return;
         };
+
+        telemetry::event!("Keybinding Action Copied", action = action.clone());
         cx.write_to_clipboard(gpui::ClipboardItem::new_string(action.clone()));
     }
 
@@ -2222,6 +2279,9 @@ async fn save_keybinding_update(
             from: Some(target),
         }
     };
+
+    let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
+
     let updated_keymap_contents =
         settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
             .context("Failed to update keybinding")?;
@@ -2231,6 +2291,13 @@ async fn save_keybinding_update(
     )
     .await
     .context("Failed to write keymap file")?;
+
+    telemetry::event!(
+        "Keybinding Updated",
+        new_keybinding = new_keybinding,
+        removed_keybinding = removed_keybinding,
+        source = source
+    );
     Ok(())
 }
 
@@ -2266,6 +2333,7 @@ async fn remove_keybinding(
             .unwrap_or(KeybindSource::User),
     };
 
+    let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry();
     let updated_keymap_contents =
         settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size)
             .context("Failed to update keybinding")?;
@@ -2275,6 +2343,13 @@ async fn remove_keybinding(
     )
     .await
     .context("Failed to write keymap file")?;
+
+    telemetry::event!(
+        "Keybinding Removed",
+        new_keybinding = new_keybinding,
+        removed_keybinding = removed_keybinding,
+        source = source
+    );
     Ok(())
 }