diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 6a005d365e..ccc657de28 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -30,6 +30,10 @@ import type { SuspensionService } from "./services/suspension/service"; import type { TaskLinkService } from "./services/task-link/service"; import type { UpdatesService } from "./services/updates/service"; import type { WorkspaceService } from "./services/workspace/service"; +import { + collectMemorySnapshot, + flattenMemorySnapshot, +} from "./utils/crash-diagnostics"; import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, @@ -71,6 +75,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", @@ -80,15 +92,13 @@ app.on("render-process-gone", (_event, webContents, details) => { url: webContents.getURL(), title: webContents.getTitle(), webContentsId: String(webContents.id), + ...crashDiagnostics(), }; - log.error("Renderer process gone", { + log.error("Renderer process gone", props); + captureException(new Error(`Renderer process gone: ${details.reason}`), { ...props, - chromiumLogTail: readChromiumLogTail(), + $exception_fingerprint: ["render-process-gone", details.reason], }); - captureException( - new Error(`Renderer process gone: ${details.reason}`), - props, - ); getPostHogClient() ?.flush() .catch(() => {}); @@ -129,14 +139,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); captureException( new Error(`Child process gone (${details.type}): ${details.reason}`), - props, + { + ...props, + $exception_fingerprint: [ + "child-process-gone", + details.type, + details.reason, + ], + }, ); getPostHogClient() ?.flush() diff --git a/apps/code/src/main/services/posthog-analytics.test.ts b/apps/code/src/main/services/posthog-analytics.test.ts index 64d746d40f..1b27c241fb 100644 --- a/apps/code/src/main/services/posthog-analytics.test.ts +++ b/apps/code/src/main/services/posthog-analytics.test.ts @@ -10,6 +10,7 @@ vi.mock("posthog-node", () => ({ PostHog: MockPostHog })); import { captureException, + getOrCreateSessionId, initializePostHog, resetUser, shutdownPostHog, @@ -97,4 +98,20 @@ describe("posthog-analytics", () => { }), ); }); + + it("stamps the main-owned session id and ignores a caller override", () => { + captureException(new Error("boom"), { $session_id: "spoofed" }); + + const [, , props] = mockCaptureException.mock.calls.at(-1) ?? []; + expect(props.$session_id).toBe(getOrCreateSessionId()); + }); + + it("mints a stable valid uuidv7 session id", () => { + const first = getOrCreateSessionId(); + + expect(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/services/posthog-analytics.ts b/apps/code/src/main/services/posthog-analytics.ts index 6eb43841e3..04868ac94d 100644 --- a/apps/code/src/main/services/posthog-analytics.ts +++ b/apps/code/src/main/services/posthog-analytics.ts @@ -1,8 +1,10 @@ import { PostHog } from "posthog-node"; import { getAppVersion } from "../utils/env"; +import { uuidv7 } from "../utils/uuidv7"; let posthogClient: PostHog | null = null; let currentUserId: string | null = null; +let sessionId: string | null = null; export function initializePostHog() { if (posthogClient) { @@ -21,6 +23,8 @@ export function initializePostHog() { enableExceptionAutocapture: true, }); + getOrCreateSessionId(); + return posthogClient; } @@ -32,6 +36,13 @@ export function getCurrentUserId() { return currentUserId; } +export function getOrCreateSessionId(): string { + if (!sessionId) { + sessionId = uuidv7(); + } + return sessionId; +} + export function trackAppEvent( eventName: string, properties?: Record, @@ -97,6 +108,7 @@ export function captureException( posthogClient.captureException(error, distinctId, { team: "posthog-code", ...additionalProperties, + ...(sessionId ? { $session_id: sessionId } : {}), app_version: getAppVersion(), }); } diff --git a/apps/code/src/main/trpc/routers/analytics.ts b/apps/code/src/main/trpc/routers/analytics.ts index c34744f64b..7b880777c1 100644 --- a/apps/code/src/main/trpc/routers/analytics.ts +++ b/apps/code/src/main/trpc/routers/analytics.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { + getOrCreateSessionId, identifyUser, resetUser, setCurrentUserId, @@ -29,6 +30,10 @@ export const analyticsRouter = router({ } }), + getSessionId: publicProcedure + .output(z.object({ sessionId: z.string() })) + .query(() => ({ sessionId: getOrCreateSessionId() })), + /** * Reset the current user (on logout) */ 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 0000000000..7a896108a1 --- /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 0000000000..7abf6c6495 --- /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 0000000000..a2516d6866 --- /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 0000000000..de256136f4 --- /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 10aa4699d0..cdc01f2f70 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -14,6 +14,7 @@ import { MAIN_TOKENS } from "./di/tokens"; 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"; @@ -110,6 +111,7 @@ function setupCrashLogging(window: BrowserWindow): void { reason: details.reason, exitCode: details.exitCode, url: window.webContents.getURL(), + memory: collectMemorySnapshot(() => app.getAppMetrics()), chromiumLogTail: readChromiumLogTail(), }); }); @@ -117,6 +119,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/App.tsx b/apps/code/src/renderer/App.tsx index 3335551a17..ede0031e0f 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -53,13 +53,21 @@ function App() { // Initialize PostHog analytics and register the app version super property. useEffect(() => { - 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 }); + }); + })(); }, []); // Initialize connectivity monitoring diff --git a/apps/code/src/renderer/utils/analytics.test.ts b/apps/code/src/renderer/utils/analytics.test.ts index c0a329f038..1c29295fa1 100644 --- a/apps/code/src/renderer/utils/analytics.test.ts +++ b/apps/code/src/renderer/utils/analytics.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/apps/code/src/renderer/utils/analytics.ts b/apps/code/src/renderer/utils/analytics.ts index d17665203f..251c9e0071 100644 --- a/apps/code/src/renderer/utils/analytics.ts +++ b/apps/code/src/renderer/utils/analytics.ts @@ -36,7 +36,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"; @@ -51,6 +53,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 : {