Skip to content

Commit 6cd3630

Browse files
committed
feat(analytics): capture settled registry searches with result counts
The registry-search event already debounced past per-keystroke noise, but it logged only the bare query — so a search that found nothing looked identical to one that found a match. Zero-result searches are the clearest signal for which servers users want that the registry doesn't carry yet, so make them visible. - RegistryPage: log one `registry_search` per settled query (1.2s debounce, well past the 300ms search debounce so the synchronous client-side filter has already produced results). Add query_length, results_count, and has_results; gate on `searchQuery === query` so the count matches the logged query. - Add RegistryPageSearchAnalytics.test.tsx: no capture per keystroke, one event per settled query with the count, zero-result flagging, rapid-keystroke coalescing, and whitespace-only ignored. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent cc95fc3 commit 6cd3630

2 files changed

Lines changed: 209 additions & 5 deletions

File tree

apps/desktop/src/features/registry/RegistryPage.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,27 @@ export function RegistryPage() {
9393
return () => clearTimeout(timer);
9494
}, [localSearch, searchQuery, search]);
9595

96-
// Track search analytics with longer debounce to capture final query only
96+
// Track search analytics: one event per *settled* query, never per keystroke.
97+
// The 1.2s debounce sits well past the 300ms search debounce, so by the time
98+
// it fires the synchronous client-side filter has already produced results for
99+
// this exact query — letting us log results_count. Zero-result searches are
100+
// the clearest signal for which servers users want that the registry lacks.
97101
useEffect(() => {
98-
if (!localSearch.trim()) return;
102+
const query = localSearch.trim();
103+
if (!query) return;
99104
const timer = setTimeout(() => {
100-
capture('registry_search', { query: localSearch.trim() });
101-
}, 1500);
105+
// Guard: only log once the executed search reflects what the user typed,
106+
// so results_count corresponds to `query` (not an in-flight edit).
107+
if (searchQuery.trim() !== query) return;
108+
capture('registry_search', {
109+
query,
110+
query_length: query.length,
111+
results_count: displayServers.length,
112+
has_results: displayServers.length > 0,
113+
});
114+
}, 1200);
102115
return () => clearTimeout(timer);
103-
}, [localSearch]);
116+
}, [localSearch, searchQuery, displayServers.length]);
104117

105118
const handleInstall = async (id: string) => {
106119
const server = servers.find((s) => s.id === id);
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* Search-analytics capture for the registry page.
3+
*
4+
* Guards the design we settled on: PostHog must receive ONE `registry_search`
5+
* event per *settled* query — never one per keystroke — and that event must
6+
* carry the result count so zero-result searches (the strongest signal for
7+
* which servers users want) are distinguishable from hits.
8+
*/
9+
10+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11+
import { render, screen, act, fireEvent } from '@testing-library/react';
12+
import type { ServerViewModel } from '@/types/registry';
13+
14+
const { mockCapture } = vi.hoisted(() => ({ mockCapture: vi.fn() }));
15+
16+
vi.mock('@/lib/analytics', () => ({
17+
capture: mockCapture,
18+
initAnalytics: vi.fn(),
19+
optIn: vi.fn(),
20+
optOut: vi.fn(),
21+
hasOptedOut: () => false,
22+
}));
23+
24+
// The real registry store runs against these — resolve empty so mount-time
25+
// loadRegistry settles instantly; we seed servers directly afterwards.
26+
vi.mock('@/lib/api/registry', () => ({
27+
discoverServers: vi.fn().mockResolvedValue([]),
28+
getRegistryUiConfig: vi.fn().mockResolvedValue(null),
29+
getRegistryHomeConfig: vi.fn().mockResolvedValue(null),
30+
listInstalledServers: vi.fn().mockResolvedValue([]),
31+
isRegistryOffline: vi.fn().mockResolvedValue(false),
32+
installServer: vi.fn().mockResolvedValue(undefined),
33+
uninstallServer: vi.fn().mockResolvedValue(undefined),
34+
setServerEnabled: vi.fn().mockResolvedValue(undefined),
35+
}));
36+
37+
vi.mock('@/stores', () => ({
38+
useViewSpace: () => ({ id: 'space-1', name: 'My Space' }),
39+
useNavigateTo: () => () => {},
40+
}));
41+
42+
// Leaf children irrelevant to search analytics — stub to keep the test focused.
43+
vi.mock('@/features/registry/ServerCard', () => ({ ServerCard: () => null }));
44+
vi.mock('@/features/registry/ServerDetailModal', () => ({ ServerDetailModal: () => null }));
45+
vi.mock('@/components/Contribute', () => ({
46+
RequestServerCTA: () => null,
47+
ContributeMenu: () => null,
48+
}));
49+
50+
import { RegistryPage } from '@/features/registry/RegistryPage';
51+
import { useRegistryStore } from '@/stores/registryStore';
52+
53+
function makeServer(overrides: Partial<ServerViewModel> = {}): ServerViewModel {
54+
return {
55+
id: 'com.test-server',
56+
name: 'Test Server',
57+
description: 'A test MCP server',
58+
alias: 'test',
59+
icon: null,
60+
auth: { type: 'none' },
61+
transport: {
62+
type: 'http',
63+
url: 'https://example.com/mcp',
64+
headers: {},
65+
metadata: { inputs: [] },
66+
},
67+
categories: ['developer-tools'],
68+
publisher: null,
69+
source: { type: 'Registry', url: 'https://registry.mcpmux.com', name: 'McpMux Registry' },
70+
is_installed: false,
71+
enabled: false,
72+
oauth_connected: false,
73+
input_values: {},
74+
connection_status: 'disconnected',
75+
missing_required_inputs: false,
76+
last_error: null,
77+
...overrides,
78+
};
79+
}
80+
81+
const SERVERS: ServerViewModel[] = [
82+
makeServer({ id: 'io.github.server', name: 'GitHub', description: 'GitHub MCP server' }),
83+
makeServer({ id: 'com.slack', name: 'Slack', description: 'Slack MCP server' }),
84+
makeServer({ id: 'com.notion', name: 'Notion', description: 'Notion MCP server' }),
85+
];
86+
87+
/** Render, flush the async mount-time loadRegistry, then seed real servers. */
88+
async function renderSeeded() {
89+
render(<RegistryPage />);
90+
// loadRegistry resolves on the microtask queue (api mocks resolve immediately).
91+
await act(async () => {
92+
await Promise.resolve();
93+
await Promise.resolve();
94+
});
95+
act(() => {
96+
useRegistryStore.setState({ servers: SERVERS, displayServers: [] });
97+
});
98+
}
99+
100+
function typeSearch(value: string) {
101+
const input = screen.getByTestId('search-input') as HTMLInputElement;
102+
fireEvent.change(input, { target: { value } });
103+
}
104+
105+
/** Advance past both the 300ms search debounce and the 1200ms analytics debounce. */
106+
function settle() {
107+
act(() => {
108+
vi.advanceTimersByTime(300); // search debounce → store updates, count settles
109+
});
110+
act(() => {
111+
vi.advanceTimersByTime(1300); // analytics debounce → capture fires
112+
});
113+
}
114+
115+
describe('RegistryPage search analytics', () => {
116+
beforeEach(() => {
117+
vi.useFakeTimers();
118+
mockCapture.mockClear();
119+
useRegistryStore.setState({ servers: [], displayServers: [], searchQuery: '' });
120+
});
121+
122+
afterEach(() => {
123+
vi.useRealTimers();
124+
});
125+
126+
it('does not capture on every keystroke', async () => {
127+
await renderSeeded();
128+
typeSearch('g');
129+
typeSearch('gi');
130+
typeSearch('git');
131+
// No debounce elapsed yet — nothing should have been sent.
132+
expect(mockCapture).not.toHaveBeenCalled();
133+
});
134+
135+
it('captures once for a settled query, with the result count', async () => {
136+
await renderSeeded();
137+
typeSearch('github');
138+
settle();
139+
140+
expect(mockCapture).toHaveBeenCalledTimes(1);
141+
expect(mockCapture).toHaveBeenCalledWith('registry_search', {
142+
query: 'github',
143+
query_length: 6,
144+
results_count: 1,
145+
has_results: true,
146+
});
147+
});
148+
149+
it('flags zero-result searches', async () => {
150+
await renderSeeded();
151+
typeSearch('zzzznotathing');
152+
settle();
153+
154+
expect(mockCapture).toHaveBeenCalledTimes(1);
155+
expect(mockCapture).toHaveBeenCalledWith('registry_search', {
156+
query: 'zzzznotathing',
157+
query_length: 13,
158+
results_count: 0,
159+
has_results: false,
160+
});
161+
});
162+
163+
it('coalesces rapid keystrokes into a single event for the final query', async () => {
164+
await renderSeeded();
165+
// Each keystroke lands well within the 1.2s analytics window, so the timer
166+
// keeps resetting and only the final query is ever sent.
167+
typeSearch('s');
168+
act(() => vi.advanceTimersByTime(200));
169+
typeSearch('sl');
170+
act(() => vi.advanceTimersByTime(200));
171+
typeSearch('sla');
172+
act(() => vi.advanceTimersByTime(200));
173+
typeSearch('slack');
174+
settle();
175+
176+
expect(mockCapture).toHaveBeenCalledTimes(1);
177+
expect(mockCapture).toHaveBeenCalledWith('registry_search', {
178+
query: 'slack',
179+
query_length: 5,
180+
results_count: 1,
181+
has_results: true,
182+
});
183+
});
184+
185+
it('ignores a whitespace-only query', async () => {
186+
await renderSeeded();
187+
typeSearch(' ');
188+
settle();
189+
expect(mockCapture).not.toHaveBeenCalled();
190+
});
191+
});

0 commit comments

Comments
 (0)