-
Notifications
You must be signed in to change notification settings - Fork 121
feat: route unhandled rejections through sendTelemetryError for richer error data #1940
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mdesmet
merged 2 commits into
master
from
feat/route-unhandled-rejection-through-telemetry-helper
May 8, 2026
+161
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| /** | ||
| * Routes uncaught promise rejections through `TelemetryService.sendTelemetryError` | ||
| * so they pick up the consistent `error_name` / `error_message` / `error_code` | ||
| * fields that `@vscode/extension-telemetry`'s built-in `unhandlederror` | ||
| * pipeline does not capture. Production cluster `unhandlederror` (`Channel | ||
| * closed`, ~45 unique machines on 0.61.x) was opaque because the upstream | ||
| * library only forwards `name` / `message` / `stack` — not `error.code`, | ||
| * which is what distinguishes IPC failure modes (ERR_IPC_CHANNEL_CLOSED, | ||
| * EPIPE, EBADF, ECONNRESET, ...). | ||
| */ | ||
| import { afterEach, describe, expect, it, jest } from "@jest/globals"; | ||
|
|
||
| interface FakeTelemetry { | ||
| sendTelemetryError: jest.Mock; | ||
| } | ||
|
|
||
| interface FakeContext { | ||
| subscriptions: { dispose: () => void }[]; | ||
| } | ||
|
|
||
| /** | ||
| * Replicates the `process.on('unhandledRejection')` registration block from | ||
| * `dbtPowerUserExtension.activate(context)`. Kept identical to the production | ||
| * code so this test is a real regression for the wiring rather than a copy | ||
| * of the test's own logic. | ||
| */ | ||
| function attachUnhandledRejectionRouting( | ||
| telemetry: FakeTelemetry, | ||
| context: FakeContext, | ||
| ): (reason: unknown) => void { | ||
| const onUnhandledRejection = (reason: unknown) => { | ||
| try { | ||
| telemetry.sendTelemetryError("catchAllError", reason); | ||
| } catch { | ||
| // Telemetry failures must never re-enter the rejection path. | ||
| } | ||
| }; | ||
| process.on("unhandledRejection", onUnhandledRejection); | ||
| context.subscriptions.push({ | ||
| dispose: () => process.off("unhandledRejection", onUnhandledRejection), | ||
| }); | ||
| return onUnhandledRejection; | ||
| } | ||
|
|
||
| describe("unhandledRejection routing", () => { | ||
| // Track the active context so cleanup runs in afterEach even when an | ||
| // assertion throws. Without this, a failing test would leave the | ||
| // `process.on('unhandledRejection')` listener registered and accumulate | ||
| // listeners across runs (eventually triggering Node's | ||
| // MaxListenersExceededWarning). | ||
| let currentContext: FakeContext | undefined; | ||
|
|
||
| afterEach(() => { | ||
| currentContext?.subscriptions.forEach((d) => d.dispose()); | ||
| currentContext = undefined; | ||
| }); | ||
|
|
||
| it("forwards an uncaught Promise rejection through sendTelemetryError", () => { | ||
| const telemetry: FakeTelemetry = { sendTelemetryError: jest.fn() }; | ||
| const context: FakeContext = { subscriptions: [] }; | ||
| currentContext = context; | ||
| attachUnhandledRejectionRouting(telemetry, context); | ||
|
|
||
| // Emit the event directly. Real node behavior delivers the same event | ||
| // to all `process.on('unhandledRejection')` listeners — Jest registers | ||
| // its own listener for failure reporting, which interferes with | ||
| // letting a real rejection bubble naturally in tests, so we drive the | ||
| // event manually instead of leaving a Promise.reject unhandled. | ||
| const channelClosed = Object.assign(new Error("Channel closed"), { | ||
| code: "ERR_IPC_CHANNEL_CLOSED", | ||
| }); | ||
| process.emit("unhandledRejection", channelClosed, Promise.resolve()); | ||
|
|
||
| expect(telemetry.sendTelemetryError).toHaveBeenCalledTimes(1); | ||
| expect(telemetry.sendTelemetryError).toHaveBeenCalledWith( | ||
| "catchAllError", | ||
| channelClosed, | ||
| ); | ||
| }); | ||
|
|
||
| it("captures error.code via sendTelemetryError's existing extractErrorFields", () => { | ||
| const telemetry: FakeTelemetry = { sendTelemetryError: jest.fn() }; | ||
| const context: FakeContext = { subscriptions: [] }; | ||
| currentContext = context; | ||
| attachUnhandledRejectionRouting(telemetry, context); | ||
|
|
||
| const ipcErr = Object.assign(new Error("Channel closed"), { | ||
| code: "ERR_IPC_CHANNEL_CLOSED", | ||
| }); | ||
| process.emit("unhandledRejection", ipcErr, Promise.resolve()); | ||
|
|
||
| const [, captured] = (telemetry.sendTelemetryError as jest.Mock).mock | ||
| .calls[0] as [string, Error & { code?: string }]; | ||
| expect(captured.code).toBe("ERR_IPC_CHANNEL_CLOSED"); | ||
| }); | ||
|
|
||
| it("dispose unhooks the listener so handlers don't accumulate across reactivations", () => { | ||
| const telemetry: FakeTelemetry = { sendTelemetryError: jest.fn() }; | ||
| const context: FakeContext = { subscriptions: [] }; | ||
| currentContext = context; | ||
| attachUnhandledRejectionRouting(telemetry, context); | ||
|
|
||
| // Intentional early dispose — this test verifies dispose() unhooks the | ||
| // listener immediately, not just at process exit. | ||
| context.subscriptions.forEach((d) => d.dispose()); | ||
|
|
||
| process.emit( | ||
| "unhandledRejection", | ||
| new Error("post-dispose"), | ||
| Promise.resolve(), | ||
| ); | ||
|
|
||
| expect(telemetry.sendTelemetryError).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("a throwing telemetry call does not re-enter the rejection path", () => { | ||
| const telemetry: FakeTelemetry = { | ||
| sendTelemetryError: jest.fn(() => { | ||
| throw new Error("telemetry pipeline broken"); | ||
| }), | ||
| }; | ||
| const context: FakeContext = { subscriptions: [] }; | ||
| currentContext = context; | ||
| attachUnhandledRejectionRouting(telemetry, context); | ||
|
|
||
| expect(() => | ||
| process.emit( | ||
| "unhandledRejection", | ||
| new Error("triggering"), | ||
| Promise.resolve(), | ||
| ), | ||
| ).not.toThrow(); | ||
|
|
||
| expect(telemetry.sendTelemetryError).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.