Skip to content

Commit a16baff

Browse files
committed
feat(telemetry): add PostHog analytics with runtime opt-out control
Instrument key app lifecycle and reliability events with PostHog, and add a persisted Settings toggle that immediately enables or disables telemetry capture at runtime. Made-with: Cursor
1 parent 669ab7c commit a16baff

9 files changed

Lines changed: 293 additions & 8 deletions

File tree

bun.lock

Lines changed: 73 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"handlebars": "^4.7.9",
4343
"i18next": "^25.8.19",
4444
"lucide-react": "^0.577.0",
45+
"posthog-js": "^1.370.0",
4546
"react": "^19.1.0",
4647
"react-dom": "^19.1.0",
4748
"react-i18next": "^16.5.8",

src/mainview/App.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { useEffect, useState } from 'react'
2-
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
1+
import { useEffect, useRef, useState } from 'react'
2+
import { HashRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
33
import { useQueryClient } from '@tanstack/react-query'
44
import { listen, invoke } from '@/mainview/lib/native'
55
import { useTranslation } from 'react-i18next'
6+
import {
7+
captureTelemetry,
8+
identifyTelemetry,
9+
setTelemetryEnabled,
10+
} from '@/mainview/lib/telemetry'
611
import Layout from './components/Layout'
712
import Dashboard from './pages/Dashboard'
813
import SkillsManager from './pages/SkillsManager'
@@ -22,8 +27,11 @@ const STAR_PROMPT_RESHOW_MS = 3 * 24 * 60 * 60 * 1000
2227

2328
function AppInner() {
2429
const queryClient = useQueryClient()
30+
const location = useLocation()
2531
const { i18n } = useTranslation()
2632
useTheme()
33+
const appOpenedTrackedRef = useRef(false)
34+
const [telemetryReady, setTelemetryReady] = useState(false)
2735
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
2836
const [showGithubStarPrompt, setShowGithubStarPrompt] = useState(false)
2937
// Onboarding shows on very first launch (or when user explicitly replays it
@@ -37,6 +45,25 @@ function AppInner() {
3745
}
3846
})
3947

48+
useEffect(() => {
49+
if (!telemetryReady || appOpenedTrackedRef.current) return
50+
appOpenedTrackedRef.current = true
51+
captureTelemetry('app_opened')
52+
void invoke('get_app_version')
53+
.then((version) => {
54+
identifyTelemetry(`desktop:${version}`, { app_version: version })
55+
})
56+
.catch(() => {})
57+
}, [telemetryReady])
58+
59+
useEffect(() => {
60+
if (!telemetryReady) return
61+
captureTelemetry('page_view', {
62+
path: location.pathname,
63+
search: location.search,
64+
})
65+
}, [location.pathname, location.search, telemetryReady])
66+
4067
useEffect(() => {
4168
const handler = (event: Event) => {
4269
const detail = (event as CustomEvent<{ force?: boolean }>).detail
@@ -96,8 +123,12 @@ function AppInner() {
96123
if (lang && lang !== i18n.language) {
97124
void i18n.changeLanguage(lang)
98125
}
126+
setTelemetryEnabled(settings.analytics_enabled !== false)
127+
setTelemetryReady(true)
128+
})
129+
.catch(() => {
130+
setTelemetryReady(true)
99131
})
100-
.catch(() => {})
101132
}, []) // eslint-disable-line react-hooks/exhaustive-deps
102133

103134
// Ask for a GitHub star once after meaningful usage cadence.
@@ -161,6 +192,7 @@ function AppInner() {
161192

162193
const handleGithubStarDismiss = () => {
163194
setShowGithubStarPrompt(false)
195+
captureTelemetry('github_star_prompt_dismissed')
164196
void invoke('read_settings')
165197
.then((settings) => {
166198
const existing = settings.github_star_prompt ?? {}
@@ -182,6 +214,7 @@ function AppInner() {
182214

183215
const handleGithubStarClick = () => {
184216
setShowGithubStarPrompt(false)
217+
captureTelemetry('github_star_prompt_clicked')
185218
void invoke('open_external', { url: GITHUB_REPO_URL })
186219
void invoke('read_settings')
187220
.then((settings) =>
@@ -198,6 +231,12 @@ function AppInner() {
198231
.catch(() => {})
199232
}
200233

234+
useEffect(() => {
235+
if (showGithubStarPrompt) {
236+
captureTelemetry('github_star_prompt_shown')
237+
}
238+
}, [showGithubStarPrompt])
239+
201240
useEffect(() => {
202241
let cancelled = false
203242
let unlisten: (() => void) | undefined

src/mainview/i18n/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ const en = {
299299
"Blur is off because window blur was disabled via environment variable. Your choice is still saved for next launch without it.",
300300
// Language
301301
language: "Language",
302+
// Analytics
303+
analytics: "Telemetry",
304+
analyticsDescription:
305+
"Anonymous product analytics and diagnostics powered by PostHog. Disable this to stop tracking events from this app.",
306+
analyticsOn: "On",
307+
analyticsOff: "Off",
302308
// App Updates
303309
appUpdates: "App Updates",
304310
updateStateIdle: "Up to date",

src/mainview/lib/native.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { isTrpcQueryProcedure } from '@/shared/trpc-query-procedures'
22
import type { AppRPCSchema } from '@/shared/rpc-schema'
3+
import { captureTelemetry } from '@/mainview/lib/telemetry'
34

45
/**
56
* Renderer-side glue to the main process.
@@ -231,11 +232,32 @@ export async function invoke<K extends keyof BunRequests>(
231232
): Promise<BunRequests[K]['response']> {
232233
const name = cmd as string
233234
const input = args[0]
234-
return callTrpcProcedure<BunRequests[K]['response']>(
235-
name,
236-
input,
237-
isTrpcQueryProcedure(name),
238-
)
235+
const isQuery = isTrpcQueryProcedure(name)
236+
const startedAt = performance.now()
237+
try {
238+
const response = await callTrpcProcedure<BunRequests[K]['response']>(
239+
name,
240+
input,
241+
isQuery,
242+
)
243+
if (!isQuery) {
244+
captureTelemetry('rpc_mutation_called', {
245+
command: name,
246+
duration_ms: Math.round(performance.now() - startedAt),
247+
})
248+
}
249+
return response
250+
} catch (error) {
251+
const message =
252+
error instanceof Error ? error.message.slice(0, 240) : 'unknown'
253+
captureTelemetry('rpc_call_failed', {
254+
command: name,
255+
is_query: isQuery,
256+
duration_ms: Math.round(performance.now() - startedAt),
257+
message,
258+
})
259+
throw error
260+
}
239261
}
240262

241263
export async function listen<T>(

src/mainview/lib/telemetry.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import posthog from 'posthog-js'
2+
3+
const DEFAULT_POSTHOG_KEY = 'phc_q6DHwwvAXc2XeGRFsZ3qQiyF5p5gbXqLJduYm4kR742G'
4+
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
5+
6+
const env = import.meta as ImportMeta & {
7+
env?: {
8+
VITE_POSTHOG_KEY?: string
9+
VITE_POSTHOG_HOST?: string
10+
MODE?: string
11+
}
12+
}
13+
14+
const TELEMETRY_DISABLED =
15+
(env.env?.VITE_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY).trim().length === 0
16+
17+
let initialized = false
18+
let telemetryEnabled = false
19+
20+
export function initTelemetry(): void {
21+
if (initialized || TELEMETRY_DISABLED) return
22+
initialized = true
23+
24+
posthog.init(env.env?.VITE_POSTHOG_KEY ?? DEFAULT_POSTHOG_KEY, {
25+
api_host: env.env?.VITE_POSTHOG_HOST ?? DEFAULT_POSTHOG_HOST,
26+
capture_pageview: false,
27+
capture_pageleave: true,
28+
autocapture: true,
29+
person_profiles: 'identified_only',
30+
persistence: 'localStorage+cookie',
31+
loaded: (client) => {
32+
client.register({
33+
app: 'skiller-desktop',
34+
app_env: env.env?.MODE ?? 'unknown',
35+
})
36+
if (!telemetryEnabled) {
37+
client.opt_out_capturing()
38+
}
39+
},
40+
})
41+
}
42+
43+
export function setTelemetryEnabled(enabled: boolean): void {
44+
telemetryEnabled = enabled
45+
if (TELEMETRY_DISABLED || !initialized) return
46+
try {
47+
if (enabled) {
48+
posthog.opt_in_capturing()
49+
} else {
50+
posthog.opt_out_capturing()
51+
}
52+
} catch {
53+
// Keep telemetry non-blocking for UX and app flow.
54+
}
55+
}
56+
57+
export function isTelemetryEnabled(): boolean {
58+
return telemetryEnabled
59+
}
60+
61+
export function captureTelemetry(
62+
event: string,
63+
properties?: Record<string, unknown>,
64+
): void {
65+
if (TELEMETRY_DISABLED || !initialized || !telemetryEnabled) return
66+
try {
67+
posthog.capture(event, properties)
68+
} catch {
69+
// Keep telemetry non-blocking for UX and app flow.
70+
}
71+
}
72+
73+
export function identifyTelemetry(
74+
distinctId: string,
75+
properties?: Record<string, unknown>,
76+
): void {
77+
if (TELEMETRY_DISABLED || !initialized || !telemetryEnabled) return
78+
try {
79+
posthog.identify(distinctId, properties)
80+
} catch {
81+
// Keep telemetry non-blocking for UX and app flow.
82+
}
83+
}

src/mainview/main.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,30 @@ import ReactDOM from 'react-dom/client'
44
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
55
import App from '@/mainview/App'
66
import { ToastProvider } from '@/mainview/components/ToastProvider'
7+
import { captureTelemetry, initTelemetry } from '@/mainview/lib/telemetry'
78
import '@/mainview/i18n'
89
import '@/mainview/index.css'
910

1011
const queryClient = new QueryClient()
12+
initTelemetry()
13+
14+
window.addEventListener('error', (event) => {
15+
captureTelemetry('renderer_error', {
16+
message: event.message || 'unknown',
17+
source: event.filename || 'unknown',
18+
line: event.lineno || 0,
19+
})
20+
})
21+
22+
window.addEventListener('unhandledrejection', (event) => {
23+
const reason =
24+
event.reason instanceof Error
25+
? event.reason.message
26+
: typeof event.reason === 'string'
27+
? event.reason
28+
: 'unknown'
29+
captureTelemetry('renderer_unhandled_rejection', { reason })
30+
})
1131

1232
/* ── Global mouse tracker for liquid-glass highlight (one listener; HMR dispose removes the previous) ── */
1333
{

src/mainview/pages/Settings.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
AlertTriangle,
1414
} from 'lucide-react'
1515
import { openUrl, invoke, listen } from '@/mainview/lib/native'
16+
import { setTelemetryEnabled } from '@/mainview/lib/telemetry'
1617
import type {
1718
AppUpdateStatusJson,
1819
} from '@/shared/rpc-schema'
@@ -27,6 +28,7 @@ interface AppSettings {
2728
language: string | null
2829
path_overrides: Record<string, string[]> | null
2930
close_action: string | null
31+
analytics_enabled?: boolean | null
3032
macos_window_blur?: boolean | null
3133
assumed_listing_char_budget?: number | null
3234
assumed_context_window_chars?: number | null
@@ -37,6 +39,7 @@ const DEFAULT_SETTINGS: AppSettings = {
3739
language: null,
3840
path_overrides: null,
3941
close_action: null,
42+
analytics_enabled: null,
4043
macos_window_blur: null,
4144
}
4245

@@ -425,6 +428,42 @@ export default function SettingsPage() {
425428
</div>
426429
</section>
427430

431+
{/* Analytics */}
432+
<section className="rounded-2xl p-5 glass-panel settings-panel space-y-3">
433+
<div className="flex items-start justify-between gap-4">
434+
<div className="min-w-0">
435+
<h2 className="text-sm font-medium">{t('settings.analytics')}</h2>
436+
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
437+
{t('settings.analyticsDescription')}
438+
</p>
439+
</div>
440+
<div className="flex shrink-0 gap-1.5">
441+
{([true, false] as const).map((enabled) => {
442+
const currentEnabled = settings?.analytics_enabled !== false
443+
const isActive = currentEnabled === enabled
444+
return (
445+
<Button
446+
key={enabled ? 'on' : 'off'}
447+
variant={isActive ? 'default' : 'outline'}
448+
size="sm"
449+
onClick={() => {
450+
setTelemetryEnabled(enabled)
451+
saveMutation.mutate({
452+
...(settings ?? DEFAULT_SETTINGS),
453+
analytics_enabled: enabled,
454+
})
455+
}}
456+
>
457+
{enabled
458+
? t('settings.analyticsOn')
459+
: t('settings.analyticsOff')}
460+
</Button>
461+
)
462+
})}
463+
</div>
464+
</div>
465+
</section>
466+
428467
{/* Close Behavior */}
429468
<section className="rounded-2xl p-5 glass-panel settings-panel space-y-3">
430469
<div className="flex items-start justify-between gap-4">

src/shared/rpc-schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export type AppSettingsJson = {
9595
assumed_listing_char_budget?: number | null;
9696
/** Optional context window size (chars); 1% can derive a listing budget when budget is unset. */
9797
assumed_context_window_chars?: number | null;
98+
/** Product telemetry and analytics (PostHog). Default true when omitted. */
99+
analytics_enabled?: boolean | null;
98100
/** One-time GitHub star prompt cadence metadata. */
99101
github_star_prompt?: {
100102
first_seen_at?: string | null;

0 commit comments

Comments
 (0)