Skip to content

Commit 72defef

Browse files
committed
feat: add PostHog analytics to desktop app and wire up discover UI tracking
Add anonymous usage analytics to understand active users, OS distribution, app versions, server popularity, and search behavior. Desktop app: - PostHog JS SDK with env-var config (VITE_POSTHOG_KEY/VITE_POSTHOG_HOST) - Core events: app_opened, page_viewed, registry_search, server_installed, server_uninstalled - Super properties: app_version, os, platform - Opt-out toggle in Settings with Zustand persistence - Analytics silently disabled when no PostHog key is set Discover UI: - Wire up trackSearch() on debounced search input - Wire up trackServerView() on server detail pages CI: - Pass VITE_POSTHOG_KEY/HOST secrets to release build Signed-off-by: McpMux <hello@mcpmux.com> Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent b55b1d2 commit 72defef

13 files changed

Lines changed: 486 additions & 2 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ jobs:
177177
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
178178
PKG_CONFIG_PATH: /usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig
179179
APPLE_SIGNING_IDENTITY: ${{ steps.apple-cert.outputs.identity }}
180+
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
181+
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
180182
with:
181183
projectPath: apps/desktop
182184
# Upload to the existing draft release

apps/desktop/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ RUST_LOG="debug"
55
# For production: https://api.mcpmux.com
66
# For local dev: http://localhost:8787
77
MCPMUX_REGISTRY_URL="https://api.mcpmux.com"
8+
9+
# PostHog analytics
10+
VITE_POSTHOG_KEY=""
11+
VITE_POSTHOG_HOST="https://us.i.posthog.com"

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@tauri-apps/plugin-updater": "^2",
2525
"immer": "^11.0.1",
2626
"lucide-react": "^0.561.0",
27+
"posthog-js": "^1.351.4",
2728
"react": "^19.1.0",
2829
"react-dom": "^19.1.0",
2930
"zustand": "^5.0.9"

apps/desktop/src/App.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import { ServerInstallModal } from '@/components/ServerInstallModal';
3333
import { SpaceSwitcher } from '@/components/SpaceSwitcher';
3434
import { ConnectIDEs } from '@/components/ConnectIDEs';
3535
import { useDataSync } from '@/hooks/useDataSync';
36-
import { useAppStore, useActiveSpace, useViewSpace, useTheme } from '@/stores';
36+
import { useAnalytics } from '@/hooks/useAnalytics';
37+
import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics';
38+
import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled } from '@/stores';
3739
import { RegistryPage } from '@/features/registry';
3840
import { FeatureSetsPage } from '@/features/featuresets';
3941
import { ClientsPage } from '@/features/clients';
@@ -111,6 +113,7 @@ function AppContent() {
111113
const setTheme = useAppStore((state) => state.setTheme);
112114
const activeSpace = useActiveSpace();
113115
const viewSpace = useViewSpace();
116+
const analyticsEnabled = useAnalyticsEnabled();
114117

115118
// App version from Rust backend
116119
const [appVersion, setAppVersion] = useState('');
@@ -120,6 +123,36 @@ function AppContent() {
120123
.catch((err) => console.error('Failed to get version:', err));
121124
}, []);
122125

126+
// Initialize analytics once we have the app version
127+
useEffect(() => {
128+
if (!appVersion) return;
129+
initAnalytics(appVersion);
130+
if (analyticsEnabled) {
131+
optIn();
132+
capture('app_opened');
133+
} else {
134+
optOut();
135+
}
136+
}, [appVersion]); // eslint-disable-line react-hooks/exhaustive-deps
137+
138+
// Sync opt-in/out when user toggles analytics
139+
useEffect(() => {
140+
if (!appVersion) return;
141+
if (analyticsEnabled) {
142+
optIn();
143+
} else {
144+
optOut();
145+
}
146+
}, [analyticsEnabled, appVersion]);
147+
148+
// Track domain events (server install/uninstall)
149+
useAnalytics();
150+
151+
// Track page navigation
152+
useEffect(() => {
153+
capture('page_viewed', { page: activeNav });
154+
}, [activeNav]);
155+
123156
// Gateway status for sidebar footer
124157
const [gatewayUrl, setGatewayUrl] = useState<string | null>(null);
125158
const loadGatewayUrl = useCallback(async () => {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useRegistryStore } from '../../stores/registryStore';
1111
import { ServerCard } from './ServerCard';
1212
import { ServerDetailModal } from './ServerDetailModal';
1313
import { useViewSpace } from '@/stores';
14+
import { capture } from '@/lib/analytics';
1415

1516
export function RegistryPage() {
1617
const {
@@ -85,6 +86,9 @@ export function RegistryPage() {
8586
const timer = setTimeout(() => {
8687
if (localSearch !== searchQuery) {
8788
search(localSearch);
89+
if (localSearch.trim()) {
90+
capture('registry_search', { query: localSearch.trim() });
91+
}
8892
}
8993
}, 300);
9094
return () => clearTimeout(timer);

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ import {
2222
Minimize2,
2323
XCircle,
2424
Trash2,
25+
BarChart3,
2526
} from 'lucide-react';
26-
import { useAppStore, useTheme } from '@/stores';
27+
import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores';
2728
import { UpdateChecker } from './UpdateChecker';
2829

2930
interface StartupSettings {
@@ -35,6 +36,8 @@ interface StartupSettings {
3536
export function SettingsPage() {
3637
const theme = useTheme();
3738
const setTheme = useAppStore((state) => state.setTheme);
39+
const analyticsEnabled = useAnalyticsEnabled();
40+
const setAnalyticsEnabled = useAppStore((state) => state.setAnalyticsEnabled);
3841
const [logsPath, setLogsPath] = useState<string>('');
3942
const [openingLogs, setOpeningLogs] = useState(false);
4043
const { toasts, success, error } = useToast();
@@ -302,6 +305,38 @@ export function SettingsPage() {
302305
</CardContent>
303306
</Card>
304307

308+
{/* Analytics Section */}
309+
<Card data-testid="settings-analytics-section">
310+
<CardHeader>
311+
<CardTitle className="flex items-center gap-2">
312+
<BarChart3 className="h-5 w-5" />
313+
Analytics
314+
</CardTitle>
315+
<CardDescription>
316+
Help improve McpMux by sharing anonymous usage data. No personal information is collected.
317+
</CardDescription>
318+
</CardHeader>
319+
<CardContent>
320+
<div className="flex items-center justify-between gap-4">
321+
<div className="flex items-start gap-3 flex-1 min-w-0">
322+
<BarChart3 className="h-5 w-5 mt-0.5 text-[rgb(var(--muted))] flex-shrink-0" />
323+
<div>
324+
<label className="text-sm font-medium">Share Usage Data</label>
325+
<p className="text-xs text-[rgb(var(--muted))] mt-1">
326+
Sends anonymous data like app version, OS, and feature usage to help us prioritize improvements.
327+
Location is approximated from IP by PostHog. No credentials or server configurations are shared.
328+
</p>
329+
</div>
330+
</div>
331+
<Switch
332+
checked={analyticsEnabled}
333+
onCheckedChange={setAnalyticsEnabled}
334+
data-testid="analytics-switch"
335+
/>
336+
</div>
337+
</CardContent>
338+
</Card>
339+
305340
{/* Logs Section */}
306341
<Card>
307342
<CardHeader>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Hook that subscribes to domain events and forwards core metrics to PostHog.
3+
*
4+
* Tracked: server_installed, server_uninstalled
5+
* (app_opened, registry_search, version, OS, location are handled elsewhere)
6+
*/
7+
8+
import { useEffect } from 'react';
9+
import { capture } from '@/lib/analytics';
10+
import { useDomainEvents } from './useDomainEvents';
11+
import type { ServerChangedPayload } from './useDomainEvents';
12+
13+
export function useAnalytics() {
14+
const { subscribe } = useDomainEvents();
15+
16+
useEffect(() => {
17+
return subscribe('server-changed', (payload: ServerChangedPayload) => {
18+
if (payload.action === 'installed') {
19+
capture('server_installed', {
20+
server_id: payload.server_id,
21+
server_name: payload.server_name,
22+
});
23+
} else if (payload.action === 'uninstalled') {
24+
capture('server_uninstalled', {
25+
server_id: payload.server_id,
26+
});
27+
}
28+
});
29+
}, [subscribe]);
30+
}

apps/desktop/src/lib/analytics.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* PostHog analytics for McpMux desktop app.
3+
*
4+
* - Anonymous by default (no PII collected)
5+
* - Opt-out toggle available in Settings
6+
* - PostHog auto-captures geolocation from IP
7+
*/
8+
9+
import posthog from 'posthog-js';
10+
11+
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? '';
12+
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com';
13+
14+
let initialized = false;
15+
16+
/** Initialize PostHog with app-level super properties. */
17+
export function initAnalytics(appVersion: string) {
18+
if (initialized || typeof window === 'undefined') return;
19+
if (!POSTHOG_KEY) return;
20+
21+
posthog.init(POSTHOG_KEY, {
22+
api_host: POSTHOG_HOST,
23+
person_profiles: 'identified_only',
24+
capture_pageview: false,
25+
capture_pageleave: false,
26+
autocapture: false,
27+
persistence: 'localStorage',
28+
});
29+
30+
// Super properties sent with every event
31+
posthog.register({
32+
app_version: appVersion,
33+
os: getOS(),
34+
platform: 'desktop',
35+
});
36+
37+
initialized = true;
38+
}
39+
40+
/** Capture an analytics event (no-op if not initialized or opted out). */
41+
export function capture(event: string, properties?: Record<string, unknown>) {
42+
if (!initialized) return;
43+
posthog.capture(event, properties);
44+
}
45+
46+
/** Opt out of analytics. */
47+
export function optOut() {
48+
if (!initialized) return;
49+
posthog.opt_out_capturing();
50+
}
51+
52+
/** Opt back in to analytics. */
53+
export function optIn() {
54+
if (!initialized) return;
55+
posthog.opt_in_capturing();
56+
}
57+
58+
/** Check if user has opted out. */
59+
export function hasOptedOut(): boolean {
60+
if (!initialized) return false;
61+
return posthog.has_opted_out_capturing();
62+
}
63+
64+
function getOS(): string {
65+
const ua = navigator.userAgent.toLowerCase();
66+
if (ua.includes('win')) return 'windows';
67+
if (ua.includes('mac')) return 'macos';
68+
if (ua.includes('linux')) return 'linux';
69+
return 'unknown';
70+
}

apps/desktop/src/stores/appStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const initialState: AppState = {
99
viewSpaceId: null,
1010
sidebarCollapsed: false,
1111
theme: 'system',
12+
analyticsEnabled: true,
1213
loading: {
1314
spaces: false,
1415
servers: false,
@@ -95,6 +96,11 @@ export const useAppStore = create<AppStore>()(
9596
state.theme = theme;
9697
}),
9798

99+
setAnalyticsEnabled: (enabled) =>
100+
set((state) => {
101+
state.analyticsEnabled = enabled;
102+
}),
103+
98104
// Loading
99105
setLoading: (key, value) =>
100106
set((state) => {
@@ -110,6 +116,7 @@ export const useAppStore = create<AppStore>()(
110116
activeSpaceId: state.activeSpaceId,
111117
sidebarCollapsed: state.sidebarCollapsed,
112118
theme: state.theme,
119+
analyticsEnabled: state.analyticsEnabled,
113120
}),
114121
}
115122
)

apps/desktop/src/stores/selectors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId
77
export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId);
88
export const useTheme = () => useAppStore((state) => state.theme);
99
export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed);
10+
export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled);
1011

1112
// Computed selectors
1213
export const useActiveSpace = (): Space | null => {

0 commit comments

Comments
 (0)