diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 572e9a6f..2d9816d0 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -16,6 +16,8 @@ ## Event catalog +All PostHog events include `app_version` automatically. Main-process events add it in `packages/core/src/analytics/posthog.ts`; renderer events get it from the `analytics:bootstrap` IPC payload and an initialization-time `before_send` hook. + ### `llm_usage` Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). @@ -101,6 +103,7 @@ Persistent across sessions for the same user. Set via `posthog.people.set` or as | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations | | `plan`, `status` | main on identify | Subscription state | | `api_url` | both processes (init + identify) | Distinguishes prod / staging / custom — assign meaning in PostHog dashboard. `https://api.x.rowboatlabs.com` = production | +| `app_version` | both processes (init + identify) | Electron app version; also included automatically on every event | | `signed_in` | renderer | `true` while rowboat OAuth is connected | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `total_notes` | renderer (init) | Workspace size signal | diff --git a/apps/x/apps/main/bundle.mjs b/apps/x/apps/main/bundle.mjs index 9ae77e0e..976e8db3 100644 --- a/apps/x/apps/main/bundle.mjs +++ b/apps/x/apps/main/bundle.mjs @@ -10,11 +10,13 @@ */ import * as esbuild from 'esbuild'; +import { readFile } from 'node:fs/promises'; // In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // The banner defines __import_meta_url at the top of the bundle, // and we use define to replace all import.meta.url references with it. const cjsBanner = `var __import_meta_url = require('url').pathToFileURL(__filename).href;`; +const pkg = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')); await esbuild.build({ entryPoints: ['./dist/main.js'], @@ -36,6 +38,7 @@ await esbuild.build({ // 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'), + 'process.env.ROWBOAT_APP_VERSION': JSON.stringify(pkg.version ?? ''), }, }); diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2f5730ce..ec2803aa 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; import os from 'node:os'; @@ -455,6 +455,7 @@ export function setupIpcHandlers() { return { installationId: getInstallationId(), apiUrl: API_URL, + appVersion: app.getVersion(), }; }, 'workspace:getRoot': async () => { diff --git a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts index 82220782..5bc5cec0 100644 --- a/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts +++ b/apps/x/apps/renderer/src/hooks/useAnalyticsIdentity.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react' import posthog from 'posthog-js' +import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics' /** * Identifies the user in PostHog when signed into Rowboat, @@ -17,7 +18,7 @@ export function useAnalyticsIdentity() { // Identify if Rowboat account is connected const rowboat = config.rowboat if (rowboat?.connected && rowboat?.userId) { - posthog.identify(rowboat.userId) + identifyUser(rowboat.userId) } // Set provider connection flags @@ -69,7 +70,7 @@ export function useAnalyticsIdentity() { // Rowboat sign-in if (event.success) { if (event.userId) { - posthog.identify(event.userId) + identifyUser(event.userId) } posthog.people.set({ signed_in: true, rowboat_connected: true }) posthog.capture('user_signed_in') @@ -80,7 +81,7 @@ export function useAnalyticsIdentity() { // 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() + resetAnalyticsIdentity() }) return cleanup diff --git a/apps/x/apps/renderer/src/lib/analytics.ts b/apps/x/apps/renderer/src/lib/analytics.ts index 672ea0c3..de837bab 100644 --- a/apps/x/apps/renderer/src/lib/analytics.ts +++ b/apps/x/apps/renderer/src/lib/analytics.ts @@ -1,5 +1,42 @@ import posthog from 'posthog-js' +let appVersion: string | undefined +let apiUrl: string | undefined + +function appVersionProperties(): Record { + return appVersion ? { app_version: appVersion } : {} +} + +export function configureAnalyticsContext(props: { appVersion?: string; apiUrl?: string }) { + appVersion = props.appVersion?.trim() || undefined + apiUrl = props.apiUrl?.trim() || undefined + + const eventProperties = appVersionProperties() + if (Object.keys(eventProperties).length > 0) { + posthog.register(eventProperties) + } + + const personProperties = { + ...(apiUrl ? { api_url: apiUrl } : {}), + ...eventProperties, + } + if (Object.keys(personProperties).length > 0) { + posthog.people.set(personProperties) + } +} + +export function identifyUser(userId: string, properties?: Record) { + posthog.identify(userId, { + ...properties, + ...appVersionProperties(), + }) +} + +export function resetAnalyticsIdentity() { + posthog.reset() + configureAnalyticsContext({ appVersion, apiUrl }) +} + export function chatSessionCreated(runId: string) { posthog.capture('chat_session_created', { run_id: runId }) } diff --git a/apps/x/apps/renderer/src/main.tsx b/apps/x/apps/renderer/src/main.tsx index fedc029c..7999061d 100644 --- a/apps/x/apps/renderer/src/main.tsx +++ b/apps/x/apps/renderer/src/main.tsx @@ -2,9 +2,10 @@ 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 type { CaptureResult } from 'posthog-js' import { ThemeProvider } from '@/contexts/theme-context' +import { configureAnalyticsContext } from './lib/analytics' // Fetch the stable installation ID from main so renderer + main share one // PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID @@ -12,19 +13,36 @@ import { ThemeProvider } from '@/contexts/theme-context' async function bootstrap() { let installationId: string | undefined let apiUrl: string | undefined + let appVersion: string | undefined try { const result = await window.ipc.invoke('analytics:bootstrap', null) installationId = result.installationId apiUrl = result.apiUrl + appVersion = result.appVersion } catch (err) { console.error('[Analytics] Failed to bootstrap from main:', err) } + configureAnalyticsContext({ apiUrl, appVersion }) + const options = { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: '2025-11-30', + defaults: '2025-11-30' as const, ...(installationId ? { bootstrap: { distinctID: installationId } } : {}), - } as const + before_send: (event: CaptureResult | null) => { + if (!event) return event + if (appVersion) { + event.properties = { + ...event.properties, + app_version: appVersion, + } + } + return event + }, + loaded: () => { + configureAnalyticsContext({ apiUrl, appVersion }) + }, + } createRoot(document.getElementById('root')!).render( @@ -36,11 +54,7 @@ async function bootstrap() { , ) - // Tag the active person record with api_url so anonymous users are also - // segmentable by environment. - if (apiUrl) { - posthog.people.set({ api_url: apiUrl }) - } + // The loaded callback applies api_url/app_version once PostHog has initialized. } bootstrap() diff --git a/apps/x/packages/core/src/analytics/posthog.ts b/apps/x/packages/core/src/analytics/posthog.ts index 156194d9..d3d1e55c 100644 --- a/apps/x/packages/core/src/analytics/posthog.ts +++ b/apps/x/packages/core/src/analytics/posthog.ts @@ -6,6 +6,7 @@ import { API_URL } from '../config/env.js'; // In dev/tsc, fall back to process.env so local runs work too. const POSTHOG_KEY = process.env.POSTHOG_KEY ?? process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''; const POSTHOG_HOST = process.env.POSTHOG_HOST ?? process.env.VITE_PUBLIC_POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const APP_VERSION = (process.env.ROWBOAT_APP_VERSION ?? process.env.npm_package_version ?? '').trim(); let client: PostHog | null = null; let initAttempted = false; @@ -29,7 +30,7 @@ function getClient(): PostHog | null { // distinguishes prod / staging / custom — meaning is assigned in PostHog). client.identify({ distinctId: getInstallationId(), - properties: { api_url: API_URL }, + properties: { api_url: API_URL, ...appVersionProperties() }, }); } catch (err) { console.error('[Analytics] Failed to init PostHog:', err); @@ -42,6 +43,10 @@ function activeDistinctId(): string { return identifiedUserId ?? getInstallationId(); } +function appVersionProperties(): Record { + return APP_VERSION ? { app_version: APP_VERSION } : {}; +} + export function capture(event: string, properties?: Record): void { const ph = getClient(); if (!ph) return; @@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record): vo ph.capture({ distinctId: activeDistinctId(), event, - properties, + properties: { + ...properties, + ...appVersionProperties(), + }, }); } catch (err) { console.error('[Analytics] capture failed:', err); @@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record): properties: { ...properties, api_url: API_URL, + ...appVersionProperties(), }, }); identifiedUserId = userId; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index d42e9935..092a4b29 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -38,6 +38,7 @@ const ipcSchemas = { res: z.object({ installationId: z.string(), apiUrl: z.string(), + appVersion: z.string(), }), }, 'workspace:getRoot': {