mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
Add app version to analytics events
This commit is contained in:
parent
5ae853e15c
commit
732401f72e
8 changed files with 83 additions and 14 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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 ?? ''),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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': {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue