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 ## 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` ### `llm_usage`
Emitted whenever ai-sdk returns token usage (one event per LLM call, not per run). 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 | | `email` | main on identify | From `/v1/me`; powers PostHog cohort match + integrations |
| `plan`, `status` | main on identify | Subscription state | | `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 | | `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 | | `signed_in` | renderer | `true` while rowboat OAuth is connected |
| `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` | | `{provider}_connected` | renderer | One of `gmail`, `calendar`, `slack`, `rowboat` |
| `total_notes` | renderer (init) | Workspace size signal | | `total_notes` | renderer (init) | Workspace size signal |

View file

@ -10,11 +10,13 @@
*/ */
import * as esbuild from 'esbuild'; import * as esbuild from 'esbuild';
import { readFile } from 'node:fs/promises';
// In CommonJS, import.meta.url doesn't exist. We need to polyfill it. // 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, // 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. // 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 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({ await esbuild.build({
entryPoints: ['./dist/main.js'], entryPoints: ['./dist/main.js'],
@ -36,6 +38,7 @@ await esbuild.build({
// Empty strings disable analytics gracefully. // Empty strings disable analytics gracefully.
'process.env.POSTHOG_KEY': JSON.stringify(process.env.VITE_PUBLIC_POSTHOG_KEY ?? ''), '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.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 { ipc } from '@x/shared';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
@ -455,6 +455,7 @@ export function setupIpcHandlers() {
return { return {
installationId: getInstallationId(), installationId: getInstallationId(),
apiUrl: API_URL, apiUrl: API_URL,
appVersion: app.getVersion(),
}; };
}, },
'workspace:getRoot': async () => { 'workspace:getRoot': async () => {

View file

@ -1,5 +1,6 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { identifyUser, resetAnalyticsIdentity } from '@/lib/analytics'
/** /**
* Identifies the user in PostHog when signed into Rowboat, * Identifies the user in PostHog when signed into Rowboat,
@ -17,7 +18,7 @@ export function useAnalyticsIdentity() {
// Identify if Rowboat account is connected // Identify if Rowboat account is connected
const rowboat = config.rowboat const rowboat = config.rowboat
if (rowboat?.connected && rowboat?.userId) { if (rowboat?.connected && rowboat?.userId) {
posthog.identify(rowboat.userId) identifyUser(rowboat.userId)
} }
// Set provider connection flags // Set provider connection flags
@ -69,7 +70,7 @@ export function useAnalyticsIdentity() {
// Rowboat sign-in // Rowboat sign-in
if (event.success) { if (event.success) {
if (event.userId) { if (event.userId) {
posthog.identify(event.userId) identifyUser(event.userId)
} }
posthog.people.set({ signed_in: true, rowboat_connected: true }) posthog.people.set({ signed_in: true, rowboat_connected: true })
posthog.capture('user_signed_in') 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. // future events on this device don't get attributed to the prior user.
posthog.people.set({ signed_in: false, rowboat_connected: false }) posthog.people.set({ signed_in: false, rowboat_connected: false })
posthog.capture('user_signed_out') posthog.capture('user_signed_out')
posthog.reset() resetAnalyticsIdentity()
}) })
return cleanup return cleanup

View file

@ -1,5 +1,42 @@
import posthog from 'posthog-js' 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) { export function chatSessionCreated(runId: string) {
posthog.capture('chat_session_created', { run_id: runId }) 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 { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react' import { PostHogProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js'
import { ThemeProvider } from '@/contexts/theme-context' import { ThemeProvider } from '@/contexts/theme-context'
import { configureAnalyticsContext } from './lib/analytics'
// Fetch the stable installation ID from main so renderer + main share one // Fetch the stable installation ID from main so renderer + main share one
// PostHog distinct_id. Falls back to PostHog's auto-generated anonymous ID // 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() { async function bootstrap() {
let installationId: string | undefined let installationId: string | undefined
let apiUrl: string | undefined let apiUrl: string | undefined
let appVersion: string | undefined
try { try {
const result = await window.ipc.invoke('analytics:bootstrap', null) const result = await window.ipc.invoke('analytics:bootstrap', null)
installationId = result.installationId installationId = result.installationId
apiUrl = result.apiUrl apiUrl = result.apiUrl
appVersion = result.appVersion
} catch (err) { } catch (err) {
console.error('[Analytics] Failed to bootstrap from main:', err) console.error('[Analytics] Failed to bootstrap from main:', err)
} }
configureAnalyticsContext({ apiUrl, appVersion })
const options = { const options = {
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
defaults: '2025-11-30', defaults: '2025-11-30' as const,
...(installationId ? { bootstrap: { distinctID: installationId } } : {}), ...(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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
@ -36,11 +54,7 @@ async function bootstrap() {
</StrictMode>, </StrictMode>,
) )
// Tag the active person record with api_url so anonymous users are also // The loaded callback applies api_url/app_version once PostHog has initialized.
// segmentable by environment.
if (apiUrl) {
posthog.people.set({ api_url: apiUrl })
}
} }
bootstrap() 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. // 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_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 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 client: PostHog | null = null;
let initAttempted = false; let initAttempted = false;
@ -29,7 +30,7 @@ function getClient(): PostHog | null {
// distinguishes prod / staging / custom — meaning is assigned in PostHog). // distinguishes prod / staging / custom — meaning is assigned in PostHog).
client.identify({ client.identify({
distinctId: getInstallationId(), distinctId: getInstallationId(),
properties: { api_url: API_URL }, properties: { api_url: API_URL, ...appVersionProperties() },
}); });
} catch (err) { } catch (err) {
console.error('[Analytics] Failed to init PostHog:', err); console.error('[Analytics] Failed to init PostHog:', err);
@ -42,6 +43,10 @@ function activeDistinctId(): string {
return identifiedUserId ?? getInstallationId(); 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 { export function capture(event: string, properties?: Record<string, unknown>): void {
const ph = getClient(); const ph = getClient();
if (!ph) return; if (!ph) return;
@ -49,7 +54,10 @@ export function capture(event: string, properties?: Record<string, unknown>): vo
ph.capture({ ph.capture({
distinctId: activeDistinctId(), distinctId: activeDistinctId(),
event, event,
properties, properties: {
...properties,
...appVersionProperties(),
},
}); });
} catch (err) { } catch (err) {
console.error('[Analytics] capture failed:', err); console.error('[Analytics] capture failed:', err);
@ -68,6 +76,7 @@ export function identify(userId: string, properties?: Record<string, unknown>):
properties: { properties: {
...properties, ...properties,
api_url: API_URL, api_url: API_URL,
...appVersionProperties(),
}, },
}); });
identifiedUserId = userId; identifiedUserId = userId;

View file

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