mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-06 13:52:44 +02:00
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:
parent
d42fb26bcc
commit
43c1ba719f
31 changed files with 625 additions and 36 deletions
|
|
@ -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'),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue