Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/selfhost/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,16 +431,21 @@ export async function buildSentryOpenTelemetryBridge(): Promise<OpenTelemetryBri
};
}

/** Name a synthetic Error before capture so its Sentry issue title reads "eventName: message" instead of the
/** Name a captured Error before capture so its Sentry issue title reads "eventName: message" instead of the
* generic "Error: message" (or a caught exception's own class name, e.g. "HttpError: ..."). Mirrors
* forwardStructuredLogToSentry's `errorEvent.name = event` below — same discipline, applied to the handful of
* call sites that construct their OWN `new Error(...)` purely to report a known condition, not to a genuinely
* caught exception (which keeps its real name/type — mislabeling those would hide what actually threw). Only
* renames when the caller opts in; every other captureError/captureReviewFailure call is unaffected. */
* forwardStructuredLogToSentry's `errorEvent.name = event` below, but never mutates the caught value: some
* runtime errors (notably DOMException from AbortSignal.timeout/fetch) expose a read-only `name` in strict mode. */
function namedCaptureError(error: unknown, eventName?: string): Error {
const err = error instanceof Error ? error : new Error(String(error));
if (eventName) err.name = eventName;
return err;
if (!eventName) return err;
const namedError = new Error(err.message, { cause: err });
namedError.name = eventName;
Object.defineProperty(namedError, "stack", {
value: err.stack,
configurable: true,
writable: true,
});
return namedError;
}

/** Capture an error with optional structured context. No-op when Sentry is off. `eventName`, when given, becomes
Expand Down
22 changes: 22 additions & 0 deletions test/unit/selfhost-sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,28 @@ describe("enabled when SENTRY_DSN is set", () => {
expect(lastCapturedError().name).toBe("ai_review_failed");
});

it("captureError/captureReviewFailure with an eventName never mutate caught errors that reject name writes", async () => {
await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv);

const timeoutError = new DOMException("signal timed out", "TimeoutError");
captureError(timeoutError, { kind: "fetch_timeout" }, "github_fetch_failed");

expect(timeoutError.name).toBe("TimeoutError");
expect(lastCapturedError()).not.toBe(timeoutError);
expect(lastCapturedError().name).toBe("github_fetch_failed");
expect(lastCapturedError().message).toBe("signal timed out");
expect(lastCapturedError().cause).toBe(timeoutError);

const frozenError = Object.freeze(new Error("review failed"));
captureReviewFailure(frozenError, { repo: "o/r" }, "ai_review_failed");

expect(frozenError.name).toBe("Error");
expect(lastCapturedError()).not.toBe(frozenError);
expect(lastCapturedError().name).toBe("ai_review_failed");
expect(lastCapturedError().message).toBe("review failed");
expect(lastCapturedError().cause).toBe(frozenError);
});

it("adds active OTEL trace ids to captured Sentry events", async () => {
await initSentry({ SENTRY_DSN: "d" } as unknown as NodeJS.ProcessEnv);
otelMocks.currentOtelTraceIds.mockReturnValue({
Expand Down
Loading