Skip to content

Commit 072d2a7

Browse files
committed
dedupe crash diagnostics and add tests
1 parent 7268778 commit 072d2a7

8 files changed

Lines changed: 120 additions & 32 deletions

File tree

apps/code/src/main/index.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ 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";
33+
import {
34+
collectMemorySnapshot,
35+
flattenMemorySnapshot,
36+
} from "./utils/crash-diagnostics";
3437
import { ensureClaudeConfigDir } from "./utils/env";
3538
import {
3639
getChromiumLogFilePath,
@@ -72,9 +75,18 @@ function isCrashLoop(): boolean {
7275
return recentCrashTimestamps.length >= CRASH_LOOP_THRESHOLD;
7376
}
7477

78+
// Shared diagnostics attached to every crash event: uptime, the native chromium
79+
// log tail, and flattened memory. A hard OOM kills the renderer before it can
80+
// log, so the memory snapshot is often the only signal of what happened.
81+
function crashDiagnostics() {
82+
return {
83+
appUptimeSeconds: Math.round(process.uptime()),
84+
chromiumLogTail: readChromiumLogTail(),
85+
...flattenMemorySnapshot(collectMemorySnapshot(() => app.getAppMetrics())),
86+
};
87+
}
88+
7589
app.on("render-process-gone", (_event, webContents, details) => {
76-
const memory = collectMemorySnapshot(() => app.getAppMetrics());
77-
const chromiumLogTail = readChromiumLogTail();
7890
const props = {
7991
source: "main",
8092
type: "render-process-gone",
@@ -83,16 +95,11 @@ app.on("render-process-gone", (_event, webContents, details) => {
8395
url: webContents.getURL(),
8496
title: webContents.getTitle(),
8597
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,
98+
...crashDiagnostics(),
9199
};
92-
log.error("Renderer process gone", { ...props, chromiumLogTail });
100+
log.error("Renderer process gone", props);
93101
captureException(new Error(`Renderer process gone: ${details.reason}`), {
94102
...props,
95-
chromiumLogTail,
96103
// Stack is always this handler, so default grouping collapses every
97104
// renderer death into one issue. Split by reason instead.
98105
$exception_fingerprint: ["render-process-gone", details.reason],
@@ -129,8 +136,6 @@ app.on("render-process-gone", (_event, webContents, details) => {
129136
});
130137

131138
app.on("child-process-gone", (_event, details) => {
132-
const memory = collectMemorySnapshot(() => app.getAppMetrics());
133-
const chromiumLogTail = readChromiumLogTail();
134139
const props = {
135140
source: "main",
136141
type: "child-process-gone",
@@ -139,18 +144,13 @@ app.on("child-process-gone", (_event, details) => {
139144
exitCode: String(details.exitCode),
140145
serviceName: details.serviceName ?? "",
141146
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,
147+
...crashDiagnostics(),
147148
};
148-
log.error("Child process gone", { ...props, chromiumLogTail });
149+
log.error("Child process gone", props);
149150
captureException(
150151
new Error(`Child process gone (${details.type}): ${details.reason}`),
151152
{
152153
...props,
153-
chromiumLogTail,
154154
$exception_fingerprint: [
155155
"child-process-gone",
156156
details.type,

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ vi.mock("posthog-node", () => ({ PostHog: MockPostHog }));
1010

1111
import {
1212
captureException,
13+
getOrCreateSessionId,
1314
initializePostHog,
1415
resetUser,
1516
shutdownPostHog,
@@ -101,8 +102,15 @@ describe("posthog-analytics", () => {
101102
it("stamps the main-owned session id and ignores a caller override", () => {
102103
captureException(new Error("boom"), { $session_id: "spoofed" });
103104

104-
const props = mockCaptureException.mock.calls.at(-1)?.[2];
105-
expect(props.$session_id).toMatch(
105+
const [, , props] = mockCaptureException.mock.calls.at(-1) ?? [];
106+
expect(props.$session_id).toBe(getOrCreateSessionId());
107+
});
108+
109+
it("mints a stable valid uuidv7 session id", () => {
110+
const first = getOrCreateSessionId();
111+
112+
expect(getOrCreateSessionId()).toBe(first);
113+
expect(first).toMatch(
106114
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
107115
);
108116
});

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ export function getOrCreateSessionId(): string {
5858
return sessionId;
5959
}
6060

61-
export function getSessionId(): string | null {
62-
return sessionId;
63-
}
64-
6561
export function trackAppEvent(
6662
eventName: string,
6763
properties?: Record<string, string | number | boolean>,

apps/code/src/main/utils/crash-diagnostics.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from "vitest";
2-
import { collectMemorySnapshot } from "./crash-diagnostics";
2+
import {
3+
collectMemorySnapshot,
4+
flattenMemorySnapshot,
5+
} from "./crash-diagnostics";
36

47
function metric(
58
type: string,
@@ -46,3 +49,25 @@ describe("collectMemorySnapshot", () => {
4649
).toBeUndefined();
4750
});
4851
});
52+
53+
describe("flattenMemorySnapshot", () => {
54+
it("flattens scalars and serializes byType for PostHog", () => {
55+
expect(
56+
flattenMemorySnapshot({
57+
totalWorkingSetKb: 430,
58+
peakWorkingSetKb: 500,
59+
processCount: 4,
60+
byType: { Browser: 100, Tab: 250, GPU: 80 },
61+
}),
62+
).toEqual({
63+
memoryTotalWorkingSetKb: 430,
64+
memoryPeakWorkingSetKb: 500,
65+
memoryProcessCount: 4,
66+
memoryByType: '{"Browser":100,"Tab":250,"GPU":80}',
67+
});
68+
});
69+
70+
it("returns an empty object when no snapshot was collected", () => {
71+
expect(flattenMemorySnapshot(undefined)).toEqual({});
72+
});
73+
});

apps/code/src/main/utils/crash-diagnostics.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,22 @@ export function collectMemorySnapshot(
4747
return undefined;
4848
}
4949
}
50+
51+
/**
52+
* Flatten a snapshot into scalar event properties for PostHog (which doesn't
53+
* accept nested objects, so `byType` is serialized). Returns `{}` when no
54+
* snapshot was collected, so it spreads cleanly into a crash event's props.
55+
*/
56+
export function flattenMemorySnapshot(
57+
memory: MemorySnapshot | undefined,
58+
): Record<string, number | string> {
59+
if (!memory) {
60+
return {};
61+
}
62+
return {
63+
memoryTotalWorkingSetKb: memory.totalWorkingSetKb,
64+
memoryPeakWorkingSetKb: memory.peakWorkingSetKb,
65+
memoryProcessCount: memory.processCount,
66+
memoryByType: JSON.stringify(memory.byType),
67+
};
68+
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, it, vi } from "vitest";
22
import { uuidv7 } from "./uuidv7";
33

44
const UUID_V7 =
@@ -26,4 +26,16 @@ describe("uuidv7", () => {
2626
const ids = new Set(Array.from({ length: 1000 }, () => uuidv7()));
2727
expect(ids.size).toBe(1000);
2828
});
29+
30+
it("writes the 48-bit millisecond timestamp big-endian into the first 6 bytes", () => {
31+
vi.spyOn(Date, "now").mockReturnValue(0x0123456789ab);
32+
try {
33+
const id = uuidv7();
34+
// bytes 0-5 of 0x0123456789ab -> "01234567" then "89ab"
35+
expect(id.slice(0, 8)).toBe("01234567");
36+
expect(id.slice(9, 13)).toBe("89ab");
37+
} finally {
38+
vi.restoreAllMocks();
39+
}
40+
});
2941
});

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,29 @@ describe("initializePostHog", () => {
148148
expect(mockPosthog.init).not.toHaveBeenCalled();
149149
expect(mockPosthog.onFeatureFlags).not.toHaveBeenCalled();
150150
});
151+
152+
it("bootstraps posthog with the main-owned session id", async () => {
153+
const { initializePostHog } = await loadAnalytics();
154+
155+
initializePostHog("0190abcd-1234-7890-8abc-def012345678");
156+
157+
expect(mockPosthog.init).toHaveBeenCalledWith(
158+
"test-key",
159+
expect.objectContaining({
160+
bootstrap: { sessionID: "0190abcd-1234-7890-8abc-def012345678" },
161+
session_idle_timeout_seconds: 36_000,
162+
}),
163+
);
164+
});
165+
166+
it("omits bootstrap when no session id is provided", async () => {
167+
const { initializePostHog } = await loadAnalytics();
168+
169+
initializePostHog();
170+
171+
expect(mockPosthog.init).toHaveBeenCalledWith(
172+
"test-key",
173+
expect.not.objectContaining({ bootstrap: expect.anything() }),
174+
);
175+
});
151176
});

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

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

39+
// 10 h, the posthog-js maximum. The app sits idle for hours during background
40+
// tasks; a shorter timeout would rotate the session id and break the
41+
// main-owned crash->replay link.
42+
const SESSION_IDLE_TIMEOUT_SECONDS = 36_000;
43+
3944
/**
4045
* @param sessionId Main-owned session id (UUIDv7) to bootstrap posthog-js with,
4146
* so the recording shares the id main stamps on crash events. Main owns it so
@@ -56,11 +61,9 @@ export function initializePostHog(sessionId?: string) {
5661
api_host: apiHost,
5762
ui_host: uiHost,
5863
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+
// Hold the session open through long idle so posthog-js's own rotation
65+
// doesn't replace main's bootstrapped id mid-run.
66+
session_idle_timeout_seconds: SESSION_IDLE_TIMEOUT_SECONDS,
6467
...(sessionId ? { bootstrap: { sessionID: sessionId } } : {}),
6568
capture_exceptions: import.meta.env.DEV
6669
? false

0 commit comments

Comments
 (0)