This document outlines the implementation plan for anonymous PostHog analytics, covering the telemetry service, IPC bridge, event instrumentation, privacy settings UI, and test mocks.
The implementation is divided into 5 focused sessions, each with:
- Clear objectives and task list (B- for backend, F- for frontend)
- Definition of done
- Testing criteria for verification
Refer to: docs/prd/PRD_ANALYTICS.md for full product requirements.
Session 1 (TelemetryService + Dependency) -- no deps
Session 2 (IPC Handlers + Preload Bridge + Types) -- blocked by Session 1
Session 3 (Lifecycle Wiring + Onboarding Events) -- blocked by Session 2
Session 4 (Instrument IPC Handler Events) -- blocked by Session 1
Session 5 (Privacy Settings UI) -- blocked by Session 2
┌─────────────────────────────────────────────────────┐
│ Time → │
│ │
│ [S1: TelemetryService + Dep] ──┬──────────────┐ │
│ │ │ │
│ [S2: IPC + Preload] │ │
│ ┌─────┴─────┐ │ │
│ ▼ ▼ ▼ │
│ [S3: Lifecycle [S5: Privacy [S4: IPC │
│ + Onboarding] Settings UI] Events] │
└─────────────────────────────────────────────────────┘
Maximum parallelism: Sessions 4 and 5 are independent of each other (both only need S1/S2).
Recommended serial order: S1 → S2 → S3 → S4 → S5
pnpm add posthog-nodeCreate the TelemetryService singleton in the main process. This is the foundation — all other sessions depend on it.
pnpm add posthog-nodeVerify it appears in package.json dependencies.
Singleton class following the exact pattern of src/main/services/logger.ts (lines 42-237):
import { PostHog } from 'posthog-node'
import { app } from 'electron'
import { randomUUID } from 'crypto'
import { createLogger } from './logger'
const log = createLogger({ component: 'Telemetry' })
const POSTHOG_API_KEY = '<project-api-key>' // TODO: user provides their key
const POSTHOG_HOST = 'https://us.i.posthog.com'
class TelemetryService {
private static instance: TelemetryService | null = null
private client: PostHog | null = null
private distinctId: string | null = null
private enabled = true
static getInstance(): TelemetryService { ... }
init(): void { ... } // Load/generate distinctId, load enabled state, create client
track(event, properties?): void { ... } // No-op if disabled
identify(properties?): void { ... }
setEnabled(enabled): void { ... }
isEnabled(): boolean { ... }
async shutdown(): Promise<void> { ... }
}
export const telemetryService = TelemetryService.getInstance()Key implementation details:
init()readstelemetry_distinct_idandtelemetry_enabledfrom SQLitesettingstable viagetDatabase()- If no
telemetry_distinct_idexists, generate one withcrypto.randomUUID()and store it - Absent
telemetry_enabled= enabled (opt-out default) - PostHog client config:
flushAt: 20,flushInterval: 30000 track()attachesapp_version(fromapp.getVersion()) andplatform(fromprocess.platform) to every eventsetEnabled(false)callsthis.shutdown(), setsclient = null, persiststelemetry_enabled: 'false'to SQLitesetEnabled(true)creates new client, persiststelemetry_enabled: 'true'- Track
telemetry_disabledevent before shutting down when user opts out
In src/main/services/index.ts, add:
export { telemetryService } from './telemetry-service'If no barrel file exists, this step is skipped — direct imports work fine.
-
posthog-nodeis inpackage.jsondependencies -
telemetry-service.tsexists with all 6 methods - Service can be imported without errors:
import { telemetryService } from './telemetry-service' -
pnpm buildsucceeds with no TypeScript errors
pnpm build # Must succeed — verifies TS types and imports resolve
pnpm lint # Must pass — verifies code styleWire the TelemetryService to the renderer via IPC so the renderer can call window.analyticsOps.track(), setEnabled(), and isEnabled().
Add after the existing handler registrations (~line 446), alongside the other ipcMain.handle calls:
import { telemetryService } from './services/telemetry-service'
// Telemetry IPC
ipcMain.handle('telemetry:track', (_event, eventName: string, properties?: Record<string, unknown>) => {
telemetryService.track(eventName, properties)
})
ipcMain.handle('telemetry:setEnabled', (_event, enabled: boolean) => {
telemetryService.setEnabled(enabled)
})
ipcMain.handle('telemetry:isEnabled', () => {
return telemetryService.isEnabled()
})Follow the existing namespace pattern (e.g., settingsOps around lines 159-230). Define the object before the contextBridge.exposeInMainWorld calls:
const analyticsOps = {
track: (event: string, properties?: Record<string, unknown>) =>
ipcRenderer.invoke('telemetry:track', event, properties),
setEnabled: (enabled: boolean) =>
ipcRenderer.invoke('telemetry:setEnabled', enabled),
isEnabled: () =>
ipcRenderer.invoke('telemetry:isEnabled') as Promise<boolean>
}Then expose it in the contextBridge.exposeInMainWorld block (~line 1589-1613):
contextBridge.exposeInMainWorld('analyticsOps', analyticsOps)Inside the declare global { interface Window { ... } } block (~line 107-1048), add:
analyticsOps: {
track: (event: string, properties?: Record<string, unknown>) => Promise<void>
setEnabled: (enabled: boolean) => Promise<void>
isEnabled: () => Promise<boolean>
}Follow the existing pattern (~lines 56-93) for mocking window APIs:
if (!window.analyticsOps) {
Object.defineProperty(window, 'analyticsOps', {
writable: true,
configurable: true,
value: {
track: vi.fn().mockResolvedValue(undefined),
setEnabled: vi.fn().mockResolvedValue(undefined),
isEnabled: vi.fn().mockResolvedValue(true)
}
})
}- Three
ipcMain.handlecalls registered fortelemetry:*channels -
analyticsOpsobject defined and exposed viacontextBridge.exposeInMainWorld - Type declarations added for
window.analyticsOps - Test mock added so existing tests don't break
-
pnpm buildsucceeds -
pnpm testpasses (existing tests unaffected by new mock)
pnpm build # Verifies TS compilation with new types
pnpm lint # Code style
pnpm test # Existing tests still pass with analyticsOps mockInitialize the telemetry service at app startup, track the two lifecycle events (app_launched, app_session_ended), wire shutdown, and add the onboarding_completed renderer event.
In app.whenReady(), immediately after getDatabase() (line 436):
// Initialize telemetry
telemetryService.init()After createWindow() (line 454) and the SDK manager setup (~line 529):
telemetryService.track('app_launched')
telemetryService.identify({
platform: process.platform,
app_version: app.getVersion(),
electron_version: process.versions.electron
})Store app start time at module level:
const appStartTime = Date.now()In app.on('will-quit') (~line 548), add BEFORE closeDatabase() (line 562):
telemetryService.track('app_session_ended', {
session_duration_ms: Date.now() - appStartTime
})
await telemetryService.shutdown()In src/renderer/src/components/setup/AgentSetupGuard.tsx, at the two places where updateSetting('initialSetupComplete', true) is called:
Auto-select path (~line 33-34):
updateSetting('defaultAgentSdk', opencode ? 'opencode' : 'claude-code')
updateSetting('initialSetupComplete', true)
window.analyticsOps.track('onboarding_completed', {
sdk: opencode ? 'opencode' : 'claude-code',
auto_selected: true
})Manual selection path (~line 62-65):
updateSetting('defaultAgentSdk', sdk)
updateSetting('initialSetupComplete', true)
window.analyticsOps.track('onboarding_completed', {
sdk,
auto_selected: false
})-
telemetryService.init()called after DB init inapp.whenReady() -
app_launchedtracked after window creation withidentify()call -
app_session_endedtracked inwill-quitwithsession_duration_ms -
telemetryService.shutdown()called beforecloseDatabase() -
onboarding_completedtracked at both auto-select and manual-select paths -
pnpm buildsucceeds -
pnpm lintpasses
pnpm build
pnpm lint
pnpm test # Existing tests pass — AgentSetupGuard tests use mock analyticsOpsManual verification:
- Run
pnpm dev, check main process logs for "Telemetry initialized" with distinct ID - Check PostHog Live Events dashboard for
app_launchedevent within ~30 seconds - Quit the app, check PostHog for
app_session_endedwithsession_duration_msproperty
Add telemetryService.track() calls to the 9 remaining events across 7 IPC handler files. Each is a single line inserted after the successful operation.
In the db:project:create handler (~lines 40-54), after the project is created and returned:
ipcMain.handle('db:project:create', (_event, data: ProjectCreate) => {
const db = getDatabase()
const project = db.createProject(data)
db.createWorktree({ ... })
telemetryService.track('project_added', { language: data.language ?? undefined })
return project
})In the worktree:create handler (~lines 45-49), after the worktree is created:
ipcMain.handle('worktree:create', async (_event, params: CreateWorktreeParams) => {
const result = await createWorktreeOp(getDatabase(), params)
telemetryService.track('worktree_created')
return result
})In the connect handler (~lines 19-48), after successful connection:
// After successful connect (both opencode and claude-code paths)
telemetryService.track('session_started', {
agent_sdk: session?.agent_sdk ?? 'opencode'
})In the prompt handler (~lines 85-150), after successful dispatch:
telemetryService.track('prompt_sent', {
agent_sdk: sdkId ?? 'opencode'
})In the connection:create handler (~lines 24-36), after creation:
ipcMain.handle('connection:create', async (_event, { worktreeIds }) => {
const db = getDatabase()
const result = createConnectionOp(db, worktreeIds)
telemetryService.track('connection_created')
return result
})In the git:commit handler (~lines 364-380), after successful commit:
const result = await gitService.commit(message)
if (result.success) {
telemetryService.track('git_commit_made')
}
return resultIn the git:push handler (~lines 384-406), after successful push:
const result = await gitService.push(remote, branch, force)
if (result.success) {
telemetryService.track('git_push_made')
}
return resultIn each of the three script handlers:
script:runSetup (~lines 41-64):
telemetryService.track('script_run', { type: 'setup' })script:runProject (~lines 68-91):
telemetryService.track('script_run', { type: 'run' })script:runArchive (~lines 110-125):
telemetryService.track('script_run', { type: 'archive' })In the settings:openWithEditor handler (~lines 42-73), after successful spawn:
spawn(command, [worktreePath], { detached: true, stdio: 'ignore' })
telemetryService.track('worktree_opened_in_editor')
return { success: true }- All 7 handler files import
telemetryService - 9 events tracked at the correct locations with correct properties
- Events only fire on success paths (not on errors)
-
pnpm buildsucceeds -
pnpm lintpasses
pnpm build
pnpm lint
pnpm test # Existing handler tests still passManual verification (run pnpm dev and check PostHog Live Events):
- Add a project → see
project_addedevent - Create a worktree → see
worktree_createdevent - Start a session → see
session_startedwithagent_sdkproperty - Send a prompt → see
prompt_sentwithagent_sdkproperty - Commit in git panel → see
git_commit_madeevent - Run a script → see
script_runwithtypeproperty
Create the Settings > Privacy section with a toggle to opt out of analytics. This is the only user-facing change.
Follow the component pattern of SettingsSecurity.tsx (~lines 69-98 for section header + toggle):
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
export function SettingsPrivacy(): React.JSX.Element {
const [enabled, setEnabled] = useState(true)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
window.analyticsOps.isEnabled().then((val) => {
setEnabled(val)
setLoaded(true)
})
}, [])
const handleToggle = () => {
const newValue = !enabled
setEnabled(newValue)
window.analyticsOps.setEnabled(newValue)
}
if (!loaded) return <div />
return (
<div className="space-y-6">
{/* Section header */}
<div>
<h3 className="text-base font-medium mb-1">Privacy</h3>
<p className="text-sm text-muted-foreground">
Control how Hive collects anonymous usage data
</p>
</div>
{/* Toggle */}
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium">Send anonymous usage analytics</label>
<p className="text-xs text-muted-foreground mt-0.5">
Help improve Hive by sharing anonymous feature usage data
</p>
</div>
<button
role="switch"
aria-checked={enabled}
onClick={handleToggle}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors',
enabled ? 'bg-primary' : 'bg-muted'
)}
>
<span className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform',
enabled ? 'translate-x-4' : 'translate-x-0'
)} />
</button>
</div>
{/* Info box */}
<div className="rounded-md border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">What we collect:</span>{' '}
Feature usage counts, app version, platform (macOS/Windows/Linux).
</p>
<p className="text-xs text-muted-foreground mt-2">
<span className="font-medium text-foreground">What we never collect:</span>{' '}
Project names, file contents, prompts, AI responses, git data, or any personal information.
</p>
</div>
</div>
)
}Add import at top:
import { SettingsPrivacy } from './SettingsPrivacy'
import { Eye } from 'lucide-react'Add to SECTIONS array (~line 14-22) — place it after security:
{ id: 'privacy', label: 'Privacy', icon: Eye },Add to content rendering (~lines 78-84):
{activeSection === 'privacy' && <SettingsPrivacy />}Update the SECTIONS type annotation if it uses as const — ensure 'privacy' is included.
In src/renderer/src/stores/useSettingsStore.ts, add telemetryEnabled to AppSettings (~line 34-82):
// Privacy
telemetryEnabled: booleanAnd its default (~line 84-120):
telemetryEnabled: true,This is a renderer-side cache only. The source of truth is the SQLite setting read directly by TelemetryService.
-
SettingsPrivacy.tsxexists with toggle switch and info box - Settings modal shows "Privacy" section with Eye icon in nav
- Toggle reads initial state from
window.analyticsOps.isEnabled() - Toggle calls
window.analyticsOps.setEnabled()on click - Info box explains what is and isn't collected
-
pnpm buildsucceeds -
pnpm lintpasses -
pnpm testpasses
pnpm build
pnpm lint
pnpm testManual verification:
- Open Settings → "Privacy" section appears in left nav with Eye icon
- Toggle is ON by default
- Toggle OFF → PostHog Live Events shows no further events
- Toggle back ON → events resume
- Restart app with toggle OFF → toggle remains OFF (persisted)
- Info box text is readable and accurate
| Session | Focus | New Files | Modified Files | Events Added |
|---|---|---|---|---|
| 1 | TelemetryService core | telemetry-service.ts |
package.json |
— |
| 2 | IPC + preload + types | — | index.ts (main), index.ts (preload), index.d.ts, test/setup.ts |
— |
| 3 | Lifecycle + onboarding | — | index.ts (main), AgentSetupGuard.tsx |
app_launched, app_session_ended, onboarding_completed |
| 4 | IPC handler events | — | 7 handler files | project_added, worktree_created, session_started, prompt_sent, connection_created, git_commit_made, git_push_made, script_run, worktree_opened_in_editor |
| 5 | Privacy settings UI | SettingsPrivacy.tsx |
SettingsModal.tsx, useSettingsStore.ts |
— |
Total: 2 new files, ~13 modified files, 13 events