Skip to content

Commit d010122

Browse files
authored
fix(telemetry): use SDK session integration instead of manual management (#232)
## Summary - Move session tracking from manual `startSession`/`endSession`/`flush` in `withTelemetry()` to the SDK's built-in `processSessionIntegration` + a `beforeExit` handler - The manual approach missed unhandled rejections and other paths that bypass `withTelemetry`'s try/catch - The SDK already starts a session during `Sentry.init()` and auto-marks crashes for unhandled exceptions via `mechanism.handled: false` ## Changes **`src/lib/telemetry.ts`** - Remove `Sentry.startSession()`, `captureSession()`, `endSession()`, and `client.flush()` from `withTelemetry()` - Add `createBeforeExitHandler(client)` — ends OK sessions the SDK skips (clean exit → `'exited'`), flushes pending events, includes re-entry guard - Extract `markSessionCrashed()` — checks both current scope and isolation scope since `processSessionIntegration` stores the session on the isolation scope - Register the `beforeExit` handler in `initSentry()` when telemetry is enabled **`test/lib/telemetry-session.test.ts`** (new) - 5 tests for `createBeforeExitHandler` (OK session, non-OK session, no session, re-entry guard, flush) - 4 tests for `markSessionCrashed` (current scope, isolation scope, no session, scope priority) - Separate file because Sentry scope mocking poisons the SDK's global state and breaks other tests in the same worker
1 parent 5909f62 commit d010122

File tree

2 files changed

+299
-20
lines changed

2 files changed

+299
-20
lines changed

src/lib/telemetry.ts

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,36 @@ async function initTelemetryContext(): Promise<void> {
4949
}
5050
}
5151

52+
/**
53+
* Mark the active session as crashed.
54+
*
55+
* Checks both current scope and isolation scope since processSessionIntegration
56+
* stores the session on the isolation scope. Called when a command error
57+
* propagates through withTelemetry — the SDK auto-marks crashes for truly
58+
* uncaught exceptions (mechanism.handled === false), but command errors need
59+
* explicit marking.
60+
*
61+
* @internal Exported for testing
62+
*/
63+
export function markSessionCrashed(): void {
64+
const session =
65+
Sentry.getCurrentScope().getSession() ??
66+
Sentry.getIsolationScope().getSession();
67+
if (session) {
68+
session.status = "crashed";
69+
}
70+
}
71+
5272
/**
5373
* Wrap CLI execution with telemetry tracking.
5474
*
55-
* Creates a Sentry session and span for the command execution.
56-
* Captures any unhandled exceptions and reports them.
75+
* Creates a Sentry span for the command execution and captures exceptions.
76+
* Session lifecycle is managed by the SDK's processSessionIntegration
77+
* (started during Sentry.init) and a beforeExit handler (registered in
78+
* initSentry) that ends healthy sessions and flushes events. This ensures
79+
* sessions are reliably tracked even for unhandled rejections and other
80+
* paths that bypass this function's try/catch.
81+
*
5782
* Telemetry can be disabled via SENTRY_CLI_NO_TELEMETRY=1 env var.
5883
*
5984
* @param callback - The CLI execution function to wrap, receives the span for naming
@@ -71,9 +96,6 @@ export async function withTelemetry<T>(
7196
// Initialize user and instance context
7297
await initTelemetryContext();
7398

74-
Sentry.startSession();
75-
Sentry.captureSession();
76-
7799
try {
78100
return await Sentry.startSpanManual(
79101
{ name: "cli.command", op: "cli.command", forceTransaction: true },
@@ -92,31 +114,47 @@ export async function withTelemetry<T>(
92114
e instanceof AuthError && e.reason === "not_authenticated";
93115
if (!isExpectedAuthState) {
94116
Sentry.captureException(e);
95-
const session = Sentry.getCurrentScope().getSession();
96-
if (session) {
97-
session.status = "crashed";
98-
}
117+
markSessionCrashed();
99118
}
100119
throw e;
101-
} finally {
102-
Sentry.endSession();
103-
// Flush with a timeout to ensure events are sent before process exits
104-
try {
105-
await client.flush(3000);
106-
} catch {
107-
// Ignore flush errors - telemetry should never block CLI
108-
}
109120
}
110121
}
111122

112123
/**
113-
* Initialize Sentry for telemetry.
124+
* Create a beforeExit handler that ends healthy sessions and flushes events.
114125
*
115-
* @param enabled - Whether telemetry is enabled
116-
* @returns The Sentry client, or undefined if initialization failed
126+
* The SDK's processSessionIntegration only ends non-OK sessions (crashed/errored).
127+
* This handler complements it by ending OK sessions (clean exit → 'exited')
128+
* and flushing pending events. Includes a re-entry guard since flush is async
129+
* and causes beforeExit to re-fire when complete.
130+
*
131+
* @param client - The Sentry client to flush
132+
* @returns A handler function for process.on("beforeExit")
117133
*
118134
* @internal Exported for testing
119135
*/
136+
export function createBeforeExitHandler(client: Sentry.BunClient): () => void {
137+
let isFlushing = false;
138+
return () => {
139+
if (isFlushing) {
140+
return;
141+
}
142+
isFlushing = true;
143+
144+
const session = Sentry.getIsolationScope().getSession();
145+
if (session?.status === "ok") {
146+
Sentry.endSession();
147+
}
148+
149+
// Flush pending events before exit. Convert PromiseLike to Promise
150+
// for proper error handling. The async work causes beforeExit to
151+
// re-fire when complete, which the isFlushing guard handles.
152+
Promise.resolve(client.flush(3000)).catch(() => {
153+
// Ignore flush errors — telemetry should never block CLI exit
154+
});
155+
};
156+
}
157+
120158
/**
121159
* Integrations to exclude for CLI.
122160
* These add overhead without benefit for short-lived CLI processes.
@@ -128,6 +166,17 @@ const EXCLUDED_INTEGRATIONS = new Set([
128166
"Modules", // Lists all loaded modules - unnecessary for CLI telemetry
129167
]);
130168

169+
/** Current beforeExit handler, tracked so it can be replaced on re-init */
170+
let currentBeforeExitHandler: (() => void) | null = null;
171+
172+
/**
173+
* Initialize Sentry for telemetry.
174+
*
175+
* @param enabled - Whether telemetry is enabled
176+
* @returns The Sentry client, or undefined if initialization failed
177+
*
178+
* @internal Exported for testing
179+
*/
131180
export function initSentry(enabled: boolean): Sentry.BunClient | undefined {
132181
const environment = process.env.NODE_ENV ?? "development";
133182

@@ -187,6 +236,23 @@ export function initSentry(enabled: boolean): Sentry.BunClient | undefined {
187236

188237
// Tag whether targeting self-hosted Sentry (not SaaS)
189238
Sentry.setTag("is_self_hosted", !isSentrySaasUrl(getSentryBaseUrl()));
239+
240+
// End healthy sessions and flush events when the event loop drains.
241+
// The SDK's processSessionIntegration starts a session during init and
242+
// registers its own beforeExit handler that ends non-OK (crashed/errored)
243+
// sessions. We complement it by ending OK sessions (clean exit → 'exited')
244+
// and flushing pending events. This covers unhandled rejections and other
245+
// paths that bypass withTelemetry's try/catch.
246+
// Ref: https://nodejs.org/api/process.html#event-beforeexit
247+
//
248+
// Replace previous handler on re-init (e.g., auto-login retry calls
249+
// withTelemetry → initSentry twice) to avoid duplicate handlers with
250+
// independent re-entry guards and stale client references.
251+
if (currentBeforeExitHandler) {
252+
process.removeListener("beforeExit", currentBeforeExitHandler);
253+
}
254+
currentBeforeExitHandler = createBeforeExitHandler(client);
255+
process.on("beforeExit", currentBeforeExitHandler);
190256
}
191257

192258
return client;

test/lib/telemetry-session.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* Telemetry Session Tests
3+
*
4+
* Tests for session lifecycle management:
5+
* - beforeExit handler (createBeforeExitHandler)
6+
* - Session crash marking (markSessionCrashed)
7+
*
8+
* These tests are in a separate file because they mock Sentry's scope methods
9+
* (getCurrentScope, getIsolationScope) which conflicts with the SDK's internal
10+
* scope machinery. Running in a separate Bun test worker prevents interference
11+
* with other telemetry tests.
12+
*/
13+
14+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
15+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
16+
import * as Sentry from "@sentry/bun";
17+
import {
18+
createBeforeExitHandler,
19+
markSessionCrashed,
20+
} from "../../src/lib/telemetry.js";
21+
22+
describe("createBeforeExitHandler", () => {
23+
/**
24+
* Create a minimal mock BunClient for testing the beforeExit handler.
25+
* Only needs flush() since that's all the handler uses from the client.
26+
*/
27+
function createMockClient(): Sentry.BunClient {
28+
return {
29+
flush: () => Promise.resolve(true),
30+
} as unknown as Sentry.BunClient;
31+
}
32+
33+
test("ends OK session on beforeExit", () => {
34+
const handler = createBeforeExitHandler(createMockClient());
35+
36+
const mockSession = { status: "ok", errors: 0 };
37+
const getIsolationScopeSpy = spyOn(
38+
Sentry,
39+
"getIsolationScope"
40+
).mockReturnValue({
41+
getSession: () => mockSession,
42+
} as unknown as Sentry.Scope);
43+
// Mock endSession to prevent it from calling through to real SDK internals
44+
const endSessionSpy = spyOn(Sentry, "endSession").mockImplementation(
45+
() => null
46+
);
47+
48+
handler();
49+
50+
expect(endSessionSpy).toHaveBeenCalled();
51+
52+
getIsolationScopeSpy.mockRestore();
53+
endSessionSpy.mockRestore();
54+
});
55+
56+
test("does not end non-OK session on beforeExit (SDK handles it)", () => {
57+
const handler = createBeforeExitHandler(createMockClient());
58+
59+
const mockSession = { status: "crashed", errors: 1 };
60+
const getIsolationScopeSpy = spyOn(
61+
Sentry,
62+
"getIsolationScope"
63+
).mockReturnValue({
64+
getSession: () => mockSession,
65+
} as unknown as Sentry.Scope);
66+
const endSessionSpy = spyOn(Sentry, "endSession").mockImplementation(
67+
() => null
68+
);
69+
70+
handler();
71+
72+
expect(endSessionSpy).not.toHaveBeenCalled();
73+
74+
getIsolationScopeSpy.mockRestore();
75+
endSessionSpy.mockRestore();
76+
});
77+
78+
test("does not end session when no session exists", () => {
79+
const handler = createBeforeExitHandler(createMockClient());
80+
81+
const getIsolationScopeSpy = spyOn(
82+
Sentry,
83+
"getIsolationScope"
84+
).mockReturnValue({
85+
getSession: () => null,
86+
} as unknown as Sentry.Scope);
87+
const endSessionSpy = spyOn(Sentry, "endSession").mockImplementation(
88+
() => null
89+
);
90+
91+
handler();
92+
93+
expect(endSessionSpy).not.toHaveBeenCalled();
94+
95+
getIsolationScopeSpy.mockRestore();
96+
endSessionSpy.mockRestore();
97+
});
98+
99+
test("re-entry guard prevents double flush", () => {
100+
const handler = createBeforeExitHandler(createMockClient());
101+
102+
const mockSession = { status: "ok", errors: 0 };
103+
const getIsolationScopeSpy = spyOn(
104+
Sentry,
105+
"getIsolationScope"
106+
).mockReturnValue({
107+
getSession: () => mockSession,
108+
} as unknown as Sentry.Scope);
109+
const endSessionSpy = spyOn(Sentry, "endSession").mockImplementation(
110+
() => null
111+
);
112+
113+
// Call twice
114+
handler();
115+
handler();
116+
117+
// endSession should only be called once due to re-entry guard
118+
expect(endSessionSpy).toHaveBeenCalledTimes(1);
119+
120+
getIsolationScopeSpy.mockRestore();
121+
endSessionSpy.mockRestore();
122+
});
123+
124+
test("flushes client on beforeExit", () => {
125+
const mockClient = createMockClient();
126+
const flushSpy = spyOn(mockClient, "flush");
127+
const handler = createBeforeExitHandler(mockClient);
128+
129+
const getIsolationScopeSpy = spyOn(
130+
Sentry,
131+
"getIsolationScope"
132+
).mockReturnValue({
133+
getSession: () => null,
134+
} as unknown as Sentry.Scope);
135+
136+
handler();
137+
138+
expect(flushSpy).toHaveBeenCalledWith(3000);
139+
140+
getIsolationScopeSpy.mockRestore();
141+
flushSpy.mockRestore();
142+
});
143+
});
144+
145+
describe("markSessionCrashed", () => {
146+
let getCurrentScopeSpy: ReturnType<typeof spyOn>;
147+
let getIsolationScopeSpy: ReturnType<typeof spyOn>;
148+
149+
afterEach(() => {
150+
getCurrentScopeSpy?.mockRestore();
151+
getIsolationScopeSpy?.mockRestore();
152+
});
153+
154+
test("marks session as crashed from current scope", () => {
155+
const mockSession = { status: "ok", errors: 0 };
156+
157+
getCurrentScopeSpy = spyOn(Sentry, "getCurrentScope").mockReturnValue({
158+
getSession: () => mockSession,
159+
} as unknown as Sentry.Scope);
160+
getIsolationScopeSpy = spyOn(Sentry, "getIsolationScope").mockReturnValue({
161+
getSession: () => null,
162+
} as unknown as Sentry.Scope);
163+
164+
markSessionCrashed();
165+
166+
expect(mockSession.status).toBe("crashed");
167+
});
168+
169+
test("marks session as crashed from isolation scope when current scope has none", () => {
170+
const mockSession = { status: "ok", errors: 0 };
171+
172+
getCurrentScopeSpy = spyOn(Sentry, "getCurrentScope").mockReturnValue({
173+
getSession: () => null,
174+
} as unknown as Sentry.Scope);
175+
getIsolationScopeSpy = spyOn(Sentry, "getIsolationScope").mockReturnValue({
176+
getSession: () => mockSession,
177+
} as unknown as Sentry.Scope);
178+
179+
markSessionCrashed();
180+
181+
expect(mockSession.status).toBe("crashed");
182+
});
183+
184+
test("does nothing when no session exists on either scope", () => {
185+
getCurrentScopeSpy = spyOn(Sentry, "getCurrentScope").mockReturnValue({
186+
getSession: () => null,
187+
} as unknown as Sentry.Scope);
188+
getIsolationScopeSpy = spyOn(Sentry, "getIsolationScope").mockReturnValue({
189+
getSession: () => null,
190+
} as unknown as Sentry.Scope);
191+
192+
// Should not throw
193+
expect(() => markSessionCrashed()).not.toThrow();
194+
});
195+
196+
test("prefers current scope session over isolation scope", () => {
197+
const currentSession = { status: "ok", errors: 0 };
198+
const isolationSession = { status: "ok", errors: 0 };
199+
200+
getCurrentScopeSpy = spyOn(Sentry, "getCurrentScope").mockReturnValue({
201+
getSession: () => currentSession,
202+
} as unknown as Sentry.Scope);
203+
getIsolationScopeSpy = spyOn(Sentry, "getIsolationScope").mockReturnValue({
204+
getSession: () => isolationSession,
205+
} as unknown as Sentry.Scope);
206+
207+
markSessionCrashed();
208+
209+
// Current scope session should be marked, isolation scope left unchanged
210+
expect(currentSession.status).toBe("crashed");
211+
expect(isolationSession.status).toBe("ok");
212+
});
213+
});

0 commit comments

Comments
 (0)