diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index bcacac6ab..4f17c0404 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -55,6 +55,10 @@ import { focusWorktreePaths, } from "./services/focus/desktop-adapters"; import type { WorkspaceServerService } from "./services/workspace-server/service"; +import { + collectMemorySnapshot, + flattenMemorySnapshot, +} from "./utils/crash-diagnostics"; import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, @@ -129,6 +133,14 @@ function isCrashLoop(): boolean { return recentCrashTimestamps.length >= CRASH_LOOP_THRESHOLD; } +function crashDiagnostics() { + return { + appUptimeSeconds: Math.round(process.uptime()), + chromiumLogTail: readChromiumLogTail(), + ...flattenMemorySnapshot(collectMemorySnapshot(() => app.getAppMetrics())), + }; +} + app.on("render-process-gone", (_event, webContents, details) => { const props = { source: "main", @@ -138,14 +150,15 @@ app.on("render-process-gone", (_event, webContents, details) => { url: webContents.getURL(), title: webContents.getTitle(), webContentsId: String(webContents.id), + ...crashDiagnostics(), }; - log.error("Renderer process gone", { - ...props, - chromiumLogTail: readChromiumLogTail(), - }); + log.error("Renderer process gone", props); posthogNodeAnalytics.captureException( new Error(`Renderer process gone: ${details.reason}`), - props, + { + ...props, + $exception_fingerprint: ["render-process-gone", details.reason], + }, ); posthogNodeAnalytics.flush().catch(() => {}); @@ -185,14 +198,19 @@ app.on("child-process-gone", (_event, details) => { exitCode: String(details.exitCode), serviceName: details.serviceName ?? "", name: details.name ?? "", + ...crashDiagnostics(), }; - log.error("Child process gone", { - ...props, - chromiumLogTail: readChromiumLogTail(), - }); + log.error("Child process gone", props); posthogNodeAnalytics.captureException( new Error(`Child process gone (${details.type}): ${details.reason}`), - props, + { + ...props, + $exception_fingerprint: [ + "child-process-gone", + details.type, + details.reason, + ], + }, ); posthogNodeAnalytics.flush().catch(() => {}); }); diff --git a/apps/code/src/main/platform-adapters/posthog-analytics.test.ts b/apps/code/src/main/platform-adapters/posthog-analytics.test.ts index 1131c97e1..d0e605987 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.test.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.test.ts @@ -93,4 +93,22 @@ describe("posthog-analytics", () => { }), ); }); + + it("stamps the main-owned session id and ignores a caller override", () => { + posthogNodeAnalytics.captureException(new Error("boom"), { + $session_id: "spoofed", + }); + + const [, , props] = mockCaptureException.mock.calls.at(-1) ?? []; + expect(props.$session_id).toBe(posthogNodeAnalytics.getOrCreateSessionId()); + }); + + it("mints a stable valid uuidv7 session id", () => { + const first = posthogNodeAnalytics.getOrCreateSessionId(); + + expect(posthogNodeAnalytics.getOrCreateSessionId()).toBe(first); + expect(first).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); }); diff --git a/apps/code/src/main/platform-adapters/posthog-analytics.ts b/apps/code/src/main/platform-adapters/posthog-analytics.ts index 8a3183b1c..27ef9c0e0 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.ts @@ -4,10 +4,12 @@ import type { } from "@posthog/platform/analytics"; import { PostHog } from "posthog-node"; import { getAppVersion } from "../utils/env"; +import { uuidv7 } from "../utils/uuidv7"; export class PosthogNodeAnalytics implements IAnalytics { private client: PostHog | null = null; private currentUserId: string | null = null; + private sessionId: string | null = null; initialize(): void { if (this.client) { @@ -25,6 +27,8 @@ export class PosthogNodeAnalytics implements IAnalytics { host: apiHost || "https://internal-c.posthog.com", enableExceptionAutocapture: true, }); + + this.getOrCreateSessionId(); } setCurrentUserId(userId: string | null): void { @@ -35,6 +39,13 @@ export class PosthogNodeAnalytics implements IAnalytics { return this.currentUserId; } + getOrCreateSessionId(): string { + if (!this.sessionId) { + this.sessionId = uuidv7(); + } + return this.sessionId; + } + track(eventName: string, properties?: AnalyticsProperties): void { if (!this.client) { return; @@ -83,6 +94,7 @@ export class PosthogNodeAnalytics implements IAnalytics { this.client.captureException(error, distinctId, { team: "posthog-code", ...additionalProperties, + ...(this.sessionId ? { $session_id: this.sessionId } : {}), app_version: getAppVersion(), }); } diff --git a/apps/code/src/main/utils/crash-diagnostics.test.ts b/apps/code/src/main/utils/crash-diagnostics.test.ts new file mode 100644 index 000000000..7a896108a --- /dev/null +++ b/apps/code/src/main/utils/crash-diagnostics.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + collectMemorySnapshot, + flattenMemorySnapshot, +} from "./crash-diagnostics"; + +function metric( + type: string, + workingSetSize: number, + peakWorkingSetSize: number, +): Electron.ProcessMetric { + return { + type, + memory: { workingSetSize, peakWorkingSetSize, privateBytes: 0 }, + } as unknown as Electron.ProcessMetric; +} + +describe("collectMemorySnapshot", () => { + it("sums working set, tracks peak, and groups by process type", () => { + const snapshot = collectMemorySnapshot(() => [ + metric("Browser", 100, 150), + metric("Tab", 200, 500), + metric("Tab", 50, 60), + metric("GPU", 80, 90), + ]); + + expect(snapshot).toEqual({ + totalWorkingSetKb: 430, + peakWorkingSetKb: 500, + processCount: 4, + byType: { Browser: 100, Tab: 250, GPU: 80 }, + }); + }); + + it("returns a zeroed snapshot for no processes", () => { + expect(collectMemorySnapshot(() => [])).toEqual({ + totalWorkingSetKb: 0, + peakWorkingSetKb: 0, + processCount: 0, + byType: {}, + }); + }); + + it("returns undefined instead of throwing (crash handler must not fail)", () => { + expect( + collectMemorySnapshot(() => { + throw new Error("getAppMetrics unavailable"); + }), + ).toBeUndefined(); + }); +}); + +describe("flattenMemorySnapshot", () => { + it("flattens scalars and serializes byType for PostHog", () => { + expect( + flattenMemorySnapshot({ + totalWorkingSetKb: 430, + peakWorkingSetKb: 500, + processCount: 4, + byType: { Browser: 100, Tab: 250, GPU: 80 }, + }), + ).toEqual({ + memoryTotalWorkingSetKb: 430, + memoryPeakWorkingSetKb: 500, + memoryProcessCount: 4, + memoryByType: '{"Browser":100,"Tab":250,"GPU":80}', + }); + }); + + it("returns an empty object when no snapshot was collected", () => { + expect(flattenMemorySnapshot(undefined)).toEqual({}); + }); +}); diff --git a/apps/code/src/main/utils/crash-diagnostics.ts b/apps/code/src/main/utils/crash-diagnostics.ts new file mode 100644 index 000000000..7abf6c649 --- /dev/null +++ b/apps/code/src/main/utils/crash-diagnostics.ts @@ -0,0 +1,48 @@ +export interface MemorySnapshot { + totalWorkingSetKb: number; + peakWorkingSetKb: number; + processCount: number; + byType: Record; +} + +export function collectMemorySnapshot( + getMetrics: () => Electron.ProcessMetric[], +): MemorySnapshot | undefined { + try { + const metrics = getMetrics(); + let totalWorkingSetKb = 0; + let peakWorkingSetKb = 0; + const byType: Record = {}; + for (const metric of metrics) { + const workingSet = metric.memory.workingSetSize; + totalWorkingSetKb += workingSet; + peakWorkingSetKb = Math.max( + peakWorkingSetKb, + metric.memory.peakWorkingSetSize, + ); + byType[metric.type] = (byType[metric.type] ?? 0) + workingSet; + } + return { + totalWorkingSetKb, + peakWorkingSetKb, + processCount: metrics.length, + byType, + }; + } catch { + return undefined; + } +} + +export function flattenMemorySnapshot( + memory: MemorySnapshot | undefined, +): Record { + if (!memory) { + return {}; + } + return { + memoryTotalWorkingSetKb: memory.totalWorkingSetKb, + memoryPeakWorkingSetKb: memory.peakWorkingSetKb, + memoryProcessCount: memory.processCount, + memoryByType: JSON.stringify(memory.byType), + }; +} diff --git a/apps/code/src/main/utils/uuidv7.test.ts b/apps/code/src/main/utils/uuidv7.test.ts new file mode 100644 index 000000000..a2516d686 --- /dev/null +++ b/apps/code/src/main/utils/uuidv7.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { uuidv7 } from "./uuidv7"; + +const UUID_V7 = + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe("uuidv7", () => { + it("produces a valid v7 string (version nibble 7, variant 10)", () => { + for (let i = 0; i < 100; i++) { + expect(uuidv7()).toMatch(UUID_V7); + } + }); + + it("encodes the current time so ids sort in creation order", () => { + const before = Date.now(); + const id = uuidv7(); + const after = Date.now(); + + const stampMs = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16); + expect(stampMs).toBeGreaterThanOrEqual(before); + expect(stampMs).toBeLessThanOrEqual(after); + }); + + it("is unique across rapid calls", () => { + const ids = new Set(Array.from({ length: 1000 }, () => uuidv7())); + expect(ids.size).toBe(1000); + }); + + it("writes the 48-bit millisecond timestamp big-endian into the first 6 bytes", () => { + vi.spyOn(Date, "now").mockReturnValue(0x0123456789ab); + try { + const id = uuidv7(); + expect(id.slice(0, 8)).toBe("01234567"); + expect(id.slice(9, 13)).toBe("89ab"); + } finally { + vi.restoreAllMocks(); + } + }); +}); diff --git a/apps/code/src/main/utils/uuidv7.ts b/apps/code/src/main/utils/uuidv7.ts new file mode 100644 index 000000000..de256136f --- /dev/null +++ b/apps/code/src/main/utils/uuidv7.ts @@ -0,0 +1,19 @@ +import { randomBytes } from "node:crypto"; + +export function uuidv7(): string { + const bytes = randomBytes(16); + const timestamp = Date.now(); + + bytes[0] = Math.floor(timestamp / 2 ** 40) & 0xff; + bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff; + bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff; + bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff; + bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff; + bytes[5] = timestamp & 0xff; + + bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + + const hex = bytes.toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 73d7d3e05..e9df218cc 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -14,6 +14,7 @@ import { container } from "./di/container"; import { buildApplicationMenu } from "./menu"; import type { ElectronMainWindow } from "./platform-adapters/electron-main-window"; import { trpcRouter } from "./trpc/router"; +import { collectMemorySnapshot } from "./utils/crash-diagnostics"; import { isDevBuild } from "./utils/env"; import { logger, readChromiumLogTail } from "./utils/logger"; import { type WindowStateSchema, windowStateStore } from "./utils/store"; @@ -106,6 +107,7 @@ function setupCrashLogging(window: BrowserWindow): void { reason: details.reason, exitCode: details.exitCode, url: window.webContents.getURL(), + memory: collectMemorySnapshot(() => app.getAppMetrics()), chromiumLogTail: readChromiumLogTail(), }); }); @@ -113,6 +115,7 @@ function setupCrashLogging(window: BrowserWindow): void { window.on("unresponsive", () => { log.warn("Window unresponsive", { url: window.webContents.getURL(), + memory: collectMemorySnapshot(() => app.getAppMetrics()), chromiumLogTail: readChromiumLogTail(), }); }); diff --git a/apps/code/src/renderer/contributions/app-boot.contributions.ts b/apps/code/src/renderer/contributions/app-boot.contributions.ts index 194042acd..040573f6e 100644 --- a/apps/code/src/renderer/contributions/app-boot.contributions.ts +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -12,13 +12,21 @@ const log = logger.scope("app-boot"); @injectable() export class AnalyticsBootContribution implements Contribution { start(): void { - initializePostHog(); - trpcClient.os.getAppVersion - .query() - .then(registerAppVersion) - .catch((error) => { - log.warn("Failed to register app version super property", { error }); - }); + void (async () => { + let sessionId: string | undefined; + try { + ({ sessionId } = await trpcClient.analytics.getSessionId.query()); + } catch (error) { + log.warn("Failed to fetch session id from main", { error }); + } + initializePostHog(sessionId); + trpcClient.os.getAppVersion + .query() + .then(registerAppVersion) + .catch((error) => { + log.warn("Failed to register app version super property", { error }); + }); + })(); } } diff --git a/packages/host-router/src/routers/analytics.router.ts b/packages/host-router/src/routers/analytics.router.ts index 3e8425bfe..1ced4d3b6 100644 --- a/packages/host-router/src/routers/analytics.router.ts +++ b/packages/host-router/src/routers/analytics.router.ts @@ -24,6 +24,14 @@ export const analyticsRouter = router({ } }), + getSessionId: publicProcedure + .output(z.object({ sessionId: z.string() })) + .query(({ ctx }) => ({ + sessionId: ctx.container + .get(ANALYTICS_SERVICE) + .getOrCreateSessionId(), + })), + resetUser: publicProcedure.mutation(({ ctx }) => { ctx.container.get(ANALYTICS_SERVICE).resetUser(); }), diff --git a/packages/platform/src/analytics.ts b/packages/platform/src/analytics.ts index 2c5e9d7ec..425dbc6a4 100644 --- a/packages/platform/src/analytics.ts +++ b/packages/platform/src/analytics.ts @@ -6,6 +6,8 @@ export interface IAnalytics { identify(userId: string, properties?: AnalyticsProperties): void; setCurrentUserId(userId: string | null): void; getCurrentUserId(): string | null; + /** Host-owned analytics session id, minted lazily on first request. */ + getOrCreateSessionId(): string; resetUser(): void; captureException( error: unknown, diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.test.ts b/packages/ui/src/shell/posthogAnalyticsImpl.test.ts index 41429827f..37e1cc8c6 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.test.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.test.ts @@ -148,4 +148,29 @@ describe("initializePostHog", () => { expect(mockPosthog.init).not.toHaveBeenCalled(); expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled(); }); + + it("bootstraps posthog with the main-owned session id", async () => { + const { initializePostHog } = await loadAnalytics(); + + initializePostHog("0190abcd-1234-7890-8abc-def012345678"); + + expect(mockPosthog.init).toHaveBeenCalledWith( + "test-key", + expect.objectContaining({ + bootstrap: { sessionID: "0190abcd-1234-7890-8abc-def012345678" }, + session_idle_timeout_seconds: 36_000, + }), + ); + }); + + it("omits bootstrap when no session id is provided", async () => { + const { initializePostHog } = await loadAnalytics(); + + initializePostHog(); + + expect(mockPosthog.init).toHaveBeenCalledWith( + "test-key", + expect.not.objectContaining({ bootstrap: expect.anything() }), + ); + }); }); diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.ts b/packages/ui/src/shell/posthogAnalyticsImpl.ts index a60bea641..e4ddbada6 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.ts @@ -41,7 +41,9 @@ type PendingFlagListener = { // Subscribers added before initializePostHog runs. const pendingFlagListeners = new Set(); -export function initializePostHog() { +const SESSION_IDLE_TIMEOUT_SECONDS = 36_000; + +export function initializePostHog(sessionId?: string) { const apiKey = import.meta.env.VITE_POSTHOG_API_KEY; const apiHost = import.meta.env.VITE_POSTHOG_API_HOST || "https://internal-c.posthog.com"; @@ -56,6 +58,8 @@ export function initializePostHog() { api_host: apiHost, ui_host: uiHost, disable_session_recording: false, + session_idle_timeout_seconds: SESSION_IDLE_TIMEOUT_SECONDS, + ...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}), capture_exceptions: import.meta.env.DEV ? false : {