Add app version to analytics events

This commit is contained in:
Ramnique Singh 2026-05-29 17:02:01 +05:30
parent 5ae853e15c
commit 732401f72e
8 changed files with 83 additions and 14 deletions

View file

@ -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 |

View file

@ -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 ?? ''),
},
});

View file

@ -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 () => {

View file

@ -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

View file

@ -1,5 +1,42 @@
import posthog from 'posthog-js'
let appVersion: string | undefined
let apiUrl: string | undefined
function appVersionProperties(): Record<string, string> {
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<string, unknown>) {
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 })
}

View file

@ -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(
<StrictMode>
@ -36,11 +54,7 @@ async function bootstrap() {
</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 })
}
// The loaded callback applies api_url/app_version once PostHog has initialized.
}
bootstrap()

View file

@ -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<string, string> {
return APP_VERSION ? { app_version: APP_VERSION } : {};
}
export function capture(event: string, properties?: Record<string, unknown>): void {
const ph = getClient();
if (!ph) return;
@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record<string, unknown>): 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<string, unknown>):
properties: {
...properties,
api_url: API_URL,
...appVersionProperties(),
},
});
identifiedUserId = userId;

View file

@ -38,6 +38,7 @@ const ipcSchemas = {
res: z.object({
installationId: z.string(),
apiUrl: z.string(),
appVersion: z.string(),
}),
},
'workspace:getRoot': {