add posthog analytics for llm usage and auth events

Captures per-LLM-call token usage tagged by feature (copilot chat,
track block, meeting note, knowledge sync), plus sign-in / sign-out
and identity. Renderer and main share one PostHog identity so events
from either process resolve to the same user.

See apps/x/ANALYTICS.md for the event catalog, person properties,
use-case taxonomy, and how to add new events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-04-28 19:53:40 +05:30
parent d42fb26bcc
commit 43c1ba719f
31 changed files with 625 additions and 36 deletions

View file

@ -31,6 +31,11 @@ await esbuild.build({
// Replace import.meta.url directly with our polyfill variable
define: {
'import.meta.url': '__import_meta_url',
// Inject PostHog credentials at build time. Reuse the renderer's
// VITE_PUBLIC_* envs so packaging only needs one set of values.
// Empty strings disable analytics gracefully.
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''),
'process.env.POSTHOG_HOST': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'),
},
});

View file

@ -46,6 +46,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
import { API_URL } from '@x/core/dist/config/env.js';
import {
fetchYaml,
updateTrackBlock,
@ -342,7 +344,7 @@ function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
}
}
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void {
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
@ -415,6 +417,12 @@ export function setupIpcHandlers() {
// args is null for this channel (no request payload)
return getVersions();
},
'analytics:bootstrap': async () => {
return {
installationId: getInstallationId(),
apiUrl: API_URL,
};
},
'workspace:getRoot': async () => {
return workspace.getRoot();
},

View file

@ -26,6 +26,7 @@ import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
import started from "electron-squirrel-startup";
@ -318,4 +319,7 @@ app.on("before-quit", () => {
shutdownLocalSites().catch((error) => {
console.error('[LocalSites] Failed to shut down cleanly:', error);
});
shutdownAnalytics().catch((error) => {
console.error('[Analytics] Failed to flush on quit:', error);
});
});

View file

@ -12,6 +12,7 @@ import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_
import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js';
import { emitOAuthEvent } from './ipc.js';
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
import { capture as analyticsCapture, identify as analyticsIdentify, reset as analyticsReset } from '@x/core/dist/analytics/posthog.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
@ -275,16 +276,33 @@ export async function connectProvider(provider: string, credentials?: { clientId
// For Rowboat sign-in, ensure user + Stripe customer exist before
// notifying the renderer. Without this, parallel API calls from
// multiple renderer hooks race to create the user, causing duplicates.
let signedInUserId: string | undefined;
if (provider === 'rowboat') {
try {
await getBillingInfo();
const billing = await getBillingInfo();
if (billing.userId) {
signedInUserId = billing.userId;
analyticsIdentify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
status: billing.subscriptionStatus,
});
analyticsCapture('user_signed_in', {
plan: billing.subscriptionPlan,
status: billing.subscriptionStatus,
});
}
} catch (meError) {
console.error('[OAuth] Failed to initialize user via /v1/me:', meError);
}
}
// Emit success event to renderer
emitOAuthEvent({ provider, success: true });
emitOAuthEvent({
provider,
success: true,
...(signedInUserId ? { userId: signedInUserId } : {}),
});
} catch (error) {
console.error('OAuth token exchange failed:', error);
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
@ -347,6 +365,10 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
try {
const oauthRepo = getOAuthRepo();
await oauthRepo.delete(provider);
if (provider === 'rowboat') {
analyticsCapture('user_signed_out');
analyticsReset();
}
// Notify renderer so sidebar, voice, and billing re-check state
emitOAuthEvent({ provider, success: false });
return { success: true };

View file

@ -58,15 +58,29 @@ export function useAnalyticsIdentity() {
// Listen for OAuth connect/disconnect events to update identity
useEffect(() => {
const cleanup = window.ipc.on('oauth:didConnect', (event) => {
if (!event.success) return
// If Rowboat provider connected, identify user
if (event.provider === 'rowboat' && event.userId) {
posthog.identify(event.userId)
posthog.people.set({ signed_in: true })
if (event.provider !== 'rowboat') {
// Other providers: just toggle the connection flag
if (event.success) {
posthog.people.set({ [`${event.provider}_connected`]: true })
}
return
}
posthog.people.set({ [`${event.provider}_connected`]: true })
// Rowboat sign-in
if (event.success) {
if (event.userId) {
posthog.identify(event.userId)
}
posthog.people.set({ signed_in: true, rowboat_connected: true })
posthog.capture('user_signed_in')
return
}
// Rowboat sign-out — flip flags, capture, and reset distinct_id so
// future events on this device don't get attributed to the prior user.
posthog.people.set({ signed_in: false, rowboat_connected: false })
posthog.capture('user_signed_out')
posthog.reset()
})
return cleanup

View file

@ -2,20 +2,45 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
import { ThemeProvider } from '@/contexts/theme-context'
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30',
} as const
// Fetch the stable installation ID from main so renderer + main share one
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID
// if the IPC call fails (rare — main is always up before renderer).
async function bootstrap() {
let installationId: string | undefined
let apiUrl: string | undefined
try {
const result = await window.ipc.invoke('analytics:bootstrap', null)
installationId = result.installationId
apiUrl = result.apiUrl
} catch (err) {
console.error('[Analytics] Failed to bootstrap from main:', err)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>
</PostHogProvider>
</StrictMode>,
)
const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30',
...(installationId ? { bootstrap: { distinctID: installationId } } : {}),
} as const
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PostHogProvider apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY} options={options}>
<ThemeProvider defaultTheme="system">
<App />
</ThemeProvider>
</PostHogProvider>
</StrictMode>,
)
// Tag the active person record with api_url so anonymous users are also
// segmentable by environment.
if (apiUrl) {
posthog.people.set({ api_url: apiUrl })
}
}
bootstrap()