diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 5487a5b221..207566aec1 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -9,14 +9,44 @@ jest.mock("@sentry/nextjs", () => ({ captureRouterTransitionStart: mockCaptureRouterTransitionStart, })); -describe("instrumentation-client beforeSendTransaction", () => { - const loadBeforeSendTransaction = () => { +describe("instrumentation-client", () => { + const wrappedNetworkMessage = + "Network request failed. Please check your connection and try again. (/api/waves-overview)"; + + type BeforeSendResult = { + tags?: Record | undefined; + fingerprint?: string[] | undefined; + exception?: + | { + values?: Array<{ value?: string | undefined } | undefined>; + } + | undefined; + message?: string | undefined; + } | null; + + const loadSentryConfig = () => { jest.isolateModules(() => { require("@/instrumentation-client"); }); const config = mockInit.mock.calls[0]?.[0]; expect(config).toBeDefined(); + + return config; + }; + + const loadBeforeSend = () => { + const config = loadSentryConfig(); + expect(typeof config.beforeSend).toBe("function"); + + return config.beforeSend as ( + event: Record, + hint?: Record + ) => BeforeSendResult; + }; + + const loadBeforeSendTransaction = () => { + const config = loadSentryConfig(); expect(typeof config.beforeSendTransaction).toBe("function"); return config.beforeSendTransaction as (event: Record) => { @@ -37,6 +67,791 @@ describe("instrumentation-client beforeSendTransaction", () => { mockCaptureRouterTransitionStart.mockReset(); }); + it("drops sampled-out first-party browser transport network errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + }); + + it("drops sampled-out first-party WebKit network connection lost errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "The network connection was lost.", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("The network connection was lost."), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network error: The network connection was lost. (/api/waves-overview)" + ); + }); + + it("rewrites raw browser network errors with the first-party failed target before dropping", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("keeps raw browser network errors for newer first-party page failures", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/notifications", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.exception?.values?.[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/notifications)" + ); + expect(result?.tags).toEqual( + expect.objectContaining({ + errorType: "network", + handled: true, + }) + ); + expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); + }); + + it("drops sampled-out raw browser transport network errors when a later unrelated request fails", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + }); + + it("uses a failed fetch breadcrumb with no status for raw browser network errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + category: "fetch", + level: "error", + data: { + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("keeps a failed fetch breadcrumb with no status ahead of a later successful breadcrumb", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + category: "fetch", + level: "error", + data: { + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("uses a status 0 breadcrumb instead of a later successful breadcrumb", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + request: { + url: "/api/request-target", + }, + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("falls back to the request URL when only successful breadcrumbs exist", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-keep-event", + request: { + url: "/api/request-target", + }, + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.exception?.values?.[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/request-target)" + ); + expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); + }); + + it("uses the latest failed fetch breadcrumb for raw browser network errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/a", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/b", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + expect(event.exception.values[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/b)" + ); + }); + + it("keeps and tags sampled-in first-party browser transport network errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "event-200", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags).toEqual( + expect.objectContaining({ + errorType: "network", + handled: true, + network_failure_kind: "browser_transport", + network_noise_sampled: "true", + }) + ); + expect(result?.fingerprint).toEqual(["network-error"]); + }); + + it("drops sampled-out app-wrapped first-party browser transport network errors", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "Error", + value: wrappedNetworkMessage, + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new Error(wrappedNetworkMessage), + }); + + expect(result).toBeNull(); + }); + + it("drops sampled-out app-wrapped browser transport network errors when a later unrelated request fails", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "Error", + value: wrappedNetworkMessage, + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new Error(wrappedNetworkMessage), + }); + + expect(result).toBeNull(); + }); + + it("keeps and tags sampled-in app-wrapped first-party browser transport network errors without rewriting the message", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "event-200", + message: wrappedNetworkMessage, + fingerprint: ["drop-reaction", "network"], + exception: { + values: [ + { + type: "Error", + value: wrappedNetworkMessage, + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new Error(wrappedNetworkMessage), + }); + + expect(result).not.toBeNull(); + expect(result?.tags).toEqual( + expect.objectContaining({ + errorType: "network", + handled: true, + network_failure_kind: "browser_transport", + network_noise_sampled: "true", + }) + ); + expect(result?.exception?.values?.[0]?.value).toBe(wrappedNetworkMessage); + expect(result?.message).toBe(wrappedNetworkMessage); + expect(result?.fingerprint).toEqual(["drop-reaction", "network"]); + }); + + it("does not tag unrelated plain errors that mention network", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "event-200", + exception: { + values: [ + { + type: "Error", + value: "network switch failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new Error("network switch failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags?.["errorType"]).toBeUndefined(); + }); + + it("does not tag unrelated TypeErrors that mention network", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "event-200", + exception: { + values: [ + { + type: "TypeError", + value: "network switch failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("network switch failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags?.["errorType"]).toBeUndefined(); + expect(result?.exception?.values?.[0]?.value).toBe("network switch failed"); + }); + + it("keeps browser network errors with a real HTTP status", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags).toEqual( + expect.objectContaining({ + errorType: "network", + handled: true, + }) + ); + expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); + expect(result?.exception?.values?.[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("keeps browser network errors when a later breadcrumb has a real HTTP failure", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags).toEqual( + expect.objectContaining({ + errorType: "network", + handled: true, + }) + ); + expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); + expect(result?.exception?.values?.[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + }); + + it("uses the failed breadcrumb instead of request URL when a later same-target request has a real HTTP failure", () => { + const beforeSend = loadBeforeSend(); + const event = { + event_id: "network-drop-event", + request: { + url: "/api/identity", + }, + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.exception?.values?.[0]?.value).toBe( + "Network request failed. Please check your connection and try again. (/api/waves-overview)" + ); + expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); + }); + it("removes only the known noisy third-party telemetry spans", () => { const beforeSendTransaction = loadBeforeSendTransaction(); const event = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index f5088c4a86..230593eb1a 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -1,12 +1,19 @@ import { __testing, + getLowValueNetworkErrorDecision, + getLowValueNetworkErrorTargetUrl, + getNetworkErrorMessageTargetUrl, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, shouldFilterTwitterConfigReferenceError, + tagSampledLowValueNetworkError, } from "@/utils/sentry-client-filters"; describe("sentry-client-filters", () => { + const wrappedNetworkMessage = + "Network request failed. Please check your connection and try again. (/api/waves-overview)"; + const buildSpan = (overrides: Record = {}) => ({ op: "http.client", @@ -85,6 +92,37 @@ describe("sentry-client-filters", () => { ...overrides, }) as any; + const createLowValueNetworkEvent = ( + overrides: Record = {} + ) => + ({ + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: wrappedNetworkMessage, + }, + ], + }, + tags: { + errorType: "network", + handled: true, + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + ...overrides, + }) as any; + it("filters events when a stack frame matches a filename exception", () => { // Arrange const frames = [{ filename: "app:///extensionServiceWorker.js" } as any]; @@ -279,6 +317,1070 @@ describe("sentry-client-filters", () => { expect(result).toBe(false); }); + it("drops sampled-out first-party status 0 network errors", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent(), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops sampled-out WebKit network connection lost errors", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "The network connection was lost.", + }, + ], + }, + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops sampled-out first-party status 0 network errors when a later request succeeds", () => { + const event = createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getNetworkErrorMessageTargetUrl(event)).toBe("/api/waves-overview"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("drop"); + }); + + it("uses a real failed HTTP breadcrumb before a later successful breadcrumb for message targets", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getNetworkErrorMessageTargetUrl(event)).toBe("/api/waves-overview"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + + it("uses a first-party status 0 page breadcrumb before a later HTTP failure for raw message targets", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/waves", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBeNull(); + expect(getNetworkErrorMessageTargetUrl(event)).toBe("/waves"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + + it("uses a third-party status 0 breadcrumb before a later HTTP failure for raw message targets", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBeNull(); + expect(getNetworkErrorMessageTargetUrl(event)).toBe( + "https://example.com/collect" + ); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + + it("does not use successful breadcrumbs for network error message targets", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getNetworkErrorMessageTargetUrl(event)).toBeNull(); + }); + + it("drops sampled-out message-target status 0 errors when a later status 0 targets a different first-party API", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/a)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/a", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/b", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops sampled-out message-target status 0 errors when a later status 0 targets a third-party URL", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/a)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/a", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops raw first-party status 0 network errors when a later third-party status 0 fails", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBe("/api/waves-overview"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("drop"); + }); + + it("keeps raw network errors when only a third-party status 0 fails", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBeNull(); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + + it("keeps message-target network errors when only a different API has status 0", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/a)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/b", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("drops sampled-out first-party status 0 network errors when a later unrelated first-party request fails", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops sampled-out first-party status 0 network errors when later unrelated third-party requests fail", () => { + for (const statusCode of [404, 500]) { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: statusCode, + url: "https://example.com/collect", + "url.is_first_party": false, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + } + }); + + it("keeps first-party network errors when a later request has a real HTTP failure", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps first-party network errors when a later real HTTP failure has no URL", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps first-party network errors when a later real HTTP failure has a filtered URL", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "[Filtered]", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("drops sampled-out first-party status 0 network errors when later unknown HTTP failures are marked third-party", () => { + for (const laterFailureData of [ + { + status_code: 500, + "url.is_first_party": false, + }, + { + status_code: 500, + url: "[Filtered]", + "url.is_first_party": false, + }, + ]) { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: laterFailureData, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + } + }); + + it("keeps raw first-party network errors when a later same-target request has a real HTTP failure", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBeNull(); + expect(getNetworkErrorMessageTargetUrl(event)).toBe("/api/waves-overview"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + + it("keeps third-party API paths after URL sanitization", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/third-party)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/third-party", + "url.is_first_party": false, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("drops sampled-out relative API network errors when the breadcrumb URL is filtered", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "[Filtered]", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops sampled-out relative API network errors when the filtered breadcrumb URL was sanitized", () => { + for (const url of ["/[Filtered]", "/%5BFiltered%5D"]) { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + } + }); + + it("drops sampled-out relative API network errors when the breadcrumb URL is missing", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("keeps events with filtered breadcrumb URLs marked third-party", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "[Filtered]", + "url.is_first_party": false, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps events with missing breadcrumb URLs marked third-party", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + "url.is_first_party": false, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("drops first-party relative API paths when sampled out", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/first-party)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/first-party", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("keeps sampled-in first-party status 0 network errors", () => { + const event = createLowValueNetworkEvent(); + const result = getLowValueNetworkErrorDecision(event, 1); + + expect(result).toBe("keep_sampled"); + + tagSampledLowValueNetworkError(event); + + expect(event.tags).toEqual( + expect.objectContaining({ + network_failure_kind: "browser_transport", + network_noise_sampled: "true", + }) + ); + }); + + it("keeps network errors when the browser received a real HTTP status", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 500, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps TypeErrors that are not tagged as network errors", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + tags: {}, + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("samples tagged plain Error network events the same way as tagged TypeError events", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "Error", + value: wrappedNetworkMessage, + }, + ], + }, + }); + + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("drop"); + expect(getLowValueNetworkErrorDecision(event, 1)).toBe("keep_sampled"); + }); + + it("keeps network errors when no failed status is known", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + level: "info", + data: { + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("drops Sentry fetch error breadcrumbs without a status", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + category: "fetch", + level: "error", + data: { + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("handles Sentry breadcrumb values form for first-party status 0 errors", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: { + values: [ + { + type: "http", + category: "xhr", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }, + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("treats api.6529.io as a first-party API target", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (https://api.6529.io/waves-overview)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "https://api.6529.io/waves-overview", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("matches api.6529.io errors to sanitized breadcrumb paths", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (https://api.6529.io/waves-overview)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/waves-overview", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("drops api.6529.io paths after sanitization when the breadcrumb keeps API metadata", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/oracle/prenodes)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/oracle/prenodes", + "url.is_first_party": true, + "url.is_first_party_api": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + + it("keeps same-looking page paths when sanitized API metadata is missing", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/oracle/prenodes)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/oracle/prenodes", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps first-party page navigation failures", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/notifications)", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/notifications", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + it("keeps newer first-party page failures ahead of older API transport failures", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ + { + type: "TypeError", + value: "Load failed", + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/notifications", + "url.is_first_party": true, + }, + }, + ], + }); + + expect(getLowValueNetworkErrorTargetUrl(event)).toBeNull(); + expect(getNetworkErrorMessageTargetUrl(event)).toBe("/notifications"); + expect(getLowValueNetworkErrorDecision(event, 0)).toBe("not_applicable"); + }); + it("filters Twitter CONFIG reference errors with app URI frames", () => { // Arrange const event = createTwitterConfigEvent(); diff --git a/__tests__/utils/sentry-sanitizer.test.ts b/__tests__/utils/sentry-sanitizer.test.ts new file mode 100644 index 0000000000..8ac2eeeb49 --- /dev/null +++ b/__tests__/utils/sentry-sanitizer.test.ts @@ -0,0 +1,113 @@ +import { sanitizeSentryBreadcrumb } from "@/utils/sentry-sanitizer"; + +describe("sentry-sanitizer", () => { + it("strips third-party breadcrumb URLs to paths and marks them as non-first-party", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "https://example.com/api/waves-overview?token=secret#hash", + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/api/waves-overview", + "url.is_first_party": false, + }) + ); + }); + + it("marks first-party absolute breadcrumb URLs before stripping the host", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "https://api.6529.io/api/waves-overview?foo=bar", + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/api/waves-overview", + "url.is_first_party": true, + "url.is_first_party_api": true, + }) + ); + }); + + it("marks api.6529.io breadcrumb URLs as first-party API before stripping the host", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "https://api.6529.io/oracle/prenodes?page=1", + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/oracle/prenodes", + "url.is_first_party": true, + "url.is_first_party_api": true, + }) + ); + }); + + it("marks relative breadcrumb URLs as first-party", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "/api/waves-overview?foo=bar", + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/api/waves-overview", + "url.is_first_party": true, + "url.is_first_party_api": true, + }) + ); + }); + + it("keeps existing breadcrumb URL origin metadata on later sanitize passes", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "/api/waves-overview", + "url.is_first_party": false, + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/api/waves-overview", + "url.is_first_party": false, + "url.is_first_party_api": false, + }) + ); + }); + + it("keeps existing breadcrumb first-party API metadata on later sanitize passes", () => { + const breadcrumb = sanitizeSentryBreadcrumb({ + type: "http", + category: "fetch", + data: { + url: "/oracle/prenodes", + "url.is_first_party": true, + "url.is_first_party_api": true, + }, + }); + + expect(breadcrumb?.data).toEqual( + expect.objectContaining({ + url: "/oracle/prenodes", + "url.is_first_party": true, + "url.is_first_party_api": true, + }) + ); + }); +}); diff --git a/instrumentation-client.ts b/instrumentation-client.ts index a4122ba6ff..4e37b9973f 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,11 +13,14 @@ import { sanitizeUrlString, } from "@/utils/sentry-sanitizer"; import { + getLowValueNetworkErrorDecision, + getNetworkErrorMessageTargetUrl, getThirdPartyTelemetrySpanTargetKey, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, shouldFilterTwitterConfigReferenceError, + tagSampledLowValueNetworkError, type SentryTransactionSpan, } from "@/utils/sentry-client-filters"; import * as Sentry from "@sentry/nextjs"; @@ -37,6 +40,10 @@ const noisyPatterns = [ const referenceErrors = ["__firefox__"]; const URL_REGEX = /\(([^)]+?)\)/; +const APP_WRAPPED_NETWORK_ERROR_PREFIXES = [ + "Network request failed.", + "Network error:", +]; type SentryTransactionEvent = Sentry.Event & { spans?: SentryTransactionSpan[] | undefined; tags?: Record | undefined; @@ -162,18 +169,17 @@ function handleIndexedDBError(event: Sentry.Event): void { event.fingerprint = ["indexeddb-connection-lost"]; } -function extractUrlFromError(error: TypeError, event: Sentry.Event): string { +function extractUrlFromError(error: Error, event: Sentry.Event): string { const urlMatch = URL_REGEX.exec(error.message.slice(0, 2048)); if (urlMatch?.[1]) { return String(sanitizeUrlString(urlMatch[1])); } - const fetchBreadcrumb = event.breadcrumbs?.find( - (crumb) => crumb.category === "fetch" || crumb.type === "http" - ); - if (fetchBreadcrumb?.data?.["url"]) { - return String(sanitizeUrlString(fetchBreadcrumb.data["url"])); + const networkMessageTargetUrl = getNetworkErrorMessageTargetUrl(event); + if (networkMessageTargetUrl) { + return String(sanitizeUrlString(networkMessageTargetUrl)); } + if (event.request?.url) { return String(sanitizeUrlString(event.request.url)); } @@ -187,25 +193,61 @@ function isNetworkError(errorMessage: string): boolean { normalized.includes("load failed") || normalized.includes("networkerror") || normalized.includes("network error") || - normalized.includes("network request failed") || - /\bnetwork\b/.test(normalized) + normalized.includes("network connection was lost") || + normalized.includes("network request failed") + ); +} + +function isRawBrowserNetworkError(error: Error): boolean { + return ( + error.name === "NetworkError" || + (error instanceof TypeError && isNetworkError(error.message)) + ); +} + +function hasUrlInParentheses(message: string): boolean { + const urlMatch = URL_REGEX.exec(message.slice(0, 2048)); + const candidate = urlMatch?.[1]?.trim(); + return ( + !!candidate && + (candidate.startsWith("/") || /^https?:\/\//i.test(candidate)) + ); +} + +function isAppWrappedApiNetworkError(errorMessage: string): boolean { + return ( + APP_WRAPPED_NETWORK_ERROR_PREFIXES.some((prefix) => + errorMessage.startsWith(prefix) + ) && hasUrlInParentheses(errorMessage) ); } +function getRawBrowserNetworkErrorMessage( + event: Sentry.Event, + error: Error +): string { + const url = extractUrlFromError(error, event); + const normalized = error.message.toLowerCase(); + return normalized.includes("network") + ? `Network error: ${error.message} (${url})` + : `Network request failed. Please check your connection and try again. (${url})`; +} + function handleNetworkError( event: Sentry.Event, - error: TypeError, + error: Error, value: Sentry.Exception | undefined ): void { - if (!isNetworkError(error.message)) { + const isAppWrappedError = isAppWrappedApiNetworkError(error.message); + const isRawBrowserError = isRawBrowserNetworkError(error); + + if (!isAppWrappedError && !isRawBrowserError) { return; } - const url = extractUrlFromError(error, event); - const normalized = error.message.toLowerCase(); - const transformedMessage = normalized.includes("network") - ? `Network error: ${error.message} (${url})` - : `Network request failed. Please check your connection and try again. (${url})`; + const transformedMessage = isAppWrappedError + ? error.message + : getRawBrowserNetworkErrorMessage(event, error); if (value) { value.value = transformedMessage; @@ -220,7 +262,9 @@ function handleNetworkError( errorType: "network", handled: true, }; - event.fingerprint = ["network-error"]; + if (!event.fingerprint || event.fingerprint.length === 0) { + event.fingerprint = ["network-error"]; + } } Sentry.init({ @@ -268,10 +312,18 @@ Sentry.init({ handleIndexedDBError(event); } - if (error instanceof TypeError) { + if (error instanceof Error) { handleNetworkError(event, error, value); } + const networkNoiseDecision = getLowValueNetworkErrorDecision(event); + if (networkNoiseDecision === "drop") { + return null; + } + if (networkNoiseDecision === "keep_sampled") { + tagSampledLowValueNetworkError(event); + } + return sanitizeSentryEvent(event); }, diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index c6a79cb9dc..56dc781420 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -12,11 +12,21 @@ export type SentryTransactionSpan = { type SentryContext = Record; type SentryBreadcrumb = { + type?: string | undefined; category?: string | undefined; + level?: string | undefined; message?: string | undefined; data?: Record | undefined; }; +type NetworkTargetCandidate = { + url: string; + isFirstParty?: boolean | undefined; + isFirstPartyApi?: boolean | undefined; +}; + +type NetworkBreadcrumbFailureKind = "transport" | "http"; + type SentryExceptionValue = { type?: string | undefined; value?: string | undefined; @@ -30,6 +40,8 @@ type SentryExceptionValue = { type SentryTags = Record; export type SentryClientEvent = { + event_id?: string | undefined; + message?: string | undefined; exception?: | { values?: SentryExceptionValue[] | undefined; @@ -50,6 +62,11 @@ export type SentryEventHint = { syntheticException?: unknown; }; +export type LowValueNetworkErrorDecision = + | "not_applicable" + | "drop" + | "keep_sampled"; + const filenameExceptions = [ "inpage.js", "extensionServiceWorker.js", @@ -70,11 +87,24 @@ const noisyThirdPartyTelemetryTargets = new Set([ "cca-lite.coinbase.com/metrics", "region1.google-analytics.com/g/collect", ]); +export const LOW_VALUE_NETWORK_ERROR_SAMPLE_RATE = 0.1; + +const URL_IN_PARENS_PATTERN = /\(([^)]+)\)/g; +const URL_IS_FIRST_PARTY_KEY = "url.is_first_party"; +const URL_IS_FIRST_PARTY_API_KEY = "url.is_first_party_api"; +const FNV_OFFSET_BASIS = 2166136261; +const FNV_PRIME = 16777619; +const UINT_32_SIZE = 4294967296; +const FILTERED_URL_TOKENS = new Set(["[filtered]", "[redacted]", "filtered"]); function getStringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function getBooleanValue(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function getNumericValue(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; @@ -88,6 +118,578 @@ function getNumericValue(value: unknown): number | null { return null; } +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values)); +} + +function isNetworkErrorMessage(value: string): boolean { + const normalized = value.toLowerCase(); + return ( + normalized.includes("failed to fetch") || + normalized.includes("load failed") || + normalized.includes("networkerror") || + normalized.includes("network error") || + normalized.includes("network connection was lost") || + normalized.includes("network request failed") + ); +} + +function getEventMessage(event: SentryClientEvent): string { + const exceptionValue = event.exception?.values?.[0]?.value; + if (typeof exceptionValue === "string" && exceptionValue) { + return exceptionValue; + } + + return typeof event.message === "string" ? event.message : ""; +} + +function getUrlCandidatesFromText(value: string): string[] { + const urls: string[] = []; + for (const match of value.matchAll(URL_IN_PARENS_PATTERN)) { + const candidate = match[1]?.trim(); + if (candidate) { + urls.push(candidate); + } + } + return urls; +} + +function isFilteredUrl(value: string | undefined): boolean { + if (!value) { + return true; + } + + const normalized = value.trim().toLowerCase(); + if (FILTERED_URL_TOKENS.has(normalized)) { + return true; + } + + const sanitizedPathToken = + normalized.startsWith("/") && !normalized.startsWith("//") + ? normalized.slice(1) + : normalized; + if (FILTERED_URL_TOKENS.has(sanitizedPathToken)) { + return true; + } + + try { + return FILTERED_URL_TOKENS.has(decodeURIComponent(sanitizedPathToken)); + } catch { + return false; + } +} + +function isRelativePath(value: string): boolean { + const normalized = value.trim(); + return normalized.startsWith("/") && !normalized.startsWith("//"); +} + +function parseAbsoluteRequestUrl(value: string | undefined): URL | null { + if (!value || isFilteredUrl(value)) { + return null; + } + + const normalized = value.trim(); + try { + if (normalized.startsWith("//")) { + return new URL(`https:${normalized}`); + } + if (/^https?:\/\//i.test(normalized)) { + return new URL(normalized); + } + return null; + } catch { + return null; + } +} + +function getRequestPathname(value: string | undefined): string | null { + if (!value || isFilteredUrl(value)) { + return null; + } + + const normalized = value.trim(); + if (isRelativePath(normalized)) { + try { + return new URL(normalized, "https://6529.io").pathname; + } catch { + return null; + } + } + + return parseAbsoluteRequestUrl(normalized)?.pathname ?? null; +} + +function isFirstPartyApiUrl(url: URL): boolean { + const hostname = url.hostname.toLowerCase(); + if (hostname === "api.6529.io") { + return true; + } + + return isFirstPartyHost(hostname) && url.pathname.startsWith("/api/"); +} + +function isFirstPartyApiTarget(candidate: NetworkTargetCandidate): boolean { + if (candidate.isFirstPartyApi === true) { + return true; + } + + const url = parseAbsoluteRequestUrl(candidate.url); + if (url) { + return isFirstPartyApiUrl(url); + } + + const pathname = getRequestPathname(candidate.url); + return ( + candidate.isFirstParty === true && + !!pathname && + pathname.startsWith("/api/") + ); +} + +function canUseAsSanitizedRelativePath( + candidate: NetworkTargetCandidate +): boolean { + return isRelativePath(candidate.url) && candidate.isFirstParty !== false; +} + +function hasExplicitThirdPartyRelativeOrigin( + candidate: NetworkTargetCandidate +): boolean { + return isRelativePath(candidate.url) && candidate.isFirstParty === false; +} + +function isSameFirstPartyApiTarget( + left: NetworkTargetCandidate, + right: NetworkTargetCandidate +): boolean { + if ( + hasExplicitThirdPartyRelativeOrigin(left) || + hasExplicitThirdPartyRelativeOrigin(right) + ) { + return false; + } + + const leftPathname = getRequestPathname(left.url); + const rightPathname = getRequestPathname(right.url); + if (!leftPathname || !rightPathname || leftPathname !== rightPathname) { + return false; + } + + const leftIsFirstPartyApi = isFirstPartyApiTarget(left); + const rightIsFirstPartyApi = isFirstPartyApiTarget(right); + + if (leftIsFirstPartyApi && rightIsFirstPartyApi) { + return true; + } + + return ( + (leftIsFirstPartyApi && canUseAsSanitizedRelativePath(right)) || + (rightIsFirstPartyApi && canUseAsSanitizedRelativePath(left)) + ); +} + +function isFilteredBreadcrumbFallbackApiTarget( + candidate: NetworkTargetCandidate +): boolean { + if (candidate.isFirstParty === false || isFilteredUrl(candidate.url)) { + return false; + } + + const normalizedUrl = candidate.url.trim(); + if (normalizedUrl.startsWith("//")) { + return false; + } + + const absoluteUrl = parseAbsoluteRequestUrl(normalizedUrl); + if (absoluteUrl) { + return isFirstPartyApiUrl(absoluteUrl); + } + + const pathname = getRequestPathname(normalizedUrl); + return ( + isRelativePath(normalizedUrl) && !!pathname && pathname.startsWith("/api/") + ); +} + +function getHttpBreadcrumbs(event: SentryClientEvent): SentryBreadcrumb[] { + return getBreadcrumbValues(event).filter( + (breadcrumb) => + breadcrumb.type === "http" || + breadcrumb.category === "fetch" || + breadcrumb.category === "xhr" + ); +} + +function getBreadcrumbStatusCode(breadcrumb: SentryBreadcrumb): number | null { + const data = breadcrumb.data; + return ( + getNumericValue(data?.["status_code"]) ?? + getNumericValue(data?.["http.response.status_code"]) + ); +} + +export function getBreadcrumbTransportStatusCode( + breadcrumb: SentryBreadcrumb +): number | null { + const statusCode = getBreadcrumbStatusCode(breadcrumb); + if (statusCode !== null) { + return statusCode; + } + + if (breadcrumb.category === "fetch" && breadcrumb.level === "error") { + return 0; + } + + return null; +} + +function getBreadcrumbFailureKind( + breadcrumb: SentryBreadcrumb +): NetworkBreadcrumbFailureKind | null { + const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); + if (statusCode === 0) { + return "transport"; + } + + if (statusCode !== null && statusCode >= 400) { + return "http"; + } + + return null; +} + +function getBreadcrumbUrl(breadcrumb: SentryBreadcrumb): string | undefined { + return getStringValue(breadcrumb.data?.["url"]); +} + +function getBreadcrumbUrlIsFirstParty( + breadcrumb: SentryBreadcrumb +): boolean | undefined { + return getBooleanValue(breadcrumb.data?.[URL_IS_FIRST_PARTY_KEY]); +} + +function getBreadcrumbUrlIsFirstPartyApi( + breadcrumb: SentryBreadcrumb +): boolean | undefined { + return getBooleanValue(breadcrumb.data?.[URL_IS_FIRST_PARTY_API_KEY]); +} + +function getBreadcrumbTargetCandidate( + breadcrumb: SentryBreadcrumb +): NetworkTargetCandidate | null { + const url = getBreadcrumbUrl(breadcrumb); + if (!url || isFilteredUrl(url)) { + return null; + } + + return { + url, + isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), + }; +} + +function getFailedTransportBreadcrumbTarget( + breadcrumb: SentryBreadcrumb +): NetworkTargetCandidate { + return { + url: getBreadcrumbUrl(breadcrumb) ?? "", + isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), + }; +} + +function canUseFailedTransportForMessageTarget( + failedTransportTarget: NetworkTargetCandidate, + messageTargetCandidates: NetworkTargetCandidate[] +): boolean { + if (isFilteredUrl(failedTransportTarget.url)) { + if (failedTransportTarget.isFirstParty === false) { + return false; + } + + return messageTargetCandidates.some(isFilteredBreadcrumbFallbackApiTarget); + } + + return messageTargetCandidates.some((target) => + isSameFirstPartyApiTarget(target, failedTransportTarget) + ); +} + +function getLatestFailedTransportBreadcrumb( + event: SentryClientEvent +): NetworkTargetCandidate | null { + const breadcrumbs = getHttpBreadcrumbs(event); + const messageTargetCandidates = getMessageTargetCandidates(event); + const laterRealFailureTargetCandidates: NetworkTargetCandidate[] = []; + + for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { + const breadcrumb = breadcrumbs[index]; + if (!breadcrumb) { + continue; + } + + const failureKind = getBreadcrumbFailureKind(breadcrumb); + if (failureKind === "http") { + const failedHttpTarget = getBreadcrumbTargetCandidate(breadcrumb); + if (!failedHttpTarget) { + if (getBreadcrumbUrlIsFirstParty(breadcrumb) === false) { + continue; + } + + return null; + } + + if ( + messageTargetCandidates.length > 0 && + messageTargetCandidates.some((target) => + isSameFirstPartyApiTarget(target, failedHttpTarget) + ) + ) { + return null; + } + + if (messageTargetCandidates.length === 0) { + laterRealFailureTargetCandidates.push(failedHttpTarget); + } + + continue; + } + + if (failureKind !== "transport") { + continue; + } + + const failedTransportTarget = + getFailedTransportBreadcrumbTarget(breadcrumb); + if (failedTransportTarget.isFirstParty === false) { + continue; + } + + if ( + messageTargetCandidates.length > 0 && + !canUseFailedTransportForMessageTarget( + failedTransportTarget, + messageTargetCandidates + ) + ) { + continue; + } + + if ( + messageTargetCandidates.length === 0 && + !isFirstPartyApiTarget(failedTransportTarget) + ) { + return null; + } + + if ( + messageTargetCandidates.length === 0 && + laterRealFailureTargetCandidates.some((target) => + isSameFirstPartyApiTarget(target, failedTransportTarget) + ) + ) { + return null; + } + + return failedTransportTarget; + } + + return null; +} + +export function getLowValueNetworkErrorTargetUrl( + event: SentryClientEvent +): string | null { + const latestBreadcrumb = getLatestFailedTransportBreadcrumb(event); + if (!latestBreadcrumb) { + return null; + } + + const messageTargetCandidates = getMessageTargetCandidates(event); + if (isFilteredUrl(latestBreadcrumb.url)) { + return ( + messageTargetCandidates.find(isFilteredBreadcrumbFallbackApiTarget) + ?.url ?? null + ); + } + + if (messageTargetCandidates.length === 0) { + return isFirstPartyApiTarget(latestBreadcrumb) + ? latestBreadcrumb.url + : null; + } + + return ( + messageTargetCandidates.find((target) => + isSameFirstPartyApiTarget(target, latestBreadcrumb) + )?.url ?? null + ); +} + +function getUsableBreadcrumbMessageUrl( + breadcrumb: SentryBreadcrumb +): string | null { + const url = getBreadcrumbUrl(breadcrumb)?.trim(); + if (!url || isFilteredUrl(url)) { + return null; + } + + return url; +} + +function getLatestUsableBreadcrumbMessageUrl( + event: SentryClientEvent, + predicate: (breadcrumb: SentryBreadcrumb) => boolean +): string | null { + const breadcrumbs = getHttpBreadcrumbs(event); + + for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { + const breadcrumb = breadcrumbs[index]; + if (!breadcrumb || !predicate(breadcrumb)) { + continue; + } + + const url = getUsableBreadcrumbMessageUrl(breadcrumb); + if (url) { + return url; + } + } + + return null; +} + +export function getNetworkErrorMessageTargetUrl( + event: SentryClientEvent +): string | null { + const lowValueTargetUrl = getLowValueNetworkErrorTargetUrl(event)?.trim(); + if (lowValueTargetUrl && !isFilteredUrl(lowValueTargetUrl)) { + return lowValueTargetUrl; + } + + return ( + getLatestUsableBreadcrumbMessageUrl( + event, + (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) === "transport" + ) ?? + getLatestUsableBreadcrumbMessageUrl( + event, + (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) === "http" + ) + ); +} + +function getMessageTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + return getUrlCandidatesFromText(getEventMessage(event)).map((url) => ({ + url, + })); +} + +function getBreadcrumbTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + return getHttpBreadcrumbs(event) + .map(getBreadcrumbTargetCandidate) + .filter((value): value is NetworkTargetCandidate => value !== null); +} + +function getNetworkTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + return [ + ...getMessageTargetCandidates(event), + ...getBreadcrumbTargetCandidates(event), + ]; +} + +function getNetworkTargetUrlCandidates(event: SentryClientEvent): string[] { + return uniqueStrings( + getNetworkTargetCandidates(event).map((candidate) => candidate.url) + ); +} + +function hasMatchingFailedTransportBreadcrumb( + event: SentryClientEvent +): boolean { + return getLowValueNetworkErrorTargetUrl(event) !== null; +} + +function isLowValueFirstPartyNetworkError(event: SentryClientEvent): boolean { + if (event.tags?.["errorType"] !== "network") { + return false; + } + + const message = getEventMessage(event); + if (!isNetworkErrorMessage(message)) { + return false; + } + + return hasMatchingFailedTransportBreadcrumb(event); +} + +function normalizeSampleRate(sampleRate: number): number { + if (!Number.isFinite(sampleRate) || sampleRate <= 0) { + return 0; + } + + return sampleRate >= 1 ? 1 : sampleRate; +} + +function stableHashToUnitInterval(value: string): number { + let hash = FNV_OFFSET_BASIS; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, FNV_PRIME); + } + + return (hash >>> 0) / UINT_32_SIZE; +} + +function getLowValueNetworkSamplingKey(event: SentryClientEvent): string { + const eventId = getStringValue(event.event_id); + if (eventId) { + return eventId; + } + + return `${getEventMessage(event)}|${getNetworkTargetUrlCandidates(event).join( + "|" + )}`; +} + +export function getLowValueNetworkErrorDecision( + event: SentryClientEvent, + sampleRate: number = LOW_VALUE_NETWORK_ERROR_SAMPLE_RATE +): LowValueNetworkErrorDecision { + if (!isLowValueFirstPartyNetworkError(event)) { + return "not_applicable"; + } + + const normalizedSampleRate = normalizeSampleRate(sampleRate); + if (normalizedSampleRate <= 0) { + return "drop"; + } + + if (normalizedSampleRate >= 1) { + return "keep_sampled"; + } + + return stableHashToUnitInterval(getLowValueNetworkSamplingKey(event)) < + normalizedSampleRate + ? "keep_sampled" + : "drop"; +} + +export function tagSampledLowValueNetworkError(event: SentryClientEvent): void { + event.tags = { + ...event.tags, + network_failure_kind: "browser_transport", + network_noise_sampled: "true", + }; +} + function shouldFilterFilenameExceptions( frames: SentryStackFrame[] | undefined ): boolean { @@ -97,7 +699,8 @@ function shouldFilterFilenameExceptions( return frames.some((frame) => filenameExceptions.some( (pattern) => - frame.filename?.includes(pattern) || frame.abs_path?.includes(pattern) + (frame.filename?.includes(pattern) ?? false) || + (frame.abs_path?.includes(pattern) ?? false) ) ); } diff --git a/utils/sentry-sanitizer.ts b/utils/sentry-sanitizer.ts index 1f420a6cbf..315633a056 100644 --- a/utils/sentry-sanitizer.ts +++ b/utils/sentry-sanitizer.ts @@ -1,6 +1,8 @@ import type { Breadcrumb, Event } from "@sentry/nextjs"; const REDACTED = "[Filtered]"; +const URL_IS_FIRST_PARTY_KEY = "url.is_first_party"; +const URL_IS_FIRST_PARTY_API_KEY = "url.is_first_party_api"; const JWT_PATTERN = /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+/g; const STRIPE_KEY_PATTERN = /\b(sk|pk)_[a-zA-Z0-9]{16,}\b/g; @@ -13,6 +15,67 @@ const SENSITIVE_KEY_FRAGMENT_PATTERN = const SENSITIVE_HEADER_NAME_PATTERN = /^(authorization|cookie|set-cookie|x-api-key|x-auth-token|x-csrf-token|x-xsrf-token|proxy-authorization|x-forwarded-for|x-real-ip|cf-connecting-ip)$/i; +function isFirstPartyHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === "6529.io" || normalized.endsWith(".6529.io"); +} + +function isAbsoluteUrlLike(value: string): boolean { + return /^[a-z][a-z\d+\-.]*:/i.test(value) || value.startsWith("//"); +} + +function getBreadcrumbUrlIsFirstParty(value: unknown): boolean | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if (!isAbsoluteUrlLike(trimmed)) { + return true; + } + + try { + const parsed = new URL(trimmed, "https://6529.io"); + return isFirstPartyHost(parsed.hostname); + } catch { + return undefined; + } +} + +function getBreadcrumbUrlIsFirstPartyApi( + value: unknown, + urlIsFirstParty: unknown +): boolean | undefined { + if (urlIsFirstParty === false) { + return false; + } + + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + try { + const parsed = new URL(trimmed, "https://6529.io"); + const hostname = parsed.hostname.toLowerCase(); + if (hostname === "api.6529.io") { + return true; + } + + return isFirstPartyHost(hostname) && parsed.pathname.startsWith("/api/"); + } catch { + return undefined; + } +} + function sanitizeString(value: string): string { if (!value) return value; let sanitized = value; @@ -90,7 +153,6 @@ function sanitizeUnknown( function sanitizeHeaders( headers: unknown ): Record | undefined { - if (!headers) return undefined; if (!isPlainObject(headers)) return undefined; const result: Record = {}; @@ -132,6 +194,43 @@ export function sanitizeSentryBreadcrumb( } if (crumb.data) { + if (isPlainObject(crumb.data)) { + const nextData = { ...crumb.data }; + let didAddUrlMetadata = false; + + if ( + !Object.prototype.hasOwnProperty.call(nextData, URL_IS_FIRST_PARTY_KEY) + ) { + const urlIsFirstParty = getBreadcrumbUrlIsFirstParty(nextData["url"]); + if (typeof urlIsFirstParty === "boolean") { + nextData[URL_IS_FIRST_PARTY_KEY] = urlIsFirstParty; + didAddUrlMetadata = true; + } + } + + if ( + !Object.prototype.hasOwnProperty.call( + nextData, + URL_IS_FIRST_PARTY_API_KEY + ) + ) { + const urlIsFirstPartyApi = getBreadcrumbUrlIsFirstPartyApi( + nextData["url"], + nextData[URL_IS_FIRST_PARTY_KEY] + ); + if (typeof urlIsFirstPartyApi === "boolean") { + nextData[URL_IS_FIRST_PARTY_API_KEY] = urlIsFirstPartyApi; + didAddUrlMetadata = true; + } + } + + if (didAddUrlMetadata) { + crumb.data = { + ...nextData, + }; + } + } + const seen = new WeakSet(); crumb.data = sanitizeUnknown(crumb.data, 0, seen) as NonNullable< Breadcrumb["data"]