diff --git a/src/logging/sentry.test.ts b/src/logging/sentry.test.ts index fbbd4bfe2..3e969ca6b 100644 --- a/src/logging/sentry.test.ts +++ b/src/logging/sentry.test.ts @@ -3,9 +3,9 @@ * * SPDX-License-Identifier: MIT */ -import { captureException } from "@sentry/browser"; +import { captureException, ErrorEvent, init } from "@sentry/browser"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { reportError } from "./sentry"; +import { initSentry, reportError } from "./sentry"; vi.mock("@sentry/browser", () => ({ addBreadcrumb: vi.fn(), @@ -94,3 +94,63 @@ describe("reportError", () => { expect(hint).toBeUndefined(); }); }); + +describe("language server request failure grouping", () => { + const fingerprint = (message: string) => { + initSentry({ VITE_SENTRY_DSN: dsn }); + const { beforeSend } = vi.mocked(init).mock.lastCall![0]!; + const event: ErrorEvent = { + type: undefined, + exception: { values: [{ type: "Error", value: message }] }, + }; + return (beforeSend!(event, {}) as ErrorEvent).fingerprint; + }; + + it("groups by request and message", () => { + expect( + fingerprint( + "Request textDocument/completion failed with message: Maximum call stack size exceeded" + ) + ).toEqual([ + "language-server-request-failed", + "textDocument/completion", + "Maximum call stack size exceeded", + ]); + }); + + it("ignores which script failed to load", () => { + const failure = (url: string) => + fingerprint( + `Request initialize failed with message: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '${url}' failed to load.` + ); + const fr = failure( + "https://python.microbit.org/v/3/workers/pyright-locale-fr-231-e938f9c58b9ac7c8a0c8.worker.js" + ); + expect(fr).toEqual([ + "language-server-request-failed", + "initialize", + "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '' failed to load.", + ]); + expect( + failure( + "https://python.microbit.org/v/3/workers/pyright-locale-en-100-ddc72e6b2c1f5de1a885.worker.js" + ) + ).toEqual(fr); + }); + + it("separates scripts resolved against a blob URL", () => { + expect( + fingerprint( + "Request initialize failed with message: Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at 'blob:https://python.microbit.org/pyright-locale-en-100-ddc72e6b2c1f5de1a885.worker.js' failed to load." + ) + ).toEqual([ + "language-server-request-failed", + "initialize", + "Failed to execute 'importScripts' on 'WorkerGlobalScope': The script at '' failed to load.", + ]); + }); + + it("leaves other errors to default grouping", () => { + expect(fingerprint("boom")).toBeUndefined(); + }); +}); diff --git a/src/logging/sentry.ts b/src/logging/sentry.ts index af5ecfc2d..150078646 100644 --- a/src/logging/sentry.ts +++ b/src/logging/sentry.ts @@ -6,6 +6,7 @@ import { addBreadcrumb as sentryAddBreadcrumb, captureException as sentryCaptureException, + ErrorEvent, init as sentryInit, } from "@sentry/browser"; @@ -31,6 +32,7 @@ export const initSentry = (env: Record): string | undefined => { // Low consequence and a big chunk of quota. "ResizeObserver loop completed with undelivered notifications", ], + beforeSend: fingerprintLanguageServerRequestFailure, }); } catch (e) { console.error(e); @@ -38,6 +40,33 @@ export const initSentry = (env: Record): string | undefined => { return dsn; }; +/** + * Every failed language server request is rejected from the same place in + * vscode-jsonrpc, so Sentry's stack-based grouping puts unrelated failures + * in one issue. Group by the request and the server's message instead. + */ +const fingerprintLanguageServerRequestFailure = ( + event: ErrorEvent +): ErrorEvent => { + const message = event.exception?.values?.[0]?.value; + const match = + message && /^Request (\S+) failed with message: (.*)$/s.exec(message); + if (match) { + const [, method, serverMessage] = match; + event.fingerprint = [ + "language-server-request-failed", + method, + // Script URLs vary by locale and build. Blob URLs stay distinct: the + // script's path was resolved against a blob rather than our URL, so + // unlike a network failure it can't succeed on retry. + serverMessage.replace(/'(blob:)?https?:[^']*'/g, (_, blob) => + blob ? "''" : "''" + ), + ]; + } + return event; +}; + /** * Report an error to Sentry (if configured) and the console. *