From 1574f387abf9b16ee605aeff919c6bcbd0cc4689 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 30 Apr 2026 15:13:42 +0300 Subject: [PATCH 01/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 128 ++++++++- __tests__/utils/sentry-client-filters.test.ts | 188 ++++++++++++ instrumentation-client.ts | 10 + utils/sentry-client-filters.ts | 268 +++++++++++++++++- 4 files changed, 591 insertions(+), 3 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 5487a5b221..763ca58d8a 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -9,14 +9,30 @@ jest.mock("@sentry/nextjs", () => ({ captureRouterTransitionStart: mockCaptureRouterTransitionStart, })); -describe("instrumentation-client beforeSendTransaction", () => { - const loadBeforeSendTransaction = () => { +describe("instrumentation-client", () => { + 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 + ) => { tags?: Record | undefined } | null; + }; + + const loadBeforeSendTransaction = () => { + const config = loadSentryConfig(); expect(typeof config.beforeSendTransaction).toBe("function"); return config.beforeSendTransaction as (event: Record) => { @@ -37,6 +53,114 @@ 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", + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new TypeError("Load failed"), + }); + + expect(result).toBeNull(); + }); + + 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", + }, + }, + ], + }; + + 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", + }) + ); + }); + + 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", + }, + }, + ], + }; + + 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(); + }); + 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..1fcf6c84c1 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -1,9 +1,11 @@ import { __testing, + getLowValueNetworkErrorDecision, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, shouldFilterTwitterConfigReferenceError, + tagSampledLowValueNetworkError, } from "@/utils/sentry-client-filters"; describe("sentry-client-filters", () => { @@ -85,6 +87,37 @@ describe("sentry-client-filters", () => { ...overrides, }) as any; + const createLowValueNetworkEvent = ( + overrides: Record = {} + ) => + ({ + event_id: "network-drop-event", + exception: { + values: [ + { + type: "TypeError", + value: + "Network request failed. Please check your connection and try again. (/api/waves-overview)", + }, + ], + }, + tags: { + errorType: "network", + handled: true, + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + }, + }, + ], + ...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 +312,161 @@ 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("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", + }, + }, + ], + }), + 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("keeps network errors when no failed status is known", () => { + const result = getLowValueNetworkErrorDecision( + createLowValueNetworkEvent({ + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + url: "/api/waves-overview", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + + 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", + }, + }, + ], + }, + }), + 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("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", + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + it("filters Twitter CONFIG reference errors with app URI frames", () => { // Arrange const event = createTwitterConfigEvent(); diff --git a/instrumentation-client.ts b/instrumentation-client.ts index a4122ba6ff..fd5762f68f 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,11 +13,13 @@ import { sanitizeUrlString, } from "@/utils/sentry-sanitizer"; import { + getLowValueNetworkErrorDecision, getThirdPartyTelemetrySpanTargetKey, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, shouldFilterTwitterConfigReferenceError, + tagSampledLowValueNetworkError, type SentryTransactionSpan, } from "@/utils/sentry-client-filters"; import * as Sentry from "@sentry/nextjs"; @@ -272,6 +274,14 @@ Sentry.init({ 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..182a0c0263 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -12,6 +12,7 @@ export type SentryTransactionSpan = { type SentryContext = Record; type SentryBreadcrumb = { + type?: string | undefined; category?: string | undefined; message?: string | undefined; data?: Record | undefined; @@ -30,6 +31,8 @@ type SentryExceptionValue = { type SentryTags = Record; export type SentryClientEvent = { + event_id?: string | undefined; + message?: string | undefined; exception?: | { values?: SentryExceptionValue[] | undefined; @@ -50,6 +53,11 @@ export type SentryEventHint = { syntheticException?: unknown; }; +export type LowValueNetworkErrorDecision = + | "not_applicable" + | "drop" + | "keep_sampled"; + const filenameExceptions = [ "inpage.js", "extensionServiceWorker.js", @@ -70,6 +78,12 @@ 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 FNV_OFFSET_BASIS = 2166136261; +const FNV_PRIME = 16777619; +const UINT_32_SIZE = 4294967296; function getStringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; @@ -88,6 +102,257 @@ 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 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(); + return ( + normalized === "[filtered]" || + normalized === "[redacted]" || + normalized === "filtered" + ); +} + +function parseRequestUrl(value: string | undefined): URL | null { + if (!value || isFilteredUrl(value)) { + return null; + } + + try { + return new URL(value, "https://6529.io"); + } catch { + return null; + } +} + +function isFirstPartyApiTarget(value: string): boolean { + const url = parseRequestUrl(value); + if (!url) { + return false; + } + + const hostname = url.hostname.toLowerCase(); + if (hostname === "api.6529.io") { + return true; + } + + return isFirstPartyHost(hostname) && url.pathname.startsWith("/api/"); +} + +function isSameFirstPartyApiTarget(left: string, right: string): boolean { + const leftUrl = parseRequestUrl(left); + const rightUrl = parseRequestUrl(right); + + if (!leftUrl || !rightUrl) { + return false; + } + + return ( + isFirstPartyApiTarget(left) && + isFirstPartyApiTarget(right) && + leftUrl.pathname === rightUrl.pathname + ); +} + +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"]) + ); +} + +function getBreadcrumbUrl(breadcrumb: SentryBreadcrumb): string | undefined { + return getStringValue(breadcrumb.data?.["url"]); +} + +function getLatestHttpBreadcrumbWithStatus( + event: SentryClientEvent +): { url?: string | undefined; statusCode: number } | null { + const breadcrumbs = getHttpBreadcrumbs(event); + for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { + const breadcrumb = breadcrumbs[index]; + if (!breadcrumb) { + continue; + } + + const statusCode = getBreadcrumbStatusCode(breadcrumb); + if (statusCode === null) { + continue; + } + + return { + url: getBreadcrumbUrl(breadcrumb), + statusCode, + }; + } + + return null; +} + +function getNetworkTargetUrlCandidates(event: SentryClientEvent): string[] { + const message = getEventMessage(event); + const messageUrls = getUrlCandidatesFromText(message); + const breadcrumbUrls = getHttpBreadcrumbs(event) + .map(getBreadcrumbUrl) + .filter((value): value is string => !!value && !isFilteredUrl(value)); + + return uniqueStrings([...messageUrls, ...breadcrumbUrls]); +} + +function getPrimaryNetworkTargetUrlCandidates( + event: SentryClientEvent +): string[] { + const messageUrls = getUrlCandidatesFromText(getEventMessage(event)); + return messageUrls.length > 0 + ? uniqueStrings(messageUrls) + : getNetworkTargetUrlCandidates(event); +} + +function hasFirstPartyApiTarget(event: SentryClientEvent): boolean { + return getPrimaryNetworkTargetUrlCandidates(event).some( + isFirstPartyApiTarget + ); +} + +function hasMatchingFailedTransportBreadcrumb( + event: SentryClientEvent +): boolean { + const latestBreadcrumb = getLatestHttpBreadcrumbWithStatus(event); + if (latestBreadcrumb?.statusCode !== 0) { + return false; + } + + if (isFilteredUrl(latestBreadcrumb.url)) { + return true; + } + + return getPrimaryNetworkTargetUrlCandidates(event).some((targetUrl) => + isSameFirstPartyApiTarget(targetUrl, latestBreadcrumb.url as string) + ); +} + +function isLowValueFirstPartyNetworkError(event: SentryClientEvent): boolean { + if (event.tags?.["errorType"] !== "network") { + return false; + } + + const message = getEventMessage(event); + if (!isNetworkErrorMessage(message)) { + return false; + } + + return ( + hasFirstPartyApiTarget(event) && 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 +362,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) ) ); } From a77d74b499654fbc8b235b3c6cd860305a780f24 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 30 Apr 2026 15:30:41 +0300 Subject: [PATCH 02/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 120 +++++++++++++++++- __tests__/utils/sentry-client-filters.test.ts | 22 +++- instrumentation-client.ts | 52 ++++++-- 3 files changed, 182 insertions(+), 12 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 763ca58d8a..d7ea2f0065 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -10,6 +10,19 @@ jest.mock("@sentry/nextjs", () => ({ })); describe("instrumentation-client", () => { + const wrappedNetworkMessage = + "Network request failed. Please check your connection and try again. (/api/waves-overview)"; + + type BeforeSendResult = { + tags?: Record | undefined; + exception?: + | { + values?: Array<{ value?: string | undefined } | undefined>; + } + | undefined; + message?: string | undefined; + } | null; + const loadSentryConfig = () => { jest.isolateModules(() => { require("@/instrumentation-client"); @@ -28,7 +41,7 @@ describe("instrumentation-client", () => { return config.beforeSend as ( event: Record, hint?: Record - ) => { tags?: Record | undefined } | null; + ) => BeforeSendResult; }; const loadBeforeSendTransaction = () => { @@ -123,6 +136,111 @@ describe("instrumentation-client", () => { ); }); + 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", + }, + }, + ], + }; + + 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, + exception: { + values: [ + { + type: "Error", + value: wrappedNetworkMessage, + }, + ], + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 0, + url: "/api/waves-overview", + }, + }, + ], + }; + + 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); + }); + + 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", + }, + }, + ], + }; + + const result = beforeSend(event, { + originalException: new Error("network switch failed"), + }); + + expect(result).not.toBeNull(); + expect(result?.tags?.["errorType"]).toBeUndefined(); + }); + it("keeps browser network errors with a real HTTP status", () => { const beforeSend = loadBeforeSend(); const event = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 1fcf6c84c1..101d61e6f4 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -9,6 +9,9 @@ import { } 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", @@ -96,8 +99,7 @@ describe("sentry-client-filters", () => { values: [ { type: "TypeError", - value: - "Network request failed. Please check your connection and try again. (/api/waves-overview)", + value: wrappedNetworkMessage, }, ], }, @@ -368,6 +370,22 @@ describe("sentry-client-filters", () => { 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({ diff --git a/instrumentation-client.ts b/instrumentation-client.ts index fd5762f68f..38a2743de3 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -39,6 +39,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; @@ -164,7 +168,7 @@ 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])); @@ -194,20 +198,50 @@ function isNetworkError(errorMessage: string): boolean { ); } +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 isRawBrowserNetworkError = + error instanceof TypeError && isNetworkError(error.message); + + if (!isAppWrappedError && !isRawBrowserNetworkError) { 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; @@ -270,7 +304,7 @@ Sentry.init({ handleIndexedDBError(event); } - if (error instanceof TypeError) { + if (error instanceof Error) { handleNetworkError(event, error, value); } From 5e4e9029ffc88a116274082506b6da4a13724fc0 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 30 Apr 2026 15:50:31 +0300 Subject: [PATCH 03/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 29 +++++++++++++++ utils/sentry-client-filters.ts | 37 ++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 101d61e6f4..331a6cc8dc 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -456,6 +456,35 @@ describe("sentry-client-filters", () => { 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("keeps first-party page navigation failures", () => { const result = getLowValueNetworkErrorDecision( createLowValueNetworkEvent({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 182a0c0263..bf0401d394 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -162,12 +162,7 @@ function parseRequestUrl(value: string | undefined): URL | null { } } -function isFirstPartyApiTarget(value: string): boolean { - const url = parseRequestUrl(value); - if (!url) { - return false; - } - +function isFirstPartyApiUrl(url: URL): boolean { const hostname = url.hostname.toLowerCase(); if (hostname === "api.6529.io") { return true; @@ -176,6 +171,20 @@ function isFirstPartyApiTarget(value: string): boolean { return isFirstPartyHost(hostname) && url.pathname.startsWith("/api/"); } +function isFirstPartyApiTarget(value: string): boolean { + const url = parseRequestUrl(value); + return !!url && isFirstPartyApiUrl(url); +} + +function isSanitizedRelativePath(value: string, parsedUrl: URL): boolean { + const normalized = value.trim(); + return ( + normalized.startsWith("/") && + !normalized.startsWith("//") && + normalized === parsedUrl.pathname + ); +} + function isSameFirstPartyApiTarget(left: string, right: string): boolean { const leftUrl = parseRequestUrl(left); const rightUrl = parseRequestUrl(right); @@ -184,10 +193,20 @@ function isSameFirstPartyApiTarget(left: string, right: string): boolean { return false; } + if (leftUrl.pathname !== rightUrl.pathname) { + return false; + } + + const leftIsFirstPartyApi = isFirstPartyApiUrl(leftUrl); + const rightIsFirstPartyApi = isFirstPartyApiUrl(rightUrl); + + if (leftIsFirstPartyApi && rightIsFirstPartyApi) { + return true; + } + return ( - isFirstPartyApiTarget(left) && - isFirstPartyApiTarget(right) && - leftUrl.pathname === rightUrl.pathname + (leftIsFirstPartyApi && isSanitizedRelativePath(right, rightUrl)) || + (rightIsFirstPartyApi && isSanitizedRelativePath(left, leftUrl)) ); } From 5816b48504f8136378943a57f690b0bed65e2347 Mon Sep 17 00:00:00 2001 From: Simo Date: Thu, 30 Apr 2026 16:34:20 +0300 Subject: [PATCH 04/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 50 +++++ __tests__/utils/sentry-client-filters.test.ts | 65 ++++++ __tests__/utils/sentry-sanitizer.test.ts | 72 +++++++ instrumentation-client.ts | 75 ++++++- utils/sentry-client-filters.ts | 185 +++++++++++++----- utils/sentry-sanitizer.ts | 46 ++++- 6 files changed, 443 insertions(+), 50 deletions(-) create mode 100644 __tests__/utils/sentry-sanitizer.test.ts diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index d7ea2f0065..5399f1f037 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -85,6 +85,7 @@ describe("instrumentation-client", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -97,6 +98,50 @@ describe("instrumentation-client", () => { expect(result).toBeNull(); }); + 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 = { @@ -116,6 +161,7 @@ describe("instrumentation-client", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -155,6 +201,7 @@ describe("instrumentation-client", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -187,6 +234,7 @@ describe("instrumentation-client", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -228,6 +276,7 @@ describe("instrumentation-client", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -260,6 +309,7 @@ describe("instrumentation-client", () => { data: { status_code: 500, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 331a6cc8dc..2613f71825 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -114,6 +114,7 @@ describe("sentry-client-filters", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -323,6 +324,66 @@ describe("sentry-client-filters", () => { expect(result).toBe("drop"); }); + 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 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); @@ -349,6 +410,7 @@ describe("sentry-client-filters", () => { data: { status_code: 500, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -395,6 +457,7 @@ describe("sentry-client-filters", () => { category: "fetch", data: { url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -416,6 +479,7 @@ describe("sentry-client-filters", () => { data: { status_code: 0, url: "/api/waves-overview", + "url.is_first_party": true, }, }, ], @@ -504,6 +568,7 @@ describe("sentry-client-filters", () => { data: { status_code: 0, url: "/notifications", + "url.is_first_party": true, }, }, ], diff --git a/__tests__/utils/sentry-sanitizer.test.ts b/__tests__/utils/sentry-sanitizer.test.ts new file mode 100644 index 0000000000..1daba4f7a9 --- /dev/null +++ b/__tests__/utils/sentry-sanitizer.test.ts @@ -0,0 +1,72 @@ +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, + }) + ); + }); + + 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, + }) + ); + }); + + 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, + }) + ); + }); +}); diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 38a2743de3..942d1cfcbb 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -23,6 +23,7 @@ import { type SentryTransactionSpan, } from "@/utils/sentry-client-filters"; import * as Sentry from "@sentry/nextjs"; +import type { Breadcrumb } from "@sentry/nextjs"; const sentryEnabled = !!publicEnv.SENTRY_DSN; const isProduction = publicEnv.NODE_ENV === "production"; @@ -106,6 +107,72 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function getNumericValue(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function isHttpBreadcrumb(breadcrumb: Breadcrumb): boolean { + return ( + breadcrumb.type === "http" || + breadcrumb.category === "fetch" || + breadcrumb.category === "xhr" + ); +} + +function getBreadcrumbStatusCode(breadcrumb: Breadcrumb): number | null { + const data = breadcrumb.data; + return ( + getNumericValue(data?.["status_code"]) ?? + getNumericValue(data?.["http.response.status_code"]) + ); +} + +function getLatestHttpBreadcrumbUrl( + event: Sentry.Event, + statusCode?: number +): unknown { + const breadcrumbs = event.breadcrumbs; + if (!Array.isArray(breadcrumbs)) { + return undefined; + } + + for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { + const breadcrumb = breadcrumbs[index]; + if (!breadcrumb || !isHttpBreadcrumb(breadcrumb)) { + continue; + } + + if ( + statusCode !== undefined && + getBreadcrumbStatusCode(breadcrumb) !== statusCode + ) { + continue; + } + + const url = breadcrumb.data?.["url"]; + if (url) { + return url; + } + } + + return undefined; +} + +function getLatestNetworkBreadcrumbUrl(event: Sentry.Event): unknown { + return ( + getLatestHttpBreadcrumbUrl(event, 0) ?? getLatestHttpBreadcrumbUrl(event) + ); +} + function filterNoisyThirdPartyTransactionSpans( event: Sentry.Event ): Sentry.Event { @@ -174,11 +241,9 @@ function extractUrlFromError(error: Error, event: Sentry.Event): string { 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 breadcrumbUrl = getLatestNetworkBreadcrumbUrl(event); + if (breadcrumbUrl) { + return String(sanitizeUrlString(breadcrumbUrl)); } if (event.request?.url) { return String(sanitizeUrlString(event.request.url)); diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index bf0401d394..a4ae6742c9 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -18,6 +18,11 @@ type SentryBreadcrumb = { data?: Record | undefined; }; +type NetworkTargetCandidate = { + url: string; + isFirstParty?: boolean | undefined; +}; + type SentryExceptionValue = { type?: string | undefined; value?: string | undefined; @@ -81,6 +86,7 @@ const noisyThirdPartyTelemetryTargets = new Set([ 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 FNV_OFFSET_BASIS = 2166136261; const FNV_PRIME = 16777619; const UINT_32_SIZE = 4294967296; @@ -89,6 +95,10 @@ 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; @@ -150,18 +160,47 @@ function isFilteredUrl(value: string | undefined): boolean { ); } -function parseRequestUrl(value: string | undefined): URL | null { +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 { - return new URL(value, "https://6529.io"); + 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") { @@ -171,42 +210,59 @@ function isFirstPartyApiUrl(url: URL): boolean { return isFirstPartyHost(hostname) && url.pathname.startsWith("/api/"); } -function isFirstPartyApiTarget(value: string): boolean { - const url = parseRequestUrl(value); - return !!url && isFirstPartyApiUrl(url); -} +function isFirstPartyApiTarget(candidate: NetworkTargetCandidate): boolean { + const url = parseAbsoluteRequestUrl(candidate.url); + if (url) { + return isFirstPartyApiUrl(url); + } -function isSanitizedRelativePath(value: string, parsedUrl: URL): boolean { - const normalized = value.trim(); + const pathname = getRequestPathname(candidate.url); return ( - normalized.startsWith("/") && - !normalized.startsWith("//") && - normalized === parsedUrl.pathname + candidate.isFirstParty === true && + !!pathname && + pathname.startsWith("/api/") ); } -function isSameFirstPartyApiTarget(left: string, right: string): boolean { - const leftUrl = parseRequestUrl(left); - const rightUrl = parseRequestUrl(right); +function canUseAsSanitizedRelativePath( + candidate: NetworkTargetCandidate +): boolean { + return isRelativePath(candidate.url) && candidate.isFirstParty !== false; +} + +function hasExplicitThirdPartyRelativeOrigin( + candidate: NetworkTargetCandidate +): boolean { + return isRelativePath(candidate.url) && candidate.isFirstParty === false; +} - if (!leftUrl || !rightUrl) { +function isSameFirstPartyApiTarget( + left: NetworkTargetCandidate, + right: NetworkTargetCandidate +): boolean { + if ( + hasExplicitThirdPartyRelativeOrigin(left) || + hasExplicitThirdPartyRelativeOrigin(right) + ) { return false; } - if (leftUrl.pathname !== rightUrl.pathname) { + const leftPathname = getRequestPathname(left.url); + const rightPathname = getRequestPathname(right.url); + if (!leftPathname || !rightPathname || leftPathname !== rightPathname) { return false; } - const leftIsFirstPartyApi = isFirstPartyApiUrl(leftUrl); - const rightIsFirstPartyApi = isFirstPartyApiUrl(rightUrl); + const leftIsFirstPartyApi = isFirstPartyApiTarget(left); + const rightIsFirstPartyApi = isFirstPartyApiTarget(right); if (leftIsFirstPartyApi && rightIsFirstPartyApi) { return true; } return ( - (leftIsFirstPartyApi && isSanitizedRelativePath(right, rightUrl)) || - (rightIsFirstPartyApi && isSanitizedRelativePath(left, leftUrl)) + (leftIsFirstPartyApi && canUseAsSanitizedRelativePath(right)) || + (rightIsFirstPartyApi && canUseAsSanitizedRelativePath(left)) ); } @@ -231,9 +287,29 @@ 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 getBreadcrumbTargetCandidate( + breadcrumb: SentryBreadcrumb +): NetworkTargetCandidate | null { + const url = getBreadcrumbUrl(breadcrumb); + if (!url || isFilteredUrl(url)) { + return null; + } + + return { + url, + isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + }; +} + function getLatestHttpBreadcrumbWithStatus( event: SentryClientEvent -): { url?: string | undefined; statusCode: number } | null { +): (NetworkTargetCandidate & { statusCode: number }) | null { const breadcrumbs = getHttpBreadcrumbs(event); for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { const breadcrumb = breadcrumbs[index]; @@ -247,7 +323,8 @@ function getLatestHttpBreadcrumbWithStatus( } return { - url: getBreadcrumbUrl(breadcrumb), + url: getBreadcrumbUrl(breadcrumb) ?? "", + isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), statusCode, }; } @@ -255,31 +332,50 @@ function getLatestHttpBreadcrumbWithStatus( return null; } -function getNetworkTargetUrlCandidates(event: SentryClientEvent): string[] { - const message = getEventMessage(event); - const messageUrls = getUrlCandidatesFromText(message); - const breadcrumbUrls = getHttpBreadcrumbs(event) - .map(getBreadcrumbUrl) - .filter((value): value is string => !!value && !isFilteredUrl(value)); +function getMessageTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + return getUrlCandidatesFromText(getEventMessage(event)).map((url) => ({ + url, + })); +} - return uniqueStrings([...messageUrls, ...breadcrumbUrls]); +function getBreadcrumbTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + return getHttpBreadcrumbs(event) + .map(getBreadcrumbTargetCandidate) + .filter((value): value is NetworkTargetCandidate => value !== null); } -function getPrimaryNetworkTargetUrlCandidates( +function getNetworkTargetCandidates( event: SentryClientEvent -): string[] { - const messageUrls = getUrlCandidatesFromText(getEventMessage(event)); - return messageUrls.length > 0 - ? uniqueStrings(messageUrls) - : getNetworkTargetUrlCandidates(event); +): NetworkTargetCandidate[] { + return [ + ...getMessageTargetCandidates(event), + ...getBreadcrumbTargetCandidates(event), + ]; } -function hasFirstPartyApiTarget(event: SentryClientEvent): boolean { - return getPrimaryNetworkTargetUrlCandidates(event).some( - isFirstPartyApiTarget +function getNetworkTargetUrlCandidates(event: SentryClientEvent): string[] { + return uniqueStrings( + getNetworkTargetCandidates(event).map((candidate) => candidate.url) ); } +function getPrimaryNetworkTargetCandidates( + event: SentryClientEvent +): NetworkTargetCandidate[] { + const messageCandidates = getMessageTargetCandidates(event); + return messageCandidates.length > 0 + ? messageCandidates + : getNetworkTargetCandidates(event); +} + +function hasFirstPartyApiTarget(event: SentryClientEvent): boolean { + return getPrimaryNetworkTargetCandidates(event).some(isFirstPartyApiTarget); +} + function hasMatchingFailedTransportBreadcrumb( event: SentryClientEvent ): boolean { @@ -289,11 +385,14 @@ function hasMatchingFailedTransportBreadcrumb( } if (isFilteredUrl(latestBreadcrumb.url)) { - return true; + return hasFirstPartyApiTarget(event); } - return getPrimaryNetworkTargetUrlCandidates(event).some((targetUrl) => - isSameFirstPartyApiTarget(targetUrl, latestBreadcrumb.url as string) + return getPrimaryNetworkTargetCandidates(event).some((target) => + isSameFirstPartyApiTarget( + target, + latestBreadcrumb as NetworkTargetCandidate + ) ); } @@ -307,9 +406,7 @@ function isLowValueFirstPartyNetworkError(event: SentryClientEvent): boolean { return false; } - return ( - hasFirstPartyApiTarget(event) && hasMatchingFailedTransportBreadcrumb(event) - ); + return hasMatchingFailedTransportBreadcrumb(event); } function normalizeSampleRate(sampleRate: number): number { diff --git a/utils/sentry-sanitizer.ts b/utils/sentry-sanitizer.ts index 1f420a6cbf..b91514c430 100644 --- a/utils/sentry-sanitizer.ts +++ b/utils/sentry-sanitizer.ts @@ -1,6 +1,7 @@ import type { Breadcrumb, Event } from "@sentry/nextjs"; const REDACTED = "[Filtered]"; +const URL_IS_FIRST_PARTY_KEY = "url.is_first_party"; 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 +14,37 @@ 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 sanitizeString(value: string): string { if (!value) return value; let sanitized = value; @@ -90,7 +122,6 @@ function sanitizeUnknown( function sanitizeHeaders( headers: unknown ): Record | undefined { - if (!headers) return undefined; if (!isPlainObject(headers)) return undefined; const result: Record = {}; @@ -132,6 +163,19 @@ export function sanitizeSentryBreadcrumb( } if (crumb.data) { + if ( + isPlainObject(crumb.data) && + !Object.prototype.hasOwnProperty.call(crumb.data, URL_IS_FIRST_PARTY_KEY) + ) { + const urlIsFirstParty = getBreadcrumbUrlIsFirstParty(crumb.data["url"]); + if (typeof urlIsFirstParty === "boolean") { + crumb.data = { + ...crumb.data, + [URL_IS_FIRST_PARTY_KEY]: urlIsFirstParty, + }; + } + } + const seen = new WeakSet(); crumb.data = sanitizeUnknown(crumb.data, 0, seen) as NonNullable< Breadcrumb["data"] From 4df1ea1d5394c1acb2ccbe7fff91c532a283339a Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 1 May 2026 07:07:18 +0300 Subject: [PATCH 05/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 80 +++++++++++++++++++ utils/sentry-client-filters.ts | 35 ++++++-- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 2613f71825..205beb99aa 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -354,6 +354,86 @@ describe("sentry-client-filters", () => { 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 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({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index a4ae6742c9..56cd78815a 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -266,6 +266,29 @@ function isSameFirstPartyApiTarget( ); } +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) => @@ -372,10 +395,6 @@ function getPrimaryNetworkTargetCandidates( : getNetworkTargetCandidates(event); } -function hasFirstPartyApiTarget(event: SentryClientEvent): boolean { - return getPrimaryNetworkTargetCandidates(event).some(isFirstPartyApiTarget); -} - function hasMatchingFailedTransportBreadcrumb( event: SentryClientEvent ): boolean { @@ -385,7 +404,13 @@ function hasMatchingFailedTransportBreadcrumb( } if (isFilteredUrl(latestBreadcrumb.url)) { - return hasFirstPartyApiTarget(event); + if (latestBreadcrumb.isFirstParty === false) { + return false; + } + + return getMessageTargetCandidates(event).some( + isFilteredBreadcrumbFallbackApiTarget + ); } return getPrimaryNetworkTargetCandidates(event).some((target) => From efc548d68a7c4c5a95f9855e755e5149ee21af8a Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 1 May 2026 07:23:20 +0300 Subject: [PATCH 06/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 22 ++++++++++++++++++ utils/sentry-client-filters.ts | 23 +++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 205beb99aa..024b1aef71 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -374,6 +374,28 @@ describe("sentry-client-filters", () => { 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({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 56cd78815a..04d4bcfd10 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -90,6 +90,7 @@ const URL_IS_FIRST_PARTY_KEY = "url.is_first_party"; 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; @@ -153,11 +154,23 @@ function isFilteredUrl(value: string | undefined): boolean { } const normalized = value.trim().toLowerCase(); - return ( - normalized === "[filtered]" || - normalized === "[redacted]" || - normalized === "filtered" - ); + 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 { From c2fa4b8f85e7753182731bc27450785d157e6f50 Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 1 May 2026 08:29:36 +0300 Subject: [PATCH 07/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 4 ++ __tests__/utils/sentry-client-filters.test.ts | 61 ++++++++++++++++ __tests__/utils/sentry-sanitizer.test.ts | 41 +++++++++++ instrumentation-client.ts | 4 +- utils/sentry-client-filters.ts | 14 ++++ utils/sentry-sanitizer.ts | 71 ++++++++++++++++--- 6 files changed, 186 insertions(+), 9 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 5399f1f037..5bcc3ee95e 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -15,6 +15,7 @@ describe("instrumentation-client", () => { type BeforeSendResult = { tags?: Record | undefined; + fingerprint?: string[] | undefined; exception?: | { values?: Array<{ value?: string | undefined } | undefined>; @@ -180,6 +181,7 @@ describe("instrumentation-client", () => { network_noise_sampled: "true", }) ); + expect(result?.fingerprint).toEqual(["network-error"]); }); it("drops sampled-out app-wrapped first-party browser transport network errors", () => { @@ -219,6 +221,7 @@ describe("instrumentation-client", () => { const event = { event_id: "event-200", message: wrappedNetworkMessage, + fingerprint: ["drop-reaction", "network"], exception: { values: [ { @@ -255,6 +258,7 @@ describe("instrumentation-client", () => { ); 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", () => { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 024b1aef71..d1ab8929cc 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -651,6 +651,67 @@ describe("sentry-client-filters", () => { 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({ diff --git a/__tests__/utils/sentry-sanitizer.test.ts b/__tests__/utils/sentry-sanitizer.test.ts index 1daba4f7a9..8ac2eeeb49 100644 --- a/__tests__/utils/sentry-sanitizer.test.ts +++ b/__tests__/utils/sentry-sanitizer.test.ts @@ -31,6 +31,25 @@ describe("sentry-sanitizer", () => { 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, }) ); }); @@ -48,6 +67,7 @@ describe("sentry-sanitizer", () => { expect.objectContaining({ url: "/api/waves-overview", "url.is_first_party": true, + "url.is_first_party_api": true, }) ); }); @@ -66,6 +86,27 @@ describe("sentry-sanitizer", () => { 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 942d1cfcbb..c7292872d9 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -321,7 +321,9 @@ function handleNetworkError( errorType: "network", handled: true, }; - event.fingerprint = ["network-error"]; + if (!event.fingerprint || event.fingerprint.length === 0) { + event.fingerprint = ["network-error"]; + } } Sentry.init({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 04d4bcfd10..d7216170da 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -21,6 +21,7 @@ type SentryBreadcrumb = { type NetworkTargetCandidate = { url: string; isFirstParty?: boolean | undefined; + isFirstPartyApi?: boolean | undefined; }; type SentryExceptionValue = { @@ -87,6 +88,7 @@ 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; @@ -224,6 +226,10 @@ function isFirstPartyApiUrl(url: URL): boolean { } function isFirstPartyApiTarget(candidate: NetworkTargetCandidate): boolean { + if (candidate.isFirstPartyApi === true) { + return true; + } + const url = parseAbsoluteRequestUrl(candidate.url); if (url) { return isFirstPartyApiUrl(url); @@ -329,6 +335,12 @@ function getBreadcrumbUrlIsFirstParty( 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 { @@ -340,6 +352,7 @@ function getBreadcrumbTargetCandidate( return { url, isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), }; } @@ -361,6 +374,7 @@ function getLatestHttpBreadcrumbWithStatus( return { url: getBreadcrumbUrl(breadcrumb) ?? "", isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), statusCode, }; } diff --git a/utils/sentry-sanitizer.ts b/utils/sentry-sanitizer.ts index b91514c430..315633a056 100644 --- a/utils/sentry-sanitizer.ts +++ b/utils/sentry-sanitizer.ts @@ -2,6 +2,7 @@ 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; @@ -45,6 +46,36 @@ function getBreadcrumbUrlIsFirstParty(value: unknown): boolean | 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; @@ -163,15 +194,39 @@ export function sanitizeSentryBreadcrumb( } if (crumb.data) { - if ( - isPlainObject(crumb.data) && - !Object.prototype.hasOwnProperty.call(crumb.data, URL_IS_FIRST_PARTY_KEY) - ) { - const urlIsFirstParty = getBreadcrumbUrlIsFirstParty(crumb.data["url"]); - if (typeof urlIsFirstParty === "boolean") { + 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 = { - ...crumb.data, - [URL_IS_FIRST_PARTY_KEY]: urlIsFirstParty, + ...nextData, }; } } From 066aa12340720be2935bd1c83f268cf14c1116ce Mon Sep 17 00:00:00 2001 From: Simo Date: Fri, 1 May 2026 08:49:42 +0300 Subject: [PATCH 08/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 21 +++++++++++++++++++ utils/sentry-client-filters.ts | 18 +++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index d1ab8929cc..5ddc526530 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -557,6 +557,7 @@ describe("sentry-client-filters", () => { { type: "http", category: "fetch", + level: "info", data: { url: "/api/waves-overview", "url.is_first_party": true, @@ -570,6 +571,26 @@ describe("sentry-client-filters", () => { 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({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index d7216170da..2a3eb8af24 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -14,6 +14,7 @@ type SentryContext = Record; type SentryBreadcrumb = { type?: string | undefined; category?: string | undefined; + level?: string | undefined; message?: string | undefined; data?: Record | undefined; }; @@ -325,6 +326,21 @@ function getBreadcrumbStatusCode(breadcrumb: SentryBreadcrumb): number | null { ); } +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 getBreadcrumbUrl(breadcrumb: SentryBreadcrumb): string | undefined { return getStringValue(breadcrumb.data?.["url"]); } @@ -366,7 +382,7 @@ function getLatestHttpBreadcrumbWithStatus( continue; } - const statusCode = getBreadcrumbStatusCode(breadcrumb); + const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); if (statusCode === null) { continue; } From 40ad363d7103907597f3c3885e135da5793d1c51 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 09:45:58 +0300 Subject: [PATCH 09/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 30 +++++++++++++++++++ utils/sentry-client-filters.ts | 16 ++++------ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 5ddc526530..d0440a667f 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -324,6 +324,36 @@ describe("sentry-client-filters", () => { expect(result).toBe("drop"); }); + it("drops sampled-out first-party status 0 network errors when a later request succeeds", () => { + 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: 200, + url: "/api/waves-overview", + "url.is_first_party": true, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("drop"); + }); + it("keeps third-party API paths after URL sanitization", () => { const result = getLowValueNetworkErrorDecision( createLowValueNetworkEvent({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 2a3eb8af24..76b2b59617 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -372,9 +372,9 @@ function getBreadcrumbTargetCandidate( }; } -function getLatestHttpBreadcrumbWithStatus( +function getLatestFailedTransportBreadcrumb( event: SentryClientEvent -): (NetworkTargetCandidate & { statusCode: number }) | null { +): NetworkTargetCandidate | null { const breadcrumbs = getHttpBreadcrumbs(event); for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { const breadcrumb = breadcrumbs[index]; @@ -383,7 +383,7 @@ function getLatestHttpBreadcrumbWithStatus( } const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); - if (statusCode === null) { + if (statusCode !== 0) { continue; } @@ -391,7 +391,6 @@ function getLatestHttpBreadcrumbWithStatus( url: getBreadcrumbUrl(breadcrumb) ?? "", isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), - statusCode, }; } @@ -441,8 +440,8 @@ function getPrimaryNetworkTargetCandidates( function hasMatchingFailedTransportBreadcrumb( event: SentryClientEvent ): boolean { - const latestBreadcrumb = getLatestHttpBreadcrumbWithStatus(event); - if (latestBreadcrumb?.statusCode !== 0) { + const latestBreadcrumb = getLatestFailedTransportBreadcrumb(event); + if (!latestBreadcrumb) { return false; } @@ -457,10 +456,7 @@ function hasMatchingFailedTransportBreadcrumb( } return getPrimaryNetworkTargetCandidates(event).some((target) => - isSameFirstPartyApiTarget( - target, - latestBreadcrumb as NetworkTargetCandidate - ) + isSameFirstPartyApiTarget(target, latestBreadcrumb) ); } From f61b1826f03273dfd566155eb38a884729016f05 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 10:02:20 +0300 Subject: [PATCH 10/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 48 +++++++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 30 ++++++++++++ utils/sentry-client-filters.ts | 4 ++ 3 files changed, 82 insertions(+) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 5bcc3ee95e..c05e7037c3 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -333,6 +333,54 @@ describe("instrumentation-client", () => { expect(result?.tags?.["network_noise_sampled"]).toBeUndefined(); }); + 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(); + }); + 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 d0440a667f..85b45ed48c 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -354,6 +354,36 @@ describe("sentry-client-filters", () => { 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 third-party API paths after URL sanitization", () => { const result = getLowValueNetworkErrorDecision( createLowValueNetworkEvent({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 76b2b59617..00f667de13 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -383,6 +383,10 @@ function getLatestFailedTransportBreadcrumb( } const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); + if (statusCode !== null && statusCode >= 400) { + return null; + } + if (statusCode !== 0) { continue; } From fdc39cc21408c2e2cb7d526a8dd66a1d981d4307 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 10:16:47 +0300 Subject: [PATCH 11/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 82 ++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 100 ++++++++++++++++++ utils/sentry-client-filters.ts | 50 +++++++-- 3 files changed, 226 insertions(+), 6 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index c05e7037c3..b08e738795 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -99,6 +99,47 @@ describe("instrumentation-client", () => { expect(result).toBeNull(); }); + 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 the latest failed fetch breadcrumb for raw browser network errors", () => { const beforeSend = loadBeforeSend(); const event = { @@ -216,6 +257,47 @@ describe("instrumentation-client", () => { 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 = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 85b45ed48c..f16bb41695 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -354,6 +354,68 @@ describe("sentry-client-filters", () => { expect(result).toBe("drop"); }); + 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({ @@ -384,6 +446,44 @@ describe("sentry-client-filters", () => { expect(result).toBe("not_applicable"); }); + it("keeps raw first-party network errors when a later same-target request has a real HTTP failure", () => { + const result = getLowValueNetworkErrorDecision( + 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, + }, + }, + ], + }), + 0 + ); + + expect(result).toBe("not_applicable"); + }); + it("keeps third-party API paths after URL sanitization", () => { const result = getLowValueNetworkErrorDecision( createLowValueNetworkEvent({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 00f667de13..26aa669fea 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -372,10 +372,23 @@ function getBreadcrumbTargetCandidate( }; } +function getFailedTransportBreadcrumbTarget( + breadcrumb: SentryBreadcrumb +): NetworkTargetCandidate { + return { + url: getBreadcrumbUrl(breadcrumb) ?? "", + isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), + isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), + }; +} + 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) { @@ -384,18 +397,43 @@ function getLatestFailedTransportBreadcrumb( const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); if (statusCode !== null && statusCode >= 400) { - return null; + const failedHttpTarget = getBreadcrumbTargetCandidate(breadcrumb); + if (!failedHttpTarget) { + continue; + } + + if ( + messageTargetCandidates.length > 0 && + messageTargetCandidates.some((target) => + isSameFirstPartyApiTarget(target, failedHttpTarget) + ) + ) { + return null; + } + + if (messageTargetCandidates.length === 0) { + laterRealFailureTargetCandidates.push(failedHttpTarget); + } + + continue; } if (statusCode !== 0) { continue; } - return { - url: getBreadcrumbUrl(breadcrumb) ?? "", - isFirstParty: getBreadcrumbUrlIsFirstParty(breadcrumb), - isFirstPartyApi: getBreadcrumbUrlIsFirstPartyApi(breadcrumb), - }; + const failedTransportTarget = + getFailedTransportBreadcrumbTarget(breadcrumb); + if ( + messageTargetCandidates.length === 0 && + laterRealFailureTargetCandidates.some((target) => + isSameFirstPartyApiTarget(target, failedTransportTarget) + ) + ) { + return null; + } + + return failedTransportTarget; } return null; From b717bf0952830b7b7c2b1c5053faa645b091cbc9 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 10:26:31 +0300 Subject: [PATCH 12/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 95 +++++++++++++++++++ utils/sentry-client-filters.ts | 6 +- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index f16bb41695..54c6cd53fd 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -446,6 +446,101 @@ describe("sentry-client-filters", () => { 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 result = getLowValueNetworkErrorDecision( createLowValueNetworkEvent({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 26aa669fea..0cb0c05d5d 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -399,7 +399,11 @@ function getLatestFailedTransportBreadcrumb( if (statusCode !== null && statusCode >= 400) { const failedHttpTarget = getBreadcrumbTargetCandidate(breadcrumb); if (!failedHttpTarget) { - continue; + if (getBreadcrumbUrlIsFirstParty(breadcrumb) === false) { + continue; + } + + return null; } if ( From 9d89b15a54b2f8637625c2e99f0946e21033ca29 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 10:54:20 +0300 Subject: [PATCH 13/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 42 ++++++++++++++++++++++++ instrumentation-client.ts | 15 +++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index b08e738795..b9298b6cc0 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -140,6 +140,48 @@ describe("instrumentation-client", () => { 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("uses the latest failed fetch breadcrumb for raw browser network errors", () => { const beforeSend = loadBeforeSend(); const event = { diff --git a/instrumentation-client.ts b/instrumentation-client.ts index c7292872d9..33c22250fa 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -130,10 +130,19 @@ function isHttpBreadcrumb(breadcrumb: Breadcrumb): boolean { function getBreadcrumbStatusCode(breadcrumb: Breadcrumb): number | null { const data = breadcrumb.data; - return ( + const statusCode = getNumericValue(data?.["status_code"]) ?? - getNumericValue(data?.["http.response.status_code"]) - ); + getNumericValue(data?.["http.response.status_code"]); + + if (statusCode !== null) { + return statusCode; + } + + if (breadcrumb.category === "fetch" && breadcrumb.level === "error") { + return 0; + } + + return null; } function getLatestHttpBreadcrumbUrl( From 6f7d9e4e514b7e02d4302d4b53bafc0dbf7d091c Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 11:09:57 +0300 Subject: [PATCH 14/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 43 ++++++++++++++++++++++++ instrumentation-client.ts | 33 ++---------------- utils/sentry-client-filters.ts | 2 +- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index b9298b6cc0..bf5549a24f 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -182,6 +182,49 @@ describe("instrumentation-client", () => { ); }); + 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 the latest failed fetch breadcrumb for raw browser network errors", () => { const beforeSend = loadBeforeSend(); const event = { diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 33c22250fa..3722aafc26 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,6 +13,7 @@ import { sanitizeUrlString, } from "@/utils/sentry-sanitizer"; import { + getBreadcrumbTransportStatusCode, getLowValueNetworkErrorDecision, getThirdPartyTelemetrySpanTargetKey, shouldFilterByFilenameExceptions, @@ -107,19 +108,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function getNumericValue(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - - if (typeof value === "string") { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } - - return null; -} - function isHttpBreadcrumb(breadcrumb: Breadcrumb): boolean { return ( breadcrumb.type === "http" || @@ -128,23 +116,6 @@ function isHttpBreadcrumb(breadcrumb: Breadcrumb): boolean { ); } -function getBreadcrumbStatusCode(breadcrumb: Breadcrumb): number | null { - const data = breadcrumb.data; - const statusCode = - getNumericValue(data?.["status_code"]) ?? - getNumericValue(data?.["http.response.status_code"]); - - if (statusCode !== null) { - return statusCode; - } - - if (breadcrumb.category === "fetch" && breadcrumb.level === "error") { - return 0; - } - - return null; -} - function getLatestHttpBreadcrumbUrl( event: Sentry.Event, statusCode?: number @@ -162,7 +133,7 @@ function getLatestHttpBreadcrumbUrl( if ( statusCode !== undefined && - getBreadcrumbStatusCode(breadcrumb) !== statusCode + getBreadcrumbTransportStatusCode(breadcrumb) !== statusCode ) { continue; } diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 0cb0c05d5d..bbd09e99f4 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -326,7 +326,7 @@ function getBreadcrumbStatusCode(breadcrumb: SentryBreadcrumb): number | null { ); } -function getBreadcrumbTransportStatusCode( +export function getBreadcrumbTransportStatusCode( breadcrumb: SentryBreadcrumb ): number | null { const statusCode = getBreadcrumbStatusCode(breadcrumb); From 873ab30b8d10d332f81c2ae99340e241c42d5aa8 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 13:11:25 +0300 Subject: [PATCH 15/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 108 ++++++++++++++++++ utils/sentry-client-filters.ts | 27 +++++ 2 files changed, 135 insertions(+) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 54c6cd53fd..6a9a22cc5d 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -354,6 +354,114 @@ describe("sentry-client-filters", () => { expect(result).toBe("drop"); }); + 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("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({ diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index bbd09e99f4..f1a7726b6c 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -382,6 +382,23 @@ function getFailedTransportBreadcrumbTarget( }; } +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 { @@ -428,6 +445,16 @@ function getLatestFailedTransportBreadcrumb( const failedTransportTarget = getFailedTransportBreadcrumbTarget(breadcrumb); + if ( + messageTargetCandidates.length > 0 && + !canUseFailedTransportForMessageTarget( + failedTransportTarget, + messageTargetCandidates + ) + ) { + continue; + } + if ( messageTargetCandidates.length === 0 && laterRealFailureTargetCandidates.some((target) => From 43e5ff0bfcb8bce6dacf2eda9c239983609f7034 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 13:35:52 +0300 Subject: [PATCH 16/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 34 ++++++++++++++++++++++++ instrumentation-client.ts | 15 +++++++---- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index bf5549a24f..076452cea3 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -461,6 +461,40 @@ describe("instrumentation-client", () => { 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 = { diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 3722aafc26..9fcd2b4e5b 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -238,8 +238,14 @@ 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 request failed") + ); +} + +function isRawBrowserNetworkError(error: Error): boolean { + return ( + error.name === "NetworkError" || + (error instanceof TypeError && isNetworkError(error.message)) ); } @@ -277,10 +283,9 @@ function handleNetworkError( value: Sentry.Exception | undefined ): void { const isAppWrappedError = isAppWrappedApiNetworkError(error.message); - const isRawBrowserNetworkError = - error instanceof TypeError && isNetworkError(error.message); + const isRawBrowserError = isRawBrowserNetworkError(error); - if (!isAppWrappedError && !isRawBrowserNetworkError) { + if (!isAppWrappedError && !isRawBrowserError) { return; } From 252fbddf5d98dc9f1039a8f5a1248dfac828dad8 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 14:49:50 +0300 Subject: [PATCH 17/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 44 ++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 64 +++++++++++++++++ instrumentation-client.ts | 55 ++------------- utils/sentry-client-filters.ts | 68 +++++++++++-------- 4 files changed, 154 insertions(+), 77 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 076452cea3..0edc9e1417 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -99,6 +99,50 @@ describe("instrumentation-client", () => { expect(result).toBeNull(); }); + 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("drops sampled-out raw browser transport network errors when a later unrelated request fails", () => { const beforeSend = loadBeforeSend(); const event = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 6a9a22cc5d..73b9419de1 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -1,6 +1,7 @@ import { __testing, getLowValueNetworkErrorDecision, + getLowValueNetworkErrorTargetUrl, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, @@ -432,6 +433,69 @@ describe("sentry-client-filters", () => { 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({ diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 9fcd2b4e5b..265f4d59ad 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -13,8 +13,8 @@ import { sanitizeUrlString, } from "@/utils/sentry-sanitizer"; import { - getBreadcrumbTransportStatusCode, getLowValueNetworkErrorDecision, + getLowValueNetworkErrorTargetUrl, getThirdPartyTelemetrySpanTargetKey, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, @@ -24,7 +24,6 @@ import { type SentryTransactionSpan, } from "@/utils/sentry-client-filters"; import * as Sentry from "@sentry/nextjs"; -import type { Breadcrumb } from "@sentry/nextjs"; const sentryEnabled = !!publicEnv.SENTRY_DSN; const isProduction = publicEnv.NODE_ENV === "production"; @@ -108,51 +107,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isHttpBreadcrumb(breadcrumb: Breadcrumb): boolean { - return ( - breadcrumb.type === "http" || - breadcrumb.category === "fetch" || - breadcrumb.category === "xhr" - ); -} - -function getLatestHttpBreadcrumbUrl( - event: Sentry.Event, - statusCode?: number -): unknown { - const breadcrumbs = event.breadcrumbs; - if (!Array.isArray(breadcrumbs)) { - return undefined; - } - - for (let index = breadcrumbs.length - 1; index >= 0; index -= 1) { - const breadcrumb = breadcrumbs[index]; - if (!breadcrumb || !isHttpBreadcrumb(breadcrumb)) { - continue; - } - - if ( - statusCode !== undefined && - getBreadcrumbTransportStatusCode(breadcrumb) !== statusCode - ) { - continue; - } - - const url = breadcrumb.data?.["url"]; - if (url) { - return url; - } - } - - return undefined; -} - -function getLatestNetworkBreadcrumbUrl(event: Sentry.Event): unknown { - return ( - getLatestHttpBreadcrumbUrl(event, 0) ?? getLatestHttpBreadcrumbUrl(event) - ); -} - function filterNoisyThirdPartyTransactionSpans( event: Sentry.Event ): Sentry.Event { @@ -221,10 +175,11 @@ function extractUrlFromError(error: Error, event: Sentry.Event): string { return String(sanitizeUrlString(urlMatch[1])); } - const breadcrumbUrl = getLatestNetworkBreadcrumbUrl(event); - if (breadcrumbUrl) { - return String(sanitizeUrlString(breadcrumbUrl)); + const lowValueNetworkTargetUrl = getLowValueNetworkErrorTargetUrl(event); + if (lowValueNetworkTargetUrl) { + return String(sanitizeUrlString(lowValueNetworkTargetUrl)); } + if (event.request?.url) { return String(sanitizeUrlString(event.request.url)); } diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index f1a7726b6c..d78f0894b8 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -445,6 +445,10 @@ function getLatestFailedTransportBreadcrumb( const failedTransportTarget = getFailedTransportBreadcrumbTarget(breadcrumb); + if (failedTransportTarget.isFirstParty === false) { + continue; + } + if ( messageTargetCandidates.length > 0 && !canUseFailedTransportForMessageTarget( @@ -455,6 +459,13 @@ function getLatestFailedTransportBreadcrumb( continue; } + if ( + messageTargetCandidates.length === 0 && + !isFirstPartyApiTarget(failedTransportTarget) + ) { + continue; + } + if ( messageTargetCandidates.length === 0 && laterRealFailureTargetCandidates.some((target) => @@ -470,6 +481,35 @@ function getLatestFailedTransportBreadcrumb( 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 getMessageTargetCandidates( event: SentryClientEvent ): NetworkTargetCandidate[] { @@ -501,36 +541,10 @@ function getNetworkTargetUrlCandidates(event: SentryClientEvent): string[] { ); } -function getPrimaryNetworkTargetCandidates( - event: SentryClientEvent -): NetworkTargetCandidate[] { - const messageCandidates = getMessageTargetCandidates(event); - return messageCandidates.length > 0 - ? messageCandidates - : getNetworkTargetCandidates(event); -} - function hasMatchingFailedTransportBreadcrumb( event: SentryClientEvent ): boolean { - const latestBreadcrumb = getLatestFailedTransportBreadcrumb(event); - if (!latestBreadcrumb) { - return false; - } - - if (isFilteredUrl(latestBreadcrumb.url)) { - if (latestBreadcrumb.isFirstParty === false) { - return false; - } - - return getMessageTargetCandidates(event).some( - isFilteredBreadcrumbFallbackApiTarget - ); - } - - return getPrimaryNetworkTargetCandidates(event).some((target) => - isSameFirstPartyApiTarget(target, latestBreadcrumb) - ); + return getLowValueNetworkErrorTargetUrl(event) !== null; } function isLowValueFirstPartyNetworkError(event: SentryClientEvent): boolean { From 5c66f93b6e98b781e598d4a86eec08e994cb6678 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 15:07:54 +0300 Subject: [PATCH 18/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 54 ++++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 62 +++++++++---------- instrumentation-client.ts | 8 +-- utils/sentry-client-filters.ts | 48 ++++++++++++++ 4 files changed, 137 insertions(+), 35 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 0edc9e1417..5a2ec143ab 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -576,6 +576,9 @@ describe("instrumentation-client", () => { }) ); 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", () => { @@ -624,6 +627,57 @@ describe("instrumentation-client", () => { }) ); 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", () => { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 73b9419de1..56f7180b97 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -2,6 +2,7 @@ import { __testing, getLowValueNetworkErrorDecision, getLowValueNetworkErrorTargetUrl, + getNetworkErrorMessageTargetUrl, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, shouldFilterThirdPartyTelemetrySpan, @@ -714,41 +715,40 @@ describe("sentry-client-filters", () => { }); it("keeps raw first-party network errors when a later same-target request has a real HTTP failure", () => { - const result = getLowValueNetworkErrorDecision( - 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, - }, - }, + const event = createLowValueNetworkEvent({ + exception: { + values: [ { - type: "http", - category: "fetch", - data: { - status_code: 500, - url: "/api/waves-overview", - "url.is_first_party": true, - }, + type: "TypeError", + value: "Load failed", }, ], - }), - 0 - ); + }, + 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(result).toBe("not_applicable"); + 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", () => { diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 265f4d59ad..99b354da34 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -14,7 +14,7 @@ import { } from "@/utils/sentry-sanitizer"; import { getLowValueNetworkErrorDecision, - getLowValueNetworkErrorTargetUrl, + getNetworkErrorMessageTargetUrl, getThirdPartyTelemetrySpanTargetKey, shouldFilterByFilenameExceptions, shouldFilterInjectedWalletCollision, @@ -175,9 +175,9 @@ function extractUrlFromError(error: Error, event: Sentry.Event): string { return String(sanitizeUrlString(urlMatch[1])); } - const lowValueNetworkTargetUrl = getLowValueNetworkErrorTargetUrl(event); - if (lowValueNetworkTargetUrl) { - return String(sanitizeUrlString(lowValueNetworkTargetUrl)); + const networkMessageTargetUrl = getNetworkErrorMessageTargetUrl(event); + if (networkMessageTargetUrl) { + return String(sanitizeUrlString(networkMessageTargetUrl)); } if (event.request?.url) { diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index d78f0894b8..a1dca9b8aa 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -510,6 +510,54 @@ export function getLowValueNetworkErrorTargetUrl( ); } +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) => getBreadcrumbTransportStatusCode(breadcrumb) === 0 + ) ?? getLatestUsableBreadcrumbMessageUrl(event, () => true) + ); +} + function getMessageTargetCandidates( event: SentryClientEvent ): NetworkTargetCandidate[] { From 3973a158ef968bc080a76042b7c7c5b6d27f11e4 Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 15:23:28 +0300 Subject: [PATCH 19/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 86 +++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 102 ++++++++++++++---- utils/sentry-client-filters.ts | 31 ++++-- 3 files changed, 190 insertions(+), 29 deletions(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 5a2ec143ab..fd4a974218 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -269,6 +269,92 @@ describe("instrumentation-client", () => { ); }); + 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 = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 56f7180b97..5560749f01 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -327,33 +327,93 @@ describe("sentry-client-filters", () => { }); it("drops sampled-out first-party status 0 network errors when a later request succeeds", () => { - const result = getLowValueNetworkErrorDecision( - createLowValueNetworkEvent({ - breadcrumbs: [ + 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: "http", - category: "fetch", - data: { - status_code: 0, - url: "/api/waves-overview", - "url.is_first_party": true, - }, + 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("does not use successful breadcrumbs for network error message targets", () => { + const event = createLowValueNetworkEvent({ + exception: { + values: [ { - type: "http", - category: "fetch", - data: { - status_code: 200, - url: "/api/waves-overview", - "url.is_first_party": true, - }, + type: "TypeError", + value: "Load failed", }, ], - }), - 0 - ); + }, + breadcrumbs: [ + { + type: "http", + category: "fetch", + data: { + status_code: 200, + url: "/api/identity", + "url.is_first_party": true, + }, + }, + ], + }); - expect(result).toBe("drop"); + expect(getNetworkErrorMessageTargetUrl(event)).toBeNull(); }); it("drops sampled-out message-target status 0 errors when a later status 0 targets a different first-party API", () => { diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index a1dca9b8aa..1a73aa76f6 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -25,6 +25,8 @@ type NetworkTargetCandidate = { isFirstPartyApi?: boolean | undefined; }; +type NetworkBreadcrumbFailureKind = "transport" | "http"; + type SentryExceptionValue = { type?: string | undefined; value?: string | undefined; @@ -341,6 +343,21 @@ export function getBreadcrumbTransportStatusCode( 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"]); } @@ -412,8 +429,8 @@ function getLatestFailedTransportBreadcrumb( continue; } - const statusCode = getBreadcrumbTransportStatusCode(breadcrumb); - if (statusCode !== null && statusCode >= 400) { + const failureKind = getBreadcrumbFailureKind(breadcrumb); + if (failureKind === "http") { const failedHttpTarget = getBreadcrumbTargetCandidate(breadcrumb); if (!failedHttpTarget) { if (getBreadcrumbUrlIsFirstParty(breadcrumb) === false) { @@ -439,7 +456,7 @@ function getLatestFailedTransportBreadcrumb( continue; } - if (statusCode !== 0) { + if (failureKind !== "transport") { continue; } @@ -550,11 +567,9 @@ export function getNetworkErrorMessageTargetUrl( return lowValueTargetUrl; } - return ( - getLatestUsableBreadcrumbMessageUrl( - event, - (breadcrumb) => getBreadcrumbTransportStatusCode(breadcrumb) === 0 - ) ?? getLatestUsableBreadcrumbMessageUrl(event, () => true) + return getLatestUsableBreadcrumbMessageUrl( + event, + (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) !== null ); } From 3d5f466bb24ab0f0434b5d75e09259c0e3deee9f Mon Sep 17 00:00:00 2001 From: Simo Date: Mon, 4 May 2026 15:39:07 +0300 Subject: [PATCH 20/22] wip Signed-off-by: Simo --- __tests__/utils/sentry-client-filters.test.ts | 76 +++++++++++++++++++ utils/sentry-client-filters.ts | 12 ++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 5560749f01..2d1f95bb2b 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -390,6 +390,82 @@ describe("sentry-client-filters", () => { 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: { diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 1a73aa76f6..66460b5b07 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -567,9 +567,15 @@ export function getNetworkErrorMessageTargetUrl( return lowValueTargetUrl; } - return getLatestUsableBreadcrumbMessageUrl( - event, - (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) !== null + return ( + getLatestUsableBreadcrumbMessageUrl( + event, + (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) === "transport" + ) ?? + getLatestUsableBreadcrumbMessageUrl( + event, + (breadcrumb) => getBreadcrumbFailureKind(breadcrumb) === "http" + ) ); } From b57b5d8855c984352ab3b45d5c23f40ea1c38deb Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 5 May 2026 09:28:41 +0300 Subject: [PATCH 21/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 35 +++++++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 18 ++++++++++ instrumentation-client.ts | 1 + utils/sentry-client-filters.ts | 1 + 4 files changed, 55 insertions(+) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index fd4a974218..22fdc0f0c7 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -99,6 +99,41 @@ describe("instrumentation-client", () => { 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 = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index 2d1f95bb2b..db79271edc 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -326,6 +326,24 @@ describe("sentry-client-filters", () => { 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: [ diff --git a/instrumentation-client.ts b/instrumentation-client.ts index 99b354da34..4e37b9973f 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -193,6 +193,7 @@ function isNetworkError(errorMessage: string): boolean { normalized.includes("load failed") || normalized.includes("networkerror") || normalized.includes("network error") || + normalized.includes("network connection was lost") || normalized.includes("network request failed") ); } diff --git a/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index 66460b5b07..f2ce551513 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -129,6 +129,7 @@ function isNetworkErrorMessage(value: string): boolean { normalized.includes("load failed") || normalized.includes("networkerror") || normalized.includes("network error") || + normalized.includes("network connection was lost") || normalized.includes("network request failed") ); } From 66a009605dc1952647887e974cead86ef7e458d8 Mon Sep 17 00:00:00 2001 From: Simo Date: Tue, 5 May 2026 10:41:17 +0300 Subject: [PATCH 22/22] wip Signed-off-by: Simo --- __tests__/instrumentation-client.test.ts | 51 +++++++++++++++++++ __tests__/utils/sentry-client-filters.test.ts | 37 ++++++++++++++ utils/sentry-client-filters.ts | 2 +- 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/__tests__/instrumentation-client.test.ts b/__tests__/instrumentation-client.test.ts index 22fdc0f0c7..207566aec1 100644 --- a/__tests__/instrumentation-client.test.ts +++ b/__tests__/instrumentation-client.test.ts @@ -178,6 +178,57 @@ describe("instrumentation-client", () => { ); }); + 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 = { diff --git a/__tests__/utils/sentry-client-filters.test.ts b/__tests__/utils/sentry-client-filters.test.ts index db79271edc..230593eb1a 100644 --- a/__tests__/utils/sentry-client-filters.test.ts +++ b/__tests__/utils/sentry-client-filters.test.ts @@ -1344,6 +1344,43 @@ describe("sentry-client-filters", () => { 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/utils/sentry-client-filters.ts b/utils/sentry-client-filters.ts index f2ce551513..56dc781420 100644 --- a/utils/sentry-client-filters.ts +++ b/utils/sentry-client-filters.ts @@ -481,7 +481,7 @@ function getLatestFailedTransportBreadcrumb( messageTargetCandidates.length === 0 && !isFirstPartyApiTarget(failedTransportTarget) ) { - continue; + return null; } if (