Skip to content

Commit d60a8f8

Browse files
ClaudeOpenSource03Copilotclaude
authored
feat: add privacy-friendly PostHog analytics with opt-out (#3)
* Initial plan * feat: add basic PostHog analytics integration Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> * docs: add PostHog configuration guide and improve API key handling Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> * docs: add PostHog analytics setup guide * Update electron/src/main.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update electron/src/lib/posthog.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: address PR review comments and fix PostHog integration - Fix named export (PostHog, not default) so events actually send - Add posthog-node to tsup externals for proper runtime resolution - Embed public API key (no env var needed for default project) - Replace console.warn with shared logger - Remove as any cast, add analytics fields to renderer AppSettings - Only reinit PostHog when analyticsEnabled actually changes - Persist daily active date to deduplicate across restarts - Remove ANALYTICS.md (redundant with inline docs) - Document dev log path in CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> Co-authored-by: Dejan Žegarac <opensource@thearcadia.xyz> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2db9580 commit d60a8f8

10 files changed

Lines changed: 441 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ pnpm build # tsup (electron/) + Vite (renderer) production build
6262
pnpm start # Run Electron with pre-built dist/
6363
```
6464

65+
**Dev logs**: Main process logs go to `logs/main-{timestamp}.log` (dev) or `{userData}/logs/main-{timestamp}.log` (packaged). Check the latest file with `ls -t logs/main-*.log | head -1 | xargs cat`.
66+
6567
## Architecture
6668

6769
### SDK-Based Session Management
@@ -316,6 +318,7 @@ The three session IPC handlers share extracted utilities:
316318
- **Text overflow** — use `wrap-break-word` on containers with user content
317319
- **No `any`** — use proper types, never `as any`
318320
- **No unsafe `as` casts** — use discriminated unions and type guards instead of `as Record<string, unknown>`
321+
- **No false optionals** — never mark props/parameters as optional (`?`) when they are always provided by every caller. Optional means "sometimes absent" — if every call site passes the value, make it required. Lazy `?` hides broken contracts and leads to unnecessary null checks.
319322
- **pnpm** — always use pnpm for package management
320323
- **Memo optimization** — components use `React.memo` with custom comparators for performance
321324
- **Component decomposition** — large components are split into focused sub-components in subdirectories (git/, tool-renderers/, mcp-renderers/, sidebar/)

electron/src/lib/app-settings.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ export interface AppSettings {
5555
claudeCustomBinaryPath: string;
5656
/** Show developer-only "Dev Fill" button in chat title bar (local dev builds only) */
5757
showDevFillInChatTitleBar: boolean;
58+
/** Enable anonymous analytics to help improve the app (default: true) */
59+
analyticsEnabled: boolean;
60+
/** Anonymous user ID for analytics (auto-generated) */
61+
analyticsUserId?: string;
62+
/** Last date (YYYY-MM-DD) when daily_active_user was sent, to deduplicate across restarts */
63+
analyticsLastDailyActiveDate?: string;
5864
}
5965

6066
const NOTIFICATION_DEFAULTS: NotificationSettings = {
@@ -76,6 +82,7 @@ const DEFAULTS: AppSettings = {
7682
claudeBinarySource: "auto",
7783
claudeCustomBinaryPath: "",
7884
showDevFillInChatTitleBar: false,
85+
analyticsEnabled: true,
7986
};
8087

8188
// ── Internal state ──

electron/src/lib/posthog.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* PostHog analytics client for main process.
3+
*
4+
* Privacy-friendly analytics to track:
5+
* - Daily active users
6+
* - App version usage
7+
* - Basic feature usage (opt-in via settings)
8+
*
9+
* All events use an anonymous user ID generated at first run.
10+
* Users can disable analytics completely in settings.
11+
*/
12+
13+
import { randomUUID } from "crypto";
14+
import { app } from "electron";
15+
import { getAppSettings, setAppSettings } from "./app-settings";
16+
import { log } from "./logger";
17+
18+
// Lazy-loaded PostHog client
19+
let PostHogCtor: typeof import("posthog-node").PostHog | null = null;
20+
let client: import("posthog-node").PostHog | null = null;
21+
let userId: string | null = null;
22+
let lastDailyActiveCheck: string | null = null;
23+
24+
/**
25+
* Initialize PostHog client based on current settings.
26+
* Call this once at app startup, after settings are loaded.
27+
*/
28+
export async function initPostHog(): Promise<void> {
29+
const settings = getAppSettings();
30+
31+
// Don't initialize if analytics is disabled
32+
if (!settings.analyticsEnabled) {
33+
return;
34+
}
35+
36+
// Generate or load anonymous user ID
37+
userId = generateUserId();
38+
39+
try {
40+
// PostHog project API keys are public (client-side) — safe to embed in source.
41+
// Override via POSTHOG_API_KEY env var if needed (e.g. for a fork's own project).
42+
const apiKey = process.env.POSTHOG_API_KEY || "phc_lOKFRov0SWy2R71BNJ2t978tmNYc3ND7WwueOteV5vw";
43+
if (!apiKey) {
44+
log("POSTHOG", "API key not configured — analytics disabled");
45+
return;
46+
}
47+
48+
// Lazy-load posthog-node
49+
const posthogModule = await import("posthog-node");
50+
PostHogCtor = posthogModule.PostHog;
51+
52+
// Initialize client with public PostHog project
53+
client = new PostHogCtor(apiKey, {
54+
host: "https://us.i.posthog.com",
55+
// Flush events every 10 seconds or 20 events, whichever comes first
56+
flushAt: 20,
57+
flushInterval: 10000,
58+
});
59+
60+
log("POSTHOG", `Initialized (userId=${userId})`);
61+
62+
// Track app start event
63+
await captureEvent("app_started", {
64+
version: app.getVersion(),
65+
platform: process.platform,
66+
arch: process.arch,
67+
});
68+
69+
// Track daily active user (once per day)
70+
await trackDailyActive();
71+
} catch (err) {
72+
// Non-fatal - analytics is optional
73+
log("POSTHOG", `Failed to initialize: ${err instanceof Error ? err.message : String(err)}`);
74+
}
75+
}
76+
77+
/**
78+
* Generate or retrieve the anonymous user ID.
79+
* Stored in app settings for persistence across sessions.
80+
*/
81+
function generateUserId(): string {
82+
const settings = getAppSettings();
83+
84+
// Use existing ID if present
85+
if (settings.analyticsUserId) {
86+
return settings.analyticsUserId;
87+
}
88+
89+
// Generate new anonymous ID
90+
const newId = randomUUID();
91+
92+
// Persist to settings
93+
setAppSettings({ analyticsUserId: newId });
94+
95+
return newId;
96+
}
97+
98+
/**
99+
* Track daily active user event (once per day).
100+
* Persists the last-sent date to settings to deduplicate across app restarts.
101+
*/
102+
async function trackDailyActive(): Promise<void> {
103+
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
104+
const settings = getAppSettings();
105+
106+
// Skip if already tracked today (check both in-memory and persisted)
107+
if (lastDailyActiveCheck === today || settings.analyticsLastDailyActiveDate === today) {
108+
lastDailyActiveCheck = today;
109+
return;
110+
}
111+
112+
lastDailyActiveCheck = today;
113+
setAppSettings({ analyticsLastDailyActiveDate: today });
114+
115+
await captureEvent("daily_active_user", {
116+
date: today,
117+
});
118+
}
119+
120+
/**
121+
* Capture a custom event with properties.
122+
*/
123+
export async function captureEvent(
124+
event: string,
125+
properties?: Record<string, unknown>
126+
): Promise<void> {
127+
if (!client || !userId) return;
128+
129+
try {
130+
client.capture({
131+
distinctId: userId,
132+
event,
133+
properties: {
134+
...properties,
135+
// Always include version in all events
136+
app_version: app.getVersion(),
137+
},
138+
});
139+
} catch (err) {
140+
// Non-fatal - analytics should never break the app
141+
log("POSTHOG", `Failed to capture event: ${err instanceof Error ? err.message : String(err)}`);
142+
}
143+
}
144+
145+
/**
146+
* Update user properties (for identifying user characteristics).
147+
*/
148+
export async function identifyUser(
149+
properties: Record<string, unknown>
150+
): Promise<void> {
151+
if (!client || !userId) return;
152+
153+
try {
154+
client.identify({
155+
distinctId: userId,
156+
properties,
157+
});
158+
} catch (err) {
159+
log("POSTHOG", `Failed to identify user: ${err instanceof Error ? err.message : String(err)}`);
160+
}
161+
}
162+
163+
/**
164+
* Shutdown PostHog client (flush pending events, close connections).
165+
* Call this on app quit.
166+
*/
167+
export async function shutdownPostHog(): Promise<void> {
168+
if (!client) return;
169+
170+
try {
171+
await client.shutdown();
172+
client = null;
173+
} catch (err) {
174+
log("POSTHOG", `Failed to shutdown: ${err instanceof Error ? err.message : String(err)}`);
175+
}
176+
}
177+
178+
/**
179+
* Re-initialize PostHog when settings change.
180+
* Call this when user toggles analytics on/off.
181+
*/
182+
export async function reinitPostHog(): Promise<void> {
183+
// Shutdown existing client if any
184+
if (client) {
185+
await shutdownPostHog();
186+
}
187+
188+
// Re-initialize if enabled
189+
await initPostHog();
190+
}
191+
192+
/**
193+
* Check if analytics is currently enabled and initialized.
194+
*/
195+
export function isPostHogEnabled(): boolean {
196+
return client !== null;
197+
}

electron/src/main.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { log } from "./lib/logger";
2323
import { migrateFromOpenAcpUi } from "./lib/migration";
2424
import { glassEnabled, liquidGlass } from "./lib/glass";
2525
import { initAutoUpdater, getIsInstallingUpdate } from "./lib/updater";
26+
import { initPostHog, shutdownPostHog, reinitPostHog } from "./lib/posthog";
2627
import { sessions } from "./ipc/claude-sessions";
2728
import { acpSessions } from "./ipc/acp-sessions";
2829
import { terminals } from "./ipc/terminal";
@@ -42,6 +43,7 @@ import * as acpSessionsIpc from "./ipc/acp-sessions";
4243
import * as codexSessionsIpc from "./ipc/codex-sessions";
4344
import * as mcpIpc from "./ipc/mcp";
4445
import * as settingsIpc from "./ipc/settings";
46+
import { onSettingsChanged } from "./ipc/settings";
4547

4648
// --- Performance: Chromium/V8 flags (must be set before app.whenReady()) ---
4749
app.commandLine.appendSwitch("enable-gpu-rasterization"); // force GPU raster for all content
@@ -179,6 +181,19 @@ codexSessionsIpc.register(getMainWindow);
179181
mcpIpc.register();
180182
settingsIpc.register();
181183

184+
// Listen for analytics settings changes and reinitialize PostHog
185+
let lastAnalyticsEnabled: boolean | undefined;
186+
onSettingsChanged((settings) => {
187+
if (lastAnalyticsEnabled !== undefined && settings.analyticsEnabled !== lastAnalyticsEnabled) {
188+
lastAnalyticsEnabled = settings.analyticsEnabled;
189+
reinitPostHog().catch((err) => {
190+
log("POSTHOG", `Failed to reinitialize PostHog: ${(err as Error).message}`);
191+
});
192+
} else {
193+
lastAnalyticsEnabled = settings.analyticsEnabled;
194+
}
195+
});
196+
182197
// --- DevTools in separate window via remote debugging ---
183198
let devToolsWindow: BrowserWindow | null = null;
184199

@@ -263,13 +278,16 @@ ipcMain.handle("speech:request-mic-permission", async () => {
263278
return { granted: true };
264279
});
265280

266-
app.whenReady().then(() => {
281+
app.whenReady().then(async () => {
267282
// Migrate data from old "OpenACP UI" app directory before anything reads it
268283
migrateFromOpenAcpUi();
269284

270285
createWindow();
271286
initAutoUpdater(getMainWindow);
272287

288+
// Initialize PostHog analytics (if enabled in settings)
289+
await initPostHog();
290+
273291
// Allow microphone access for Whisper voice dictation (getUserMedia in renderer)
274292
session.defaultSession.setPermissionRequestHandler(
275293
(webContents, permission, callback) => {
@@ -305,8 +323,32 @@ app.whenReady().then(() => {
305323
}
306324
});
307325

308-
app.on("will-quit", () => {
326+
app.on("will-quit", (event) => {
309327
globalShortcut.unregisterAll();
328+
329+
// When an update is being installed, let the updater control the quit lifecycle.
330+
// In that case, fire-and-forget PostHog shutdown and do not delay quit.
331+
if (getIsInstallingUpdate()) {
332+
void shutdownPostHog();
333+
return;
334+
}
335+
336+
// For normal quits, delay process exit until PostHog has flushed pending events.
337+
event.preventDefault();
338+
339+
shutdownPostHog()
340+
.catch((err) => {
341+
// Log and continue exit even if analytics shutdown fails
342+
log(
343+
"POSTHOG",
344+
`Error shutting down PostHog: ${
345+
err instanceof Error ? err.message : String(err)
346+
}`,
347+
);
348+
})
349+
.finally(() => {
350+
app.exit(0);
351+
});
310352
});
311353

312354
app.on("window-all-closed", () => {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"konva": "^10.2.0",
4242
"motion": "^12.34.3",
4343
"node-pty": "^1.1.0",
44+
"posthog-node": "^4.3.1",
4445
"react-konva": "^19.2.3",
4546
"refractor": "^5.0.0",
4647
"sonner": "^2.0.7"

0 commit comments

Comments
 (0)