Skip to content

Commit 09d3fe2

Browse files
authored
Merge branch 'main' into release-please--branches--main--components--mcpmux
2 parents 154c5ea + cbebd05 commit 09d3fe2

14 files changed

Lines changed: 491 additions & 9 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: 0 additions & 7 deletions
This file was deleted.

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)