Skip to content

Commit b9c0225

Browse files
committed
feat(cmd-k): Add comprehensive analytics for the new command palette
Adds richer analytics instrumentation to measure baseline success, understand user behavior, and identify improvement opportunities. New events: - command_palette.closed: tracks how/why users close the palette - command_palette.query: debounced (500ms) search query tracking with result count - command_palette.session: end-to-end summary with duration, completion, drill depth Updated events: - command_palette.opened: adds session_id for funnel correlation - command_palette.action_selected: adds action_type, group, result_index, session_id - command_palette.no_results: adds session_id All events share a session_id for Amplitude funnel analysis.
1 parent 36f6e8a commit b9c0225

File tree

5 files changed

+206
-17
lines changed

5 files changed

+206
-17
lines changed

static/app/actionCreators/modal.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uniqueId from 'lodash/uniqueId';
2+
13
import type {
24
CommandPaletteState,
35
CommandPaletteDispatch,
@@ -160,22 +162,33 @@ export async function toggleCommandPalette(
160162
dispatch: CommandPaletteDispatch,
161163
source: 'button' | 'keyboard'
162164
) {
163-
const {default: Modal, modalCss} =
164-
await import('sentry/components/commandPalette/ui/modal');
165+
const modalModule = await import('sentry/components/commandPalette/ui/modal');
166+
const {default: Modal, modalCss} = modalModule;
165167

166168
function closeCommandPaletteModal() {
167169
dispatch({type: 'toggle modal'});
168170
}
169171

170172
if (state.open) {
173+
modalModule._closeMethod = 'keyboard_toggle';
171174
closeCommandPaletteModal();
172175
closeModal();
173176
} else {
174-
trackAnalytics('command_palette.opened', {organization, source});
175-
dispatch({type: 'toggle modal'});
177+
const sessionId = uniqueId('cmd-palette-');
178+
trackAnalytics('command_palette.opened', {
179+
organization,
180+
source,
181+
session_id: sessionId,
182+
});
183+
dispatch({type: 'toggle modal', session_id: sessionId});
176184
openModal(deps => <Modal {...deps} {...options} />, {
177185
modalCss,
178-
onClose: closeCommandPaletteModal,
186+
onClose: reason => {
187+
if (reason === 'backdrop-click') {
188+
modalModule._closeMethod = 'backdrop_click';
189+
}
190+
closeCommandPaletteModal();
191+
},
179192
});
180193
}
181194
}

static/app/components/commandPalette/ui/commandPalette.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useLayoutEffect, useMemo, useEffect, useCallback} from 'react';
1+
import {Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react';
22
import {preload} from 'react-dom';
33
import {useTheme} from '@emotion/react';
44
import styled from '@emotion/styled';
@@ -65,7 +65,13 @@ type CommandPaletteActionWithPriority = CommandPaletteActionWithKey & {
6565
};
6666

6767
interface CommandPaletteProps {
68-
onAction: (action: Exclude<CommandPaletteActionWithKey, {type: 'group'}>) => void;
68+
onAction: (
69+
action: Exclude<CommandPaletteActionWithKey, {type: 'group'}>,
70+
resultIndex: number,
71+
group: string
72+
) => void;
73+
onQueryTracked?: () => void;
74+
sessionId?: string;
6975
}
7076

7177
export function CommandPalette(props: CommandPaletteProps) {
@@ -76,6 +82,10 @@ export function CommandPalette(props: CommandPaletteProps) {
7682
const state = useCommandPaletteState();
7783
const dispatch = useCommandPaletteDispatch();
7884

85+
// Debounced query tracking (500ms)
86+
const queryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
87+
const lastTrackedQueryRef = useRef('');
88+
7989
// Preload the empty state image so it's ready if/when there are no results
8090
// Guard against non-string imports (e.g. SVG objects in test environments)
8191
if (typeof errorIllustration === 'string') {
@@ -97,6 +107,31 @@ export function CommandPalette(props: CommandPaletteProps) {
97107
[state.query, displayedActions]
98108
);
99109

110+
// Debounced query analytics
111+
useEffect(() => {
112+
if (queryTimerRef.current) {
113+
clearTimeout(queryTimerRef.current);
114+
}
115+
if (state.query.length > 0 && state.query !== lastTrackedQueryRef.current) {
116+
queryTimerRef.current = setTimeout(() => {
117+
lastTrackedQueryRef.current = state.query;
118+
trackAnalytics('command_palette.query', {
119+
organization,
120+
query: state.query,
121+
result_count: filteredActions.length,
122+
session_id: props.sessionId ?? '',
123+
});
124+
props.onQueryTracked?.();
125+
}, 500);
126+
}
127+
return () => {
128+
if (queryTimerRef.current) {
129+
clearTimeout(queryTimerRef.current);
130+
}
131+
};
132+
// eslint-disable-next-line react-hooks/exhaustive-deps
133+
}, [state.query, filteredActions.length]);
134+
100135
const sections = useMemo(
101136
() => groupActionsBySection(filteredActions),
102137
[filteredActions]
@@ -169,23 +204,30 @@ export function CommandPalette(props: CommandPaletteProps) {
169204

170205
const onActionSelection = useCallback(
171206
(key: ReturnType<typeof treeState.collection.getFirstKey> | null) => {
172-
const action = filteredActions.find(a => a.key === key);
207+
const resultIndex = filteredActions.findIndex(a => a.key === key);
208+
const action = resultIndex >= 0 ? filteredActions[resultIndex] : undefined;
173209
if (!action) {
174210
return;
175211
}
176212

213+
const group = action.groupingKey ?? '';
214+
177215
if (action.type === 'group') {
178216
trackAnalytics('command_palette.action_selected', {
179217
organization,
180218
action: action.display.label,
181219
query: state.query,
220+
action_type: 'group',
221+
group,
222+
result_index: resultIndex,
223+
session_id: props.sessionId ?? '',
182224
});
183225
dispatch({type: 'push action', action});
184226
return;
185227
}
186228

187229
dispatch({type: 'trigger action'});
188-
props.onAction(action);
230+
props.onAction(action, resultIndex, group);
189231
},
190232
[filteredActions, dispatch, props, treeState, organization, state.query]
191233
);
@@ -445,7 +487,7 @@ function flattenActions(
445487

446488
function CommandPaletteNoResults() {
447489
const organization = useOrganization();
448-
const {query, action} = useCommandPaletteState();
490+
const {query, action, session_id} = useCommandPaletteState();
449491

450492
useEffect(() => {
451493
const actionLabel =
@@ -456,12 +498,13 @@ function CommandPaletteNoResults() {
456498
organization,
457499
query,
458500
action: actionLabel,
501+
session_id,
459502
});
460503
Sentry.logger.info('Command palette returned no results', {
461504
query,
462505
action: actionLabel,
463506
});
464-
}, [organization, query, action]);
507+
}, [organization, query, action, session_id]);
465508

466509
return (
467510
<Flex

static/app/components/commandPalette/ui/commandPaletteStateContext.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ export type CommandPaletteState = {
1919
input: React.RefObject<HTMLInputElement | null>;
2020
open: boolean;
2121
query: string;
22+
session_id: string;
2223
};
2324

2425
export type CommandPaletteDispatch = React.Dispatch<CommandPaletteAction>;
2526

2627
export type CommandPaletteAction =
27-
| {type: 'toggle modal'}
28+
| {type: 'toggle modal'; session_id?: string}
2829
| {type: 'reset'}
2930
| {query: string; type: 'set query'}
3031
| {action: CommandPaletteActionWithKey; type: 'push action'}
@@ -45,6 +46,7 @@ function commandPaletteReducer(
4546
return {
4647
...state,
4748
open: !state.open,
49+
session_id: action.session_id ?? state.session_id,
4850
};
4951
case 'reset':
5052
return {
@@ -108,6 +110,7 @@ export function CommandPaletteStateProvider({
108110
query: '',
109111
action: null,
110112
open: false,
113+
session_id: '',
111114
});
112115

113116
return (

static/app/components/commandPalette/ui/modal.tsx

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useCallback} from 'react';
1+
import {useCallback, useEffect, useRef} from 'react';
22
import {css} from '@emotion/react';
33

44
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
@@ -17,17 +17,76 @@ import {normalizeUrl} from 'sentry/utils/url/normalizeUrl';
1717
import {useNavigate} from 'sentry/utils/useNavigate';
1818
import {useOrganization} from 'sentry/utils/useOrganization';
1919

20+
/**
21+
* Stores the close method so the unmount effect can include it in analytics.
22+
* Set externally by toggleCommandPalette's onClose callback.
23+
*/
24+
export let _closeMethod = 'escape';
25+
2026
export default function CommandPaletteModal({Body}: ModalRenderProps) {
2127
const navigate = useNavigate();
2228
const organization = useOrganization();
2329

2430
const state = useCommandPaletteState();
25-
const {query} = state;
31+
const {query, session_id} = state;
2632

2733
useDsnLookupActions(query);
2834

35+
const openedAtRef = useRef(Date.now());
36+
const actionsSelectedRef = useRef(0);
37+
const queriesTypedRef = useRef(0);
38+
const completedRef = useRef(false);
39+
const stateRef = useRef(state);
40+
stateRef.current = state;
41+
42+
// Reset close method on mount
43+
_closeMethod = 'escape';
44+
45+
// Fire closed + session summary on unmount
46+
useEffect(() => {
47+
const openedAt = openedAtRef.current;
48+
// These refs accumulate values during the modal's lifetime and are intentionally
49+
// read at cleanup time to capture the final session metrics.
50+
const actions = actionsSelectedRef;
51+
const queries = queriesTypedRef;
52+
const done = completedRef;
53+
54+
return () => {
55+
const s = stateRef.current;
56+
let depth = 0;
57+
let node = s.action;
58+
while (node !== null) {
59+
depth++;
60+
node = node.previous;
61+
}
62+
63+
trackAnalytics('command_palette.closed', {
64+
organization,
65+
method: _closeMethod as 'escape',
66+
query: s.query,
67+
had_interaction: s.query.length > 0 || s.action !== null || actions.current > 0,
68+
session_id: s.session_id,
69+
});
70+
71+
trackAnalytics('command_palette.session', {
72+
organization,
73+
session_id: s.session_id,
74+
duration_ms: Date.now() - openedAt,
75+
actions_selected: actions.current,
76+
queries_typed: queries.current,
77+
completed: done.current,
78+
max_drill_depth: depth,
79+
});
80+
};
81+
// eslint-disable-next-line react-hooks/exhaustive-deps
82+
}, []);
83+
2984
const handleSelect = useCallback(
30-
(action: Exclude<CommandPaletteActionWithKey, {type: 'group'}>) => {
85+
(
86+
action: Exclude<CommandPaletteActionWithKey, {type: 'group'}>,
87+
resultIndex: number,
88+
group: string
89+
) => {
3190
const actionType = action.type;
3291
switch (actionType) {
3392
case 'navigate':
@@ -39,7 +98,14 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) {
3998
organization,
4099
action: label,
41100
query,
101+
action_type: actionType,
102+
group,
103+
result_index: resultIndex,
104+
session_id,
42105
});
106+
_closeMethod = 'action_selected';
107+
actionsSelectedRef.current++;
108+
completedRef.current = true;
43109
if (actionType === 'navigate') {
44110
navigate(normalizeUrl(action.to));
45111
} else {
@@ -53,12 +119,18 @@ export default function CommandPaletteModal({Body}: ModalRenderProps) {
53119
}
54120
closeModal();
55121
},
56-
[navigate, organization, state, query]
122+
[navigate, organization, state, query, session_id]
57123
);
58124

59125
return (
60126
<Body>
61-
<CommandPalette onAction={handleSelect} />
127+
<CommandPalette
128+
onAction={handleSelect}
129+
sessionId={session_id}
130+
onQueryTracked={() => {
131+
queriesTypedRef.current++;
132+
}}
133+
/>
62134
</Body>
63135
);
64136
}

0 commit comments

Comments
 (0)