Skip to content

Commit 6dbc74f

Browse files
committed
enrich renderer crash events for error tracking
1 parent fec00b8 commit 6dbc74f

10 files changed

Lines changed: 258 additions & 19 deletions

File tree

apps/code/src/main/index.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { SuspensionService } from "./services/suspension/service";
3030
import type { TaskLinkService } from "./services/task-link/service";
3131
import type { UpdatesService } from "./services/updates/service";
3232
import type { WorkspaceService } from "./services/workspace/service";
33+
import { collectMemorySnapshot } from "./utils/crash-diagnostics";
3334
import { ensureClaudeConfigDir } from "./utils/env";
3435
import {
3536
getChromiumLogFilePath,
@@ -72,6 +73,8 @@ function isCrashLoop(): boolean {
7273
}
7374

7475
app.on("render-process-gone", (_event, webContents, details) => {
76+
const memory = collectMemorySnapshot(() => app.getAppMetrics());
77+
const chromiumLogTail = readChromiumLogTail();
7578
const props = {
7679
source: "main",
7780
type: "render-process-gone",
@@ -80,15 +83,20 @@ app.on("render-process-gone", (_event, webContents, details) => {
8083
url: webContents.getURL(),
8184
title: webContents.getTitle(),
8285
webContentsId: String(webContents.id),
86+
appUptimeSeconds: Math.round(process.uptime()),
87+
memoryTotalWorkingSetKb: memory?.totalWorkingSetKb,
88+
memoryPeakWorkingSetKb: memory?.peakWorkingSetKb,
89+
memoryProcessCount: memory?.processCount,
90+
memoryByType: memory ? JSON.stringify(memory.byType) : undefined,
8391
};
84-
log.error("Renderer process gone", {
92+
log.error("Renderer process gone", { ...props, chromiumLogTail });
93+
captureException(new Error(`Renderer process gone: ${details.reason}`), {
8594
...props,
86-
chromiumLogTail: readChromiumLogTail(),
95+
chromiumLogTail,
96+
// Stack is always this handler, so default grouping collapses every
97+
// renderer death into one issue. Split by reason instead.
98+
$exception_fingerprint: ["render-process-gone", details.reason],
8799
});
88-
captureException(
89-
new Error(`Renderer process gone: ${details.reason}`),
90-
props,
91-
);
92100
getPostHogClient()
93101
?.flush()
94102
.catch(() => {});
@@ -121,6 +129,8 @@ app.on("render-process-gone", (_event, webContents, details) => {
121129
});
122130

123131
app.on("child-process-gone", (_event, details) => {
132+
const memory = collectMemorySnapshot(() => app.getAppMetrics());
133+
const chromiumLogTail = readChromiumLogTail();
124134
const props = {
125135
source: "main",
126136
type: "child-process-gone",
@@ -129,14 +139,24 @@ app.on("child-process-gone", (_event, details) => {
129139
exitCode: String(details.exitCode),
130140
serviceName: details.serviceName ?? "",
131141
name: details.name ?? "",
142+
appUptimeSeconds: Math.round(process.uptime()),
143+
memoryTotalWorkingSetKb: memory?.totalWorkingSetKb,
144+
memoryPeakWorkingSetKb: memory?.peakWorkingSetKb,
145+
memoryProcessCount: memory?.processCount,
146+
memoryByType: memory ? JSON.stringify(memory.byType) : undefined,
132147
};
133-
log.error("Child process gone", {
134-
...props,
135-
chromiumLogTail: readChromiumLogTail(),
136-
});
148+
log.error("Child process gone", { ...props, chromiumLogTail });
137149
captureException(
138150
new Error(`Child process gone (${details.type}): ${details.reason}`),
139-
props,
151+
{
152+
...props,
153+
chromiumLogTail,
154+
$exception_fingerprint: [
155+
"child-process-gone",
156+
details.type,
157+
details.reason,
158+
],
159+
},
140160
);
141161
getPostHogClient()
142162
?.flush()

apps/code/src/main/services/posthog-analytics.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { PostHog } from "posthog-node";
22
import { getAppVersion } from "../utils/env";
3+
import { uuidv7 } from "../utils/uuidv7";
34

45
let posthogClient: PostHog | null = null;
56
let currentUserId: string | null = null;
7+
let sessionId: string | null = null;
68

79
export function initializePostHog() {
810
if (posthogClient) {
@@ -32,6 +34,29 @@ export function getCurrentUserId() {
3234
return currentUserId;
3335
}
3436

37+
/**
38+
* The PostHog session id is OWNED BY MAIN. Main mints one UUIDv7 and every
39+
* renderer window bootstraps posthog-js with it (`bootstrap.sessionID`).
40+
* Because main outlives the renderer, the id stays stable across a renderer
41+
* crash + reload, so the replay is one continuous session spanning the crash
42+
* and main-captured crash events (the renderer can't report its own OOM)
43+
* always carry the right `$session_id` with no race or hand-off.
44+
*
45+
* Minted lazily on first request (a window asks at boot, before posthog-js
46+
* init) so its UUIDv7 timestamp precedes the session's first event, as
47+
* posthog-js requires.
48+
*/
49+
export function getOrCreateSessionId(): string {
50+
if (!sessionId) {
51+
sessionId = uuidv7();
52+
}
53+
return sessionId;
54+
}
55+
56+
export function getSessionId(): string | null {
57+
return sessionId;
58+
}
59+
3560
export function trackAppEvent(
3661
eventName: string,
3762
properties?: Record<string, string | number | boolean>,
@@ -96,6 +121,7 @@ export function captureException(
96121
const distinctId = currentUserId || "anonymous-app-event";
97122
posthogClient.captureException(error, distinctId, {
98123
team: "posthog-code",
124+
...(sessionId ? { $session_id: sessionId } : {}),
99125
...additionalProperties,
100126
app_version: getAppVersion(),
101127
});

apps/code/src/main/trpc/routers/analytics.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from "zod";
22
import {
3+
getOrCreateSessionId,
34
identifyUser,
45
resetUser,
56
setCurrentUserId,
@@ -29,6 +30,15 @@ export const analyticsRouter = router({
2930
}
3031
}),
3132

33+
/**
34+
* Return the main-owned session id for a window to bootstrap posthog-js with
35+
* (`bootstrap.sessionID`). Main owns it so crash events captured from main
36+
* link to the right replay, and the id survives renderer crash+reload.
37+
*/
38+
getSessionId: publicProcedure
39+
.output(z.object({ sessionId: z.string() }))
40+
.query(() => ({ sessionId: getOrCreateSessionId() })),
41+
3242
/**
3343
* Reset the current user (on logout)
3444
*/
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest";
2+
import { collectMemorySnapshot } from "./crash-diagnostics";
3+
4+
function metric(
5+
type: string,
6+
workingSetSize: number,
7+
peakWorkingSetSize: number,
8+
): Electron.ProcessMetric {
9+
return {
10+
type,
11+
memory: { workingSetSize, peakWorkingSetSize, privateBytes: 0 },
12+
} as unknown as Electron.ProcessMetric;
13+
}
14+
15+
describe("collectMemorySnapshot", () => {
16+
it("sums working set, tracks peak, and groups by process type", () => {
17+
const snapshot = collectMemorySnapshot(() => [
18+
metric("Browser", 100, 150),
19+
metric("Tab", 200, 500),
20+
metric("Tab", 50, 60),
21+
metric("GPU", 80, 90),
22+
]);
23+
24+
expect(snapshot).toEqual({
25+
totalWorkingSetKb: 430,
26+
peakWorkingSetKb: 500,
27+
processCount: 4,
28+
byType: { Browser: 100, Tab: 250, GPU: 80 },
29+
});
30+
});
31+
32+
it("returns a zeroed snapshot for no processes", () => {
33+
expect(collectMemorySnapshot(() => [])).toEqual({
34+
totalWorkingSetKb: 0,
35+
peakWorkingSetKb: 0,
36+
processCount: 0,
37+
byType: {},
38+
});
39+
});
40+
41+
it("returns undefined instead of throwing (crash handler must not fail)", () => {
42+
expect(
43+
collectMemorySnapshot(() => {
44+
throw new Error("getAppMetrics unavailable");
45+
}),
46+
).toBeUndefined();
47+
});
48+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export interface MemorySnapshot {
2+
totalWorkingSetKb: number;
3+
peakWorkingSetKb: number;
4+
processCount: number;
5+
byType: Record<string, number>;
6+
}
7+
8+
/**
9+
* Summarize per-process memory (from `app.getAppMetrics()`, passed in by the
10+
* caller so this stays free of a direct `electron` import) for crash
11+
* diagnostics. Working-set sizes are in KB. Attached to renderer/child crash
12+
* events so PostHog Error Tracking can show whether the app was under memory
13+
* pressure: a hard OOM kills the renderer before it can log anything, so the
14+
* chromium log usually goes silent and this is the only reliable signal.
15+
*
16+
* Defensive on purpose: a throw here would run before the crash handler's
17+
* auto-recovery reload, so failures return `undefined` instead.
18+
*
19+
* Caveat: at `render-process-gone` time the dead renderer is already gone from
20+
* the metrics, so the `Tab` total understates the renderer's real peak. The
21+
* `unresponsive` sample (renderer still alive) is the more telling one.
22+
*/
23+
export function collectMemorySnapshot(
24+
getMetrics: () => Electron.ProcessMetric[],
25+
): MemorySnapshot | undefined {
26+
try {
27+
const metrics = getMetrics();
28+
let totalWorkingSetKb = 0;
29+
let peakWorkingSetKb = 0;
30+
const byType: Record<string, number> = {};
31+
for (const metric of metrics) {
32+
const workingSet = metric.memory.workingSetSize;
33+
totalWorkingSetKb += workingSet;
34+
peakWorkingSetKb = Math.max(
35+
peakWorkingSetKb,
36+
metric.memory.peakWorkingSetSize,
37+
);
38+
byType[metric.type] = (byType[metric.type] ?? 0) + workingSet;
39+
}
40+
return {
41+
totalWorkingSetKb,
42+
peakWorkingSetKb,
43+
processCount: metrics.length,
44+
byType,
45+
};
46+
} catch {
47+
return undefined;
48+
}
49+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "vitest";
2+
import { uuidv7 } from "./uuidv7";
3+
4+
const UUID_V7 =
5+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
6+
7+
describe("uuidv7", () => {
8+
it("produces a valid v7 string (version nibble 7, variant 10)", () => {
9+
for (let i = 0; i < 100; i++) {
10+
expect(uuidv7()).toMatch(UUID_V7);
11+
}
12+
});
13+
14+
it("encodes the current time so ids sort in creation order", () => {
15+
const before = Date.now();
16+
const id = uuidv7();
17+
const after = Date.now();
18+
19+
// First 48 bits (first 12 hex chars, minus the dash) are the unix-ms stamp.
20+
const stampMs = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16);
21+
expect(stampMs).toBeGreaterThanOrEqual(before);
22+
expect(stampMs).toBeLessThanOrEqual(after);
23+
});
24+
25+
it("is unique across rapid calls", () => {
26+
const ids = new Set(Array.from({ length: 1000 }, () => uuidv7()));
27+
expect(ids.size).toBe(1000);
28+
});
29+
});

apps/code/src/main/utils/uuidv7.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { randomBytes } from "node:crypto";
2+
3+
/**
4+
* Generate a UUIDv7 (time-ordered, RFC 9562). posthog-js requires this exact
5+
* format for `bootstrap.sessionID`: a valid v7 whose 48-bit timestamp precedes
6+
* the session's first event. Main mints it before any window starts posthog-js,
7+
* so the ordering holds.
8+
*
9+
* Layout: 48-bit big-endian unix-ms timestamp, 4-bit version (7), 2-bit variant
10+
* (10), 74 random bits. Hand-rolled to avoid a phantom dependency on `uuid`
11+
* (transitive only, and several major versions resolve in the tree).
12+
*/
13+
export function uuidv7(): string {
14+
const bytes = randomBytes(16);
15+
const timestamp = Date.now();
16+
17+
bytes[0] = Math.floor(timestamp / 2 ** 40) & 0xff;
18+
bytes[1] = Math.floor(timestamp / 2 ** 32) & 0xff;
19+
bytes[2] = Math.floor(timestamp / 2 ** 24) & 0xff;
20+
bytes[3] = Math.floor(timestamp / 2 ** 16) & 0xff;
21+
bytes[4] = Math.floor(timestamp / 2 ** 8) & 0xff;
22+
bytes[5] = timestamp & 0xff;
23+
24+
bytes[6] = (bytes[6] & 0x0f) | 0x70; // version 7
25+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
26+
27+
const hex = bytes.toString("hex");
28+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
29+
}

apps/code/src/main/window.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MAIN_TOKENS } from "./di/tokens";
1414
import { buildApplicationMenu } from "./menu";
1515
import type { ElectronMainWindow } from "./platform-adapters/electron-main-window";
1616
import { trpcRouter } from "./trpc/router";
17+
import { collectMemorySnapshot } from "./utils/crash-diagnostics";
1718
import { isDevBuild } from "./utils/env";
1819
import { logger, readChromiumLogTail } from "./utils/logger";
1920
import { type WindowStateSchema, windowStateStore } from "./utils/store";
@@ -110,13 +111,17 @@ function setupCrashLogging(window: BrowserWindow): void {
110111
reason: details.reason,
111112
exitCode: details.exitCode,
112113
url: window.webContents.getURL(),
114+
memory: collectMemorySnapshot(() => app.getAppMetrics()),
113115
chromiumLogTail: readChromiumLogTail(),
114116
});
115117
});
116118

119+
// Unresponsive often precedes an OOM kill, and here the renderer is still
120+
// alive, so this memory sample reflects its real (bloated) footprint.
117121
window.on("unresponsive", () => {
118122
log.warn("Window unresponsive", {
119123
url: window.webContents.getURL(),
124+
memory: collectMemorySnapshot(() => app.getAppMetrics()),
120125
chromiumLogTail: readChromiumLogTail(),
121126
});
122127
});

apps/code/src/renderer/App.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,25 @@ function App() {
5252

5353
// Initialize PostHog analytics and register the app version super property.
5454
useEffect(() => {
55-
initializePostHog();
56-
trpcClient.os.getAppVersion
57-
.query()
58-
.then(registerAppVersion)
59-
.catch((error) => {
60-
log.warn("Failed to register app version super property", { error });
61-
});
55+
// Fetch the main-owned session id BEFORE initializing posthog-js so the
56+
// recording shares the id main stamps on crash events. Init is gated on it
57+
// so the id is set before the first event (posthog-js requires the
58+
// bootstrap id's timestamp to precede the session's first event).
59+
void (async () => {
60+
let sessionId: string | undefined;
61+
try {
62+
({ sessionId } = await trpcClient.analytics.getSessionId.query());
63+
} catch (error) {
64+
log.warn("Failed to fetch session id from main", { error });
65+
}
66+
initializePostHog(sessionId);
67+
trpcClient.os.getAppVersion
68+
.query()
69+
.then(registerAppVersion)
70+
.catch((error) => {
71+
log.warn("Failed to register app version super property", { error });
72+
});
73+
})();
6274
}, []);
6375

6476
// Initialize connectivity monitoring

apps/code/src/renderer/utils/analytics.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,12 @@ type PendingFlagListener = {
3636
// Subscribers added before initializePostHog runs.
3737
const pendingFlagListeners = new Set<PendingFlagListener>();
3838

39-
export function initializePostHog() {
39+
/**
40+
* @param sessionId Main-owned session id (UUIDv7) to bootstrap posthog-js with,
41+
* so the recording shares the id main stamps on crash events. Main owns it so
42+
* it survives a renderer crash+reload as one continuous session.
43+
*/
44+
export function initializePostHog(sessionId?: string) {
4045
const apiKey = import.meta.env.VITE_POSTHOG_API_KEY;
4146
const apiHost =
4247
import.meta.env.VITE_POSTHOG_API_HOST || "https://internal-c.posthog.com";
@@ -51,6 +56,12 @@ export function initializePostHog() {
5156
api_host: apiHost,
5257
ui_host: uiHost,
5358
disable_session_recording: false,
59+
// Hold the session open through long idle (max posthog-js allows) so its
60+
// own rotation doesn't replace main's bootstrapped id mid-run. This app
61+
// sits idle for hours with background tasks, which is exactly when a
62+
// shorter timeout would silently rotate and break crash->replay linking.
63+
session_idle_timeout_seconds: 36000,
64+
...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}),
5465
capture_exceptions: import.meta.env.DEV
5566
? false
5667
: {

0 commit comments

Comments
 (0)