From 235758c1a3ca4cf5782ce8ba1902567b316b5b30 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 6 Jun 2026 16:37:28 -0700 Subject: [PATCH 1/4] enrich renderer crash events for error tracking --- apps/code/src/main/index.ts | 43 ++++++++++++---- .../platform-adapters/posthog-analytics.ts | 26 ++++++++++ .../src/main/utils/crash-diagnostics.test.ts | 48 ++++++++++++++++++ apps/code/src/main/utils/crash-diagnostics.ts | 49 +++++++++++++++++++ apps/code/src/main/utils/uuidv7.test.ts | 29 +++++++++++ apps/code/src/main/utils/uuidv7.ts | 29 +++++++++++ apps/code/src/main/window.ts | 5 ++ .../contributions/app-boot.contributions.ts | 26 +++++++--- .../src/routers/analytics.router.ts | 13 +++++ packages/platform/src/analytics.ts | 7 +++ packages/ui/src/shell/posthogAnalyticsImpl.ts | 13 ++++- 11 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 apps/code/src/main/utils/crash-diagnostics.test.ts create mode 100644 apps/code/src/main/utils/crash-diagnostics.ts create mode 100644 apps/code/src/main/utils/uuidv7.test.ts create mode 100644 apps/code/src/main/utils/uuidv7.ts diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index bcacac6ab..428e8e51c 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -55,6 +55,7 @@ import { focusWorktreePaths, } from "./services/focus/desktop-adapters"; import type { WorkspaceServerService } from "./services/workspace-server/service"; +import { collectMemorySnapshot } from "./utils/crash-diagnostics"; import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, @@ -130,6 +131,8 @@ function isCrashLoop(): boolean { } app.on("render-process-gone", (_event, webContents, details) => { + const memory = collectMemorySnapshot(() => app.getAppMetrics()); + const chromiumLogTail = readChromiumLogTail(); const props = { source: "main", type: "render-process-gone", @@ -138,14 +141,22 @@ app.on("render-process-gone", (_event, webContents, details) => { url: webContents.getURL(), title: webContents.getTitle(), webContentsId: String(webContents.id), + appUptimeSeconds: Math.round(process.uptime()), + memoryTotalWorkingSetKb: memory?.totalWorkingSetKb, + memoryPeakWorkingSetKb: memory?.peakWorkingSetKb, + memoryProcessCount: memory?.processCount, + memoryByType: memory ? JSON.stringify(memory.byType) : undefined, }; - log.error("Renderer process gone", { - ...props, - chromiumLogTail: readChromiumLogTail(), - }); + log.error("Renderer process gone", { ...props, chromiumLogTail }); posthogNodeAnalytics.captureException( new Error(`Renderer process gone: ${details.reason}`), - props, + { + ...props, + chromiumLogTail, + // Stack is always this handler, so default grouping collapses every + // renderer death into one issue. Split by reason instead. + $exception_fingerprint: ["render-process-gone", details.reason], + }, ); posthogNodeAnalytics.flush().catch(() => {}); @@ -177,6 +188,8 @@ app.on("render-process-gone", (_event, webContents, details) => { }); app.on("child-process-gone", (_event, details) => { + const memory = collectMemorySnapshot(() => app.getAppMetrics()); + const chromiumLogTail = readChromiumLogTail(); const props = { source: "main", type: "child-process-gone", @@ -185,14 +198,24 @@ app.on("child-process-gone", (_event, details) => { exitCode: String(details.exitCode), serviceName: details.serviceName ?? "", name: details.name ?? "", + appUptimeSeconds: Math.round(process.uptime()), + memoryTotalWorkingSetKb: memory?.totalWorkingSetKb, + memoryPeakWorkingSetKb: memory?.peakWorkingSetKb, + memoryProcessCount: memory?.processCount, + memoryByType: memory ? JSON.stringify(memory.byType) : undefined, }; - log.error("Child process gone", { - ...props, - chromiumLogTail: readChromiumLogTail(), - }); + log.error("Child process gone", { ...props, chromiumLogTail }); posthogNodeAnalytics.captureException( new Error(`Child process gone (${details.type}): ${details.reason}`), - props, + { + ...props, + chromiumLogTail, + $exception_fingerprint: [ + "child-process-gone", + details.type, + details.reason, + ], + }, ); posthogNodeAnalytics.flush().catch(() => {}); }); diff --git a/apps/code/src/main/platform-adapters/posthog-analytics.ts b/apps/code/src/main/platform-adapters/posthog-analytics.ts index 8a3183b1c..24320a935 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) { @@ -35,6 +37,29 @@ export class PosthogNodeAnalytics implements IAnalytics { return this.currentUserId; } + /** + * The PostHog session id is OWNED BY MAIN. Main mints one UUIDv7 and every + * renderer window bootstraps posthog-js with it (`bootstrap.sessionID`). + * Because main outlives the renderer, the id stays stable across a renderer + * crash + reload, so the replay is one continuous session spanning the crash + * and main-captured crash events (the renderer can't report its own OOM) + * always carry the right `$session_id` with no race or hand-off. + * + * Minted lazily on first request (a window asks at boot, before posthog-js + * init) so its UUIDv7 timestamp precedes the session's first event, as + * posthog-js requires. + */ + getOrCreateSessionId(): string { + if (!this.sessionId) { + this.sessionId = uuidv7(); + } + return this.sessionId; + } + + getSessionId(): string | null { + return this.sessionId; + } + track(eventName: string, properties?: AnalyticsProperties): void { if (!this.client) { return; @@ -82,6 +107,7 @@ export class PosthogNodeAnalytics implements IAnalytics { const distinctId = this.currentUserId || "anonymous-app-event"; this.client.captureException(error, distinctId, { team: "posthog-code", + ...(this.sessionId ? { $session_id: this.sessionId } : {}), ...additionalProperties, 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..1f7c5441d --- /dev/null +++ b/apps/code/src/main/utils/crash-diagnostics.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { collectMemorySnapshot } 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(); + }); +}); 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..5b915a0c4 --- /dev/null +++ b/apps/code/src/main/utils/crash-diagnostics.ts @@ -0,0 +1,49 @@ +export interface MemorySnapshot { + totalWorkingSetKb: number; + peakWorkingSetKb: number; + processCount: number; + byType: Record; +} + +/** + * Summarize per-process memory (from `app.getAppMetrics()`, passed in by the + * caller so this stays free of a direct `electron` import) for crash + * diagnostics. Working-set sizes are in KB. Attached to renderer/child crash + * events so PostHog Error Tracking can show whether the app was under memory + * pressure: a hard OOM kills the renderer before it can log anything, so the + * chromium log usually goes silent and this is the only reliable signal. + * + * Defensive on purpose: a throw here would run before the crash handler's + * auto-recovery reload, so failures return `undefined` instead. + * + * Caveat: at `render-process-gone` time the dead renderer is already gone from + * the metrics, so the `Tab` total understates the renderer's real peak. The + * `unresponsive` sample (renderer still alive) is the more telling one. + */ +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; + } +} 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..2df896d19 --- /dev/null +++ b/apps/code/src/main/utils/uuidv7.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } 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(); + + // First 48 bits (first 12 hex chars, minus the dash) are the unix-ms stamp. + 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); + }); +}); diff --git a/apps/code/src/main/utils/uuidv7.ts b/apps/code/src/main/utils/uuidv7.ts new file mode 100644 index 000000000..4983ca679 --- /dev/null +++ b/apps/code/src/main/utils/uuidv7.ts @@ -0,0 +1,29 @@ +import { randomBytes } from "node:crypto"; + +/** + * Generate a UUIDv7 (time-ordered, RFC 9562). posthog-js requires this exact + * format for `bootstrap.sessionID`: a valid v7 whose 48-bit timestamp precedes + * the session's first event. Main mints it before any window starts posthog-js, + * so the ordering holds. + * + * Layout: 48-bit big-endian unix-ms timestamp, 4-bit version (7), 2-bit variant + * (10), 74 random bits. Hand-rolled to avoid a phantom dependency on `uuid` + * (transitive only, and several major versions resolve in the tree). + */ +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..2c7a1bc66 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,13 +107,17 @@ function setupCrashLogging(window: BrowserWindow): void { reason: details.reason, exitCode: details.exitCode, url: window.webContents.getURL(), + memory: collectMemorySnapshot(() => app.getAppMetrics()), chromiumLogTail: readChromiumLogTail(), }); }); + // Unresponsive often precedes an OOM kill, and here the renderer is still + // alive, so this memory sample reflects its real (bloated) footprint. 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..e8307b85a 100644 --- a/apps/code/src/renderer/contributions/app-boot.contributions.ts +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -12,13 +12,25 @@ 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 }); - }); + // Fetch the main-owned session id BEFORE initializing posthog-js so the + // recording shares the id main stamps on crash events. Init is gated on it + // so the id is set before the first event (posthog-js requires the + // bootstrap id's timestamp to precede the session's first event). + 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..5b2235ecd 100644 --- a/packages/host-router/src/routers/analytics.router.ts +++ b/packages/host-router/src/routers/analytics.router.ts @@ -24,6 +24,19 @@ export const analyticsRouter = router({ } }), + /** + * Return the main-owned session id for a window to bootstrap posthog-js with + * (`bootstrap.sessionID`). Main owns it so crash events captured from main + * link to the right replay, and the id survives renderer crash+reload. + */ + 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..79d4370a5 100644 --- a/packages/platform/src/analytics.ts +++ b/packages/platform/src/analytics.ts @@ -6,6 +6,13 @@ export interface IAnalytics { identify(userId: string, properties?: AnalyticsProperties): void; setCurrentUserId(userId: string | null): void; getCurrentUserId(): string | null; + /** + * Stable analytics session id owned by the host process. Minted lazily on + * first request so renderer windows can bootstrap posthog-js with it and + * host-captured crash events link to the same replay session. + */ + getOrCreateSessionId(): string; + getSessionId(): string | null; resetUser(): void; captureException( error: unknown, diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.ts b/packages/ui/src/shell/posthogAnalyticsImpl.ts index a60bea641..02035fef7 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.ts @@ -41,7 +41,12 @@ type PendingFlagListener = { // Subscribers added before initializePostHog runs. const pendingFlagListeners = new Set(); -export function initializePostHog() { +/** + * @param sessionId Main-owned session id (UUIDv7) to bootstrap posthog-js with, + * so the recording shares the id main stamps on crash events. Main owns it so + * it survives a renderer crash+reload as one continuous session. + */ +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 +61,12 @@ export function initializePostHog() { api_host: apiHost, ui_host: uiHost, disable_session_recording: false, + // Hold the session open through long idle (max posthog-js allows) so its + // own rotation doesn't replace main's bootstrapped id mid-run. This app + // sits idle for hours with background tasks, which is exactly when a + // shorter timeout would silently rotate and break crash->replay linking. + session_idle_timeout_seconds: 36000, + ...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}), capture_exceptions: import.meta.env.DEV ? false : { From 12bbee7f710f20c0de117ba93a0451b40934b9ef Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 6 Jun 2026 17:11:51 -0700 Subject: [PATCH 2/4] protect session id and mint before first window --- .../main/platform-adapters/posthog-analytics.test.ts | 11 +++++++++++ .../src/main/platform-adapters/posthog-analytics.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) 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..6b5dfdfc9 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,15 @@ 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)?.[2]; + expect(props.$session_id).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 24320a935..a8567b386 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.ts @@ -27,6 +27,11 @@ export class PosthogNodeAnalytics implements IAnalytics { host: apiHost || "https://internal-c.posthog.com", enableExceptionAutocapture: true, }); + + // Mint the main-owned session id now, before the first window, so crash + // handlers can stamp $session_id even when the renderer crashes during + // startup (before it fetches the id to bootstrap posthog-js). + this.getOrCreateSessionId(); } setCurrentUserId(userId: string | null): void { @@ -107,8 +112,10 @@ export class PosthogNodeAnalytics implements IAnalytics { const distinctId = this.currentUserId || "anonymous-app-event"; this.client.captureException(error, distinctId, { team: "posthog-code", - ...(this.sessionId ? { $session_id: this.sessionId } : {}), ...additionalProperties, + // System-owned fields last so callers can't overwrite them: main owns + // the session id used for crash->replay linking. + ...(this.sessionId ? { $session_id: this.sessionId } : {}), app_version: getAppVersion(), }); } From 298947c8576726fb1c28e6b2a7c4a0ea37c747ff Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 7 Jun 2026 17:52:43 -0700 Subject: [PATCH 3/4] dedupe crash diagnostics and add tests --- apps/code/src/main/index.ts | 38 +++++++++---------- .../posthog-analytics.test.ts | 11 +++++- .../platform-adapters/posthog-analytics.ts | 4 -- .../src/main/utils/crash-diagnostics.test.ts | 27 ++++++++++++- apps/code/src/main/utils/crash-diagnostics.ts | 19 ++++++++++ apps/code/src/main/utils/uuidv7.test.ts | 14 ++++++- packages/platform/src/analytics.ts | 1 - .../ui/src/shell/posthogAnalyticsImpl.test.ts | 25 ++++++++++++ packages/ui/src/shell/posthogAnalyticsImpl.ts | 13 ++++--- 9 files changed, 119 insertions(+), 33 deletions(-) diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 428e8e51c..39983cf99 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -55,7 +55,10 @@ import { focusWorktreePaths, } from "./services/focus/desktop-adapters"; import type { WorkspaceServerService } from "./services/workspace-server/service"; -import { collectMemorySnapshot } from "./utils/crash-diagnostics"; +import { + collectMemorySnapshot, + flattenMemorySnapshot, +} from "./utils/crash-diagnostics"; import { ensureClaudeConfigDir } from "./utils/env"; import { getChromiumLogFilePath, @@ -130,9 +133,18 @@ function isCrashLoop(): boolean { return recentCrashTimestamps.length >= CRASH_LOOP_THRESHOLD; } +// Shared diagnostics attached to every crash event: uptime, the native chromium +// log tail, and flattened memory. A hard OOM kills the renderer before it can +// log, so the memory snapshot is often the only signal of what happened. +function crashDiagnostics() { + return { + appUptimeSeconds: Math.round(process.uptime()), + chromiumLogTail: readChromiumLogTail(), + ...flattenMemorySnapshot(collectMemorySnapshot(() => app.getAppMetrics())), + }; +} + app.on("render-process-gone", (_event, webContents, details) => { - const memory = collectMemorySnapshot(() => app.getAppMetrics()); - const chromiumLogTail = readChromiumLogTail(); const props = { source: "main", type: "render-process-gone", @@ -141,18 +153,13 @@ app.on("render-process-gone", (_event, webContents, details) => { url: webContents.getURL(), title: webContents.getTitle(), webContentsId: String(webContents.id), - appUptimeSeconds: Math.round(process.uptime()), - memoryTotalWorkingSetKb: memory?.totalWorkingSetKb, - memoryPeakWorkingSetKb: memory?.peakWorkingSetKb, - memoryProcessCount: memory?.processCount, - memoryByType: memory ? JSON.stringify(memory.byType) : undefined, + ...crashDiagnostics(), }; - log.error("Renderer process gone", { ...props, chromiumLogTail }); + log.error("Renderer process gone", props); posthogNodeAnalytics.captureException( new Error(`Renderer process gone: ${details.reason}`), { ...props, - chromiumLogTail, // Stack is always this handler, so default grouping collapses every // renderer death into one issue. Split by reason instead. $exception_fingerprint: ["render-process-gone", details.reason], @@ -188,8 +195,6 @@ app.on("render-process-gone", (_event, webContents, details) => { }); app.on("child-process-gone", (_event, details) => { - const memory = collectMemorySnapshot(() => app.getAppMetrics()); - const chromiumLogTail = readChromiumLogTail(); const props = { source: "main", type: "child-process-gone", @@ -198,18 +203,13 @@ app.on("child-process-gone", (_event, details) => { exitCode: String(details.exitCode), serviceName: details.serviceName ?? "", name: details.name ?? "", - appUptimeSeconds: Math.round(process.uptime()), - memoryTotalWorkingSetKb: memory?.totalWorkingSetKb, - memoryPeakWorkingSetKb: memory?.peakWorkingSetKb, - memoryProcessCount: memory?.processCount, - memoryByType: memory ? JSON.stringify(memory.byType) : undefined, + ...crashDiagnostics(), }; - log.error("Child process gone", { ...props, chromiumLogTail }); + log.error("Child process gone", props); posthogNodeAnalytics.captureException( new Error(`Child process gone (${details.type}): ${details.reason}`), { ...props, - chromiumLogTail, $exception_fingerprint: [ "child-process-gone", details.type, 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 6b5dfdfc9..d0e605987 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.test.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.test.ts @@ -99,8 +99,15 @@ describe("posthog-analytics", () => { $session_id: "spoofed", }); - const props = mockCaptureException.mock.calls.at(-1)?.[2]; - expect(props.$session_id).toMatch( + 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 a8567b386..b2c66e61b 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.ts @@ -61,10 +61,6 @@ export class PosthogNodeAnalytics implements IAnalytics { return this.sessionId; } - getSessionId(): string | null { - return this.sessionId; - } - track(eventName: string, properties?: AnalyticsProperties): void { if (!this.client) { return; diff --git a/apps/code/src/main/utils/crash-diagnostics.test.ts b/apps/code/src/main/utils/crash-diagnostics.test.ts index 1f7c5441d..7a896108a 100644 --- a/apps/code/src/main/utils/crash-diagnostics.test.ts +++ b/apps/code/src/main/utils/crash-diagnostics.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { collectMemorySnapshot } from "./crash-diagnostics"; +import { + collectMemorySnapshot, + flattenMemorySnapshot, +} from "./crash-diagnostics"; function metric( type: string, @@ -46,3 +49,25 @@ describe("collectMemorySnapshot", () => { ).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 index 5b915a0c4..ef13b7034 100644 --- a/apps/code/src/main/utils/crash-diagnostics.ts +++ b/apps/code/src/main/utils/crash-diagnostics.ts @@ -47,3 +47,22 @@ export function collectMemorySnapshot( return undefined; } } + +/** + * Flatten a snapshot into scalar event properties for PostHog (which doesn't + * accept nested objects, so `byType` is serialized). Returns `{}` when no + * snapshot was collected, so it spreads cleanly into a crash event's props. + */ +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 index 2df896d19..5dadfa841 100644 --- a/apps/code/src/main/utils/uuidv7.test.ts +++ b/apps/code/src/main/utils/uuidv7.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { uuidv7 } from "./uuidv7"; const UUID_V7 = @@ -26,4 +26,16 @@ describe("uuidv7", () => { 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(); + // bytes 0-5 of 0x0123456789ab -> "01234567" then "89ab" + expect(id.slice(0, 8)).toBe("01234567"); + expect(id.slice(9, 13)).toBe("89ab"); + } finally { + vi.restoreAllMocks(); + } + }); }); diff --git a/packages/platform/src/analytics.ts b/packages/platform/src/analytics.ts index 79d4370a5..cb2143e1c 100644 --- a/packages/platform/src/analytics.ts +++ b/packages/platform/src/analytics.ts @@ -12,7 +12,6 @@ export interface IAnalytics { * host-captured crash events link to the same replay session. */ getOrCreateSessionId(): string; - getSessionId(): string | null; 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 02035fef7..ed4ea96f2 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.ts @@ -41,6 +41,11 @@ type PendingFlagListener = { // Subscribers added before initializePostHog runs. const pendingFlagListeners = new Set(); +// 10 h, the posthog-js maximum. The app sits idle for hours during background +// tasks; a shorter timeout would rotate the session id and break the +// main-owned crash->replay link. +const SESSION_IDLE_TIMEOUT_SECONDS = 36_000; + /** * @param sessionId Main-owned session id (UUIDv7) to bootstrap posthog-js with, * so the recording shares the id main stamps on crash events. Main owns it so @@ -61,11 +66,9 @@ export function initializePostHog(sessionId?: string) { api_host: apiHost, ui_host: uiHost, disable_session_recording: false, - // Hold the session open through long idle (max posthog-js allows) so its - // own rotation doesn't replace main's bootstrapped id mid-run. This app - // sits idle for hours with background tasks, which is exactly when a - // shorter timeout would silently rotate and break crash->replay linking. - session_idle_timeout_seconds: 36000, + // Hold the session open through long idle so posthog-js's own rotation + // doesn't replace main's bootstrapped id mid-run. + session_idle_timeout_seconds: SESSION_IDLE_TIMEOUT_SECONDS, ...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}), capture_exceptions: import.meta.env.DEV ? false From 783721e7d54bf347725d47afc327fecef3bcd524 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 8 Jun 2026 08:13:19 -0700 Subject: [PATCH 4/4] remove verbose telemetry comments --- apps/code/src/main/index.ts | 5 ----- .../platform-adapters/posthog-analytics.ts | 17 ---------------- apps/code/src/main/utils/crash-diagnostics.ts | 20 ------------------- apps/code/src/main/utils/uuidv7.test.ts | 2 -- apps/code/src/main/utils/uuidv7.ts | 10 ---------- apps/code/src/main/window.ts | 2 -- .../contributions/app-boot.contributions.ts | 4 ---- .../src/routers/analytics.router.ts | 5 ----- packages/platform/src/analytics.ts | 6 +----- packages/ui/src/shell/posthogAnalyticsImpl.ts | 10 ---------- 10 files changed, 1 insertion(+), 80 deletions(-) diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 39983cf99..4f17c0404 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -133,9 +133,6 @@ function isCrashLoop(): boolean { return recentCrashTimestamps.length >= CRASH_LOOP_THRESHOLD; } -// Shared diagnostics attached to every crash event: uptime, the native chromium -// log tail, and flattened memory. A hard OOM kills the renderer before it can -// log, so the memory snapshot is often the only signal of what happened. function crashDiagnostics() { return { appUptimeSeconds: Math.round(process.uptime()), @@ -160,8 +157,6 @@ app.on("render-process-gone", (_event, webContents, details) => { new Error(`Renderer process gone: ${details.reason}`), { ...props, - // Stack is always this handler, so default grouping collapses every - // renderer death into one issue. Split by reason instead. $exception_fingerprint: ["render-process-gone", details.reason], }, ); diff --git a/apps/code/src/main/platform-adapters/posthog-analytics.ts b/apps/code/src/main/platform-adapters/posthog-analytics.ts index b2c66e61b..27ef9c0e0 100644 --- a/apps/code/src/main/platform-adapters/posthog-analytics.ts +++ b/apps/code/src/main/platform-adapters/posthog-analytics.ts @@ -28,9 +28,6 @@ export class PosthogNodeAnalytics implements IAnalytics { enableExceptionAutocapture: true, }); - // Mint the main-owned session id now, before the first window, so crash - // handlers can stamp $session_id even when the renderer crashes during - // startup (before it fetches the id to bootstrap posthog-js). this.getOrCreateSessionId(); } @@ -42,18 +39,6 @@ export class PosthogNodeAnalytics implements IAnalytics { return this.currentUserId; } - /** - * The PostHog session id is OWNED BY MAIN. Main mints one UUIDv7 and every - * renderer window bootstraps posthog-js with it (`bootstrap.sessionID`). - * Because main outlives the renderer, the id stays stable across a renderer - * crash + reload, so the replay is one continuous session spanning the crash - * and main-captured crash events (the renderer can't report its own OOM) - * always carry the right `$session_id` with no race or hand-off. - * - * Minted lazily on first request (a window asks at boot, before posthog-js - * init) so its UUIDv7 timestamp precedes the session's first event, as - * posthog-js requires. - */ getOrCreateSessionId(): string { if (!this.sessionId) { this.sessionId = uuidv7(); @@ -109,8 +94,6 @@ export class PosthogNodeAnalytics implements IAnalytics { this.client.captureException(error, distinctId, { team: "posthog-code", ...additionalProperties, - // System-owned fields last so callers can't overwrite them: main owns - // the session id used for crash->replay linking. ...(this.sessionId ? { $session_id: this.sessionId } : {}), app_version: getAppVersion(), }); diff --git a/apps/code/src/main/utils/crash-diagnostics.ts b/apps/code/src/main/utils/crash-diagnostics.ts index ef13b7034..7abf6c649 100644 --- a/apps/code/src/main/utils/crash-diagnostics.ts +++ b/apps/code/src/main/utils/crash-diagnostics.ts @@ -5,21 +5,6 @@ export interface MemorySnapshot { byType: Record; } -/** - * Summarize per-process memory (from `app.getAppMetrics()`, passed in by the - * caller so this stays free of a direct `electron` import) for crash - * diagnostics. Working-set sizes are in KB. Attached to renderer/child crash - * events so PostHog Error Tracking can show whether the app was under memory - * pressure: a hard OOM kills the renderer before it can log anything, so the - * chromium log usually goes silent and this is the only reliable signal. - * - * Defensive on purpose: a throw here would run before the crash handler's - * auto-recovery reload, so failures return `undefined` instead. - * - * Caveat: at `render-process-gone` time the dead renderer is already gone from - * the metrics, so the `Tab` total understates the renderer's real peak. The - * `unresponsive` sample (renderer still alive) is the more telling one. - */ export function collectMemorySnapshot( getMetrics: () => Electron.ProcessMetric[], ): MemorySnapshot | undefined { @@ -48,11 +33,6 @@ export function collectMemorySnapshot( } } -/** - * Flatten a snapshot into scalar event properties for PostHog (which doesn't - * accept nested objects, so `byType` is serialized). Returns `{}` when no - * snapshot was collected, so it spreads cleanly into a crash event's props. - */ export function flattenMemorySnapshot( memory: MemorySnapshot | undefined, ): Record { diff --git a/apps/code/src/main/utils/uuidv7.test.ts b/apps/code/src/main/utils/uuidv7.test.ts index 5dadfa841..a2516d686 100644 --- a/apps/code/src/main/utils/uuidv7.test.ts +++ b/apps/code/src/main/utils/uuidv7.test.ts @@ -16,7 +16,6 @@ describe("uuidv7", () => { const id = uuidv7(); const after = Date.now(); - // First 48 bits (first 12 hex chars, minus the dash) are the unix-ms stamp. const stampMs = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16); expect(stampMs).toBeGreaterThanOrEqual(before); expect(stampMs).toBeLessThanOrEqual(after); @@ -31,7 +30,6 @@ describe("uuidv7", () => { vi.spyOn(Date, "now").mockReturnValue(0x0123456789ab); try { const id = uuidv7(); - // bytes 0-5 of 0x0123456789ab -> "01234567" then "89ab" expect(id.slice(0, 8)).toBe("01234567"); expect(id.slice(9, 13)).toBe("89ab"); } finally { diff --git a/apps/code/src/main/utils/uuidv7.ts b/apps/code/src/main/utils/uuidv7.ts index 4983ca679..de256136f 100644 --- a/apps/code/src/main/utils/uuidv7.ts +++ b/apps/code/src/main/utils/uuidv7.ts @@ -1,15 +1,5 @@ import { randomBytes } from "node:crypto"; -/** - * Generate a UUIDv7 (time-ordered, RFC 9562). posthog-js requires this exact - * format for `bootstrap.sessionID`: a valid v7 whose 48-bit timestamp precedes - * the session's first event. Main mints it before any window starts posthog-js, - * so the ordering holds. - * - * Layout: 48-bit big-endian unix-ms timestamp, 4-bit version (7), 2-bit variant - * (10), 74 random bits. Hand-rolled to avoid a phantom dependency on `uuid` - * (transitive only, and several major versions resolve in the tree). - */ export function uuidv7(): string { const bytes = randomBytes(16); const timestamp = Date.now(); diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 2c7a1bc66..e9df218cc 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -112,8 +112,6 @@ function setupCrashLogging(window: BrowserWindow): void { }); }); - // Unresponsive often precedes an OOM kill, and here the renderer is still - // alive, so this memory sample reflects its real (bloated) footprint. window.on("unresponsive", () => { log.warn("Window unresponsive", { url: window.webContents.getURL(), diff --git a/apps/code/src/renderer/contributions/app-boot.contributions.ts b/apps/code/src/renderer/contributions/app-boot.contributions.ts index e8307b85a..040573f6e 100644 --- a/apps/code/src/renderer/contributions/app-boot.contributions.ts +++ b/apps/code/src/renderer/contributions/app-boot.contributions.ts @@ -12,10 +12,6 @@ const log = logger.scope("app-boot"); @injectable() export class AnalyticsBootContribution implements Contribution { start(): void { - // Fetch the main-owned session id BEFORE initializing posthog-js so the - // recording shares the id main stamps on crash events. Init is gated on it - // so the id is set before the first event (posthog-js requires the - // bootstrap id's timestamp to precede the session's first event). void (async () => { let sessionId: string | undefined; try { diff --git a/packages/host-router/src/routers/analytics.router.ts b/packages/host-router/src/routers/analytics.router.ts index 5b2235ecd..1ced4d3b6 100644 --- a/packages/host-router/src/routers/analytics.router.ts +++ b/packages/host-router/src/routers/analytics.router.ts @@ -24,11 +24,6 @@ export const analyticsRouter = router({ } }), - /** - * Return the main-owned session id for a window to bootstrap posthog-js with - * (`bootstrap.sessionID`). Main owns it so crash events captured from main - * link to the right replay, and the id survives renderer crash+reload. - */ getSessionId: publicProcedure .output(z.object({ sessionId: z.string() })) .query(({ ctx }) => ({ diff --git a/packages/platform/src/analytics.ts b/packages/platform/src/analytics.ts index cb2143e1c..425dbc6a4 100644 --- a/packages/platform/src/analytics.ts +++ b/packages/platform/src/analytics.ts @@ -6,11 +6,7 @@ export interface IAnalytics { identify(userId: string, properties?: AnalyticsProperties): void; setCurrentUserId(userId: string | null): void; getCurrentUserId(): string | null; - /** - * Stable analytics session id owned by the host process. Minted lazily on - * first request so renderer windows can bootstrap posthog-js with it and - * host-captured crash events link to the same replay session. - */ + /** Host-owned analytics session id, minted lazily on first request. */ getOrCreateSessionId(): string; resetUser(): void; captureException( diff --git a/packages/ui/src/shell/posthogAnalyticsImpl.ts b/packages/ui/src/shell/posthogAnalyticsImpl.ts index ed4ea96f2..e4ddbada6 100644 --- a/packages/ui/src/shell/posthogAnalyticsImpl.ts +++ b/packages/ui/src/shell/posthogAnalyticsImpl.ts @@ -41,16 +41,8 @@ type PendingFlagListener = { // Subscribers added before initializePostHog runs. const pendingFlagListeners = new Set(); -// 10 h, the posthog-js maximum. The app sits idle for hours during background -// tasks; a shorter timeout would rotate the session id and break the -// main-owned crash->replay link. const SESSION_IDLE_TIMEOUT_SECONDS = 36_000; -/** - * @param sessionId Main-owned session id (UUIDv7) to bootstrap posthog-js with, - * so the recording shares the id main stamps on crash events. Main owns it so - * it survives a renderer crash+reload as one continuous session. - */ export function initializePostHog(sessionId?: string) { const apiKey = import.meta.env.VITE_POSTHOG_API_KEY; const apiHost = @@ -66,8 +58,6 @@ export function initializePostHog(sessionId?: string) { api_host: apiHost, ui_host: uiHost, disable_session_recording: false, - // Hold the session open through long idle so posthog-js's own rotation - // doesn't replace main's bootstrapped id mid-run. session_idle_timeout_seconds: SESSION_IDLE_TIMEOUT_SECONDS, ...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}), capture_exceptions: import.meta.env.DEV