From 47d33f2c982a9467114dbb528e2095d4f3addc28 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 10 Dec 2025 14:55:19 +0100 Subject: [PATCH] feat(core): Add option to enhance the fetch error message --- .../errors/fetch-enhance-messages-off/init.js | 8 ++ .../fetch-enhance-messages-off/subject.js | 49 +++++++ .../errors/fetch-enhance-messages-off/test.ts | 113 +++++++++++++++ .../init.js | 8 ++ .../subject.js | 49 +++++++ .../test.ts | 134 ++++++++++++++++++ .../suites/errors/fetch/test.ts | 20 ++- packages/browser/src/eventbuilder.ts | 5 +- packages/browser/test/eventbuilder.test.ts | 75 ++++++++++ packages/core/src/index.ts | 8 +- packages/core/src/instrument/fetch.ts | 19 ++- packages/core/src/types-hoist/options.ts | 15 ++ packages/core/src/utils/eventbuilder.ts | 24 +++- .../core/test/lib/utils/eventbuilder.test.ts | 80 ++++++++++- 14 files changed, 597 insertions(+), 10 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/init.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/init.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/test.ts diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/init.js b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/init.js new file mode 100644 index 000000000000..783f188b3ba2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enhanceFetchErrorMessages: false, +}); diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/subject.js b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/subject.js new file mode 100644 index 000000000000..bd943ee74370 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/subject.js @@ -0,0 +1,49 @@ +// Based on possible TypeError exceptions from https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch + +// Network error (e.g. ad-blocked, offline, page does not exist, ...) +window.networkError = () => { + fetch('http://sentry-test-external.io/does-not-exist'); +}; + +window.networkErrorSubdomain = () => { + fetch('http://subdomain.sentry-test-external.io/does-not-exist'); +}; + +window.networkErrorWithPort = () => { + fetch('http://sentry-test-external.io:3000/does-not-exist'); +}; + +// Invalid header also produces TypeError +window.invalidHeaderName = () => { + fetch('http://sentry-test-external.io/invalid-header-name', { headers: { 'C ontent-Type': 'text/xml' } }); +}; + +// Invalid header value also produces TypeError +window.invalidHeaderValue = () => { + fetch('http://sentry-test-external.io/invalid-header-value', { headers: ['Content-Type', 'text/html', 'extra'] }); +}; + +// Invalid URL scheme +window.invalidUrlScheme = () => { + fetch('blub://sentry-test-external.io/invalid-scheme'); +}; + +// URL includes credentials +window.credentialsInUrl = () => { + fetch('https://user:password@sentry-test-external.io/credentials-in-url'); +}; + +// Invalid mode +window.invalidMode = () => { + fetch('https://sentry-test-external.io/invalid-mode', { mode: 'navigate' }); +}; + +// Invalid request method +window.invalidMethod = () => { + fetch('http://sentry-test-external.io/invalid-method', { method: 'CONNECT' }); +}; + +// No-cors mode with cors-required method +window.noCorsMethod = () => { + fetch('http://sentry-test-external.io/no-cors-method', { mode: 'no-cors', method: 'PUT' }); +}; diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/test.ts b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/test.ts new file mode 100644 index 000000000000..a92438ee4f3a --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/test.ts @@ -0,0 +1,113 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; + +sentryTest( + 'enhanceFetchErrorMessages: false: enhances error for Sentry while preserving original @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => { + page.on('pageerror', error => { + resolve(error.message); + }); + }); + + await page.goto(url); + await page.evaluate('networkError()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const originalError = originalErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('sentry-test-external.io'); + + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: originalError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); + +sentryTest( + 'enhanceFetchErrorMessages: false: enhances subdomain errors @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + + await page.goto(url); + await page.evaluate('networkErrorSubdomain()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const originalError = originalErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('subdomain.sentry-test-external.io'); + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: originalError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); + +sentryTest( + 'enhanceFetchErrorMessages: false: includes port in hostname @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + + await page.goto(url); + await page.evaluate('networkErrorWithPort()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const originalError = originalErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('sentry-test-external.io:3000'); + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: originalError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/init.js b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/init.js new file mode 100644 index 000000000000..535d3397fb60 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/init.js @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + enhanceFetchErrorMessages: 'report-only', +}); diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/subject.js b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/subject.js new file mode 100644 index 000000000000..bd943ee74370 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/subject.js @@ -0,0 +1,49 @@ +// Based on possible TypeError exceptions from https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch + +// Network error (e.g. ad-blocked, offline, page does not exist, ...) +window.networkError = () => { + fetch('http://sentry-test-external.io/does-not-exist'); +}; + +window.networkErrorSubdomain = () => { + fetch('http://subdomain.sentry-test-external.io/does-not-exist'); +}; + +window.networkErrorWithPort = () => { + fetch('http://sentry-test-external.io:3000/does-not-exist'); +}; + +// Invalid header also produces TypeError +window.invalidHeaderName = () => { + fetch('http://sentry-test-external.io/invalid-header-name', { headers: { 'C ontent-Type': 'text/xml' } }); +}; + +// Invalid header value also produces TypeError +window.invalidHeaderValue = () => { + fetch('http://sentry-test-external.io/invalid-header-value', { headers: ['Content-Type', 'text/html', 'extra'] }); +}; + +// Invalid URL scheme +window.invalidUrlScheme = () => { + fetch('blub://sentry-test-external.io/invalid-scheme'); +}; + +// URL includes credentials +window.credentialsInUrl = () => { + fetch('https://user:password@sentry-test-external.io/credentials-in-url'); +}; + +// Invalid mode +window.invalidMode = () => { + fetch('https://sentry-test-external.io/invalid-mode', { mode: 'navigate' }); +}; + +// Invalid request method +window.invalidMethod = () => { + fetch('http://sentry-test-external.io/invalid-method', { method: 'CONNECT' }); +}; + +// No-cors mode with cors-required method +window.noCorsMethod = () => { + fetch('http://sentry-test-external.io/no-cors-method', { mode: 'no-cors', method: 'PUT' }); +}; diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/test.ts b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/test.ts new file mode 100644 index 000000000000..113daf33f6b6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/test.ts @@ -0,0 +1,134 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; + +sentryTest( + 'enhanceFetchErrorMessages: report-only: enhances error for Sentry while preserving original @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + + await page.goto(url); + await page.evaluate('networkError()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const enhancedErrorMap: Record = { + chromium: 'Failed to fetch (sentry-test-external.io)', + webkit: 'Load failed (sentry-test-external.io)', + firefox: 'NetworkError when attempting to fetch resource. (sentry-test-external.io)', + }; + + const originalError = originalErrorMap[browserName]; + const enhancedError = enhancedErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('sentry-test-external.io'); + + // Verify Sentry received the enhanced message + // Note: In report-only mode, the original error message remains unchanged + // at the JavaScript level (for third-party package compatibility), + // but Sentry gets the enhanced version via __sentry_fetch_url_host__ + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: enhancedError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); + +sentryTest( + 'enhanceFetchErrorMessages: report-only: enhances subdomain errors @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + + await page.goto(url); + await page.evaluate('networkErrorSubdomain()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const enhancedErrorMap: Record = { + chromium: 'Failed to fetch (subdomain.sentry-test-external.io)', + webkit: 'Load failed (subdomain.sentry-test-external.io)', + firefox: 'NetworkError when attempting to fetch resource. (subdomain.sentry-test-external.io)', + }; + + const originalError = originalErrorMap[browserName]; + const enhancedError = enhancedErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('subdomain.sentry-test-external.io'); + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: enhancedError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); + +sentryTest( + 'enhanceFetchErrorMessages: report-only: includes port in hostname @firefox', + async ({ getLocalTestUrl, page, browserName }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const reqPromise = waitForErrorRequest(page); + + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + + await page.goto(url); + await page.evaluate('networkErrorWithPort()'); + + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); + + const originalErrorMap: Record = { + chromium: 'Failed to fetch', + webkit: 'Load failed', + firefox: 'NetworkError when attempting to fetch resource.', + }; + + const enhancedErrorMap: Record = { + chromium: 'Failed to fetch (sentry-test-external.io:3000)', + webkit: 'Load failed (sentry-test-external.io:3000)', + firefox: 'NetworkError when attempting to fetch resource. (sentry-test-external.io:3000)', + }; + + const originalError = originalErrorMap[browserName]; + const enhancedError = enhancedErrorMap[browserName]; + + expect(pageErrorMessage).toContain(originalError); + expect(pageErrorMessage).not.toContain('sentry-test-external.io:3000'); + expect(eventData.exception?.values).toHaveLength(1); + expect(eventData.exception?.values?.[0]).toMatchObject({ + type: 'TypeError', + value: enhancedError, + mechanism: { + handled: false, + type: 'auto.browser.global_handlers.onunhandledrejection', + }, + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/errors/fetch/test.ts b/dev-packages/browser-integration-tests/suites/errors/fetch/test.ts index 19fe923c7b30..56e8225bf5b7 100644 --- a/dev-packages/browser-integration-tests/suites/errors/fetch/test.ts +++ b/dev-packages/browser-integration-tests/suites/errors/fetch/test.ts @@ -5,10 +5,13 @@ import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpe sentryTest('handles fetch network errors @firefox', async ({ getLocalTestUrl, page, browserName }) => { const url = await getLocalTestUrl({ testDir: __dirname }); const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + await page.goto(url); await page.evaluate('networkError()'); - const eventData = envelopeRequestParser(await reqPromise); + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); const errorMap: Record = { chromium: 'Failed to fetch (sentry-test-external.io)', @@ -18,6 +21,7 @@ sentryTest('handles fetch network errors @firefox', async ({ getLocalTestUrl, pa const error = errorMap[browserName]; + expect(pageErrorMessage).toContain(error); expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'TypeError', @@ -32,10 +36,13 @@ sentryTest('handles fetch network errors @firefox', async ({ getLocalTestUrl, pa sentryTest('handles fetch network errors on subdomains @firefox', async ({ getLocalTestUrl, page, browserName }) => { const url = await getLocalTestUrl({ testDir: __dirname }); const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + await page.goto(url); await page.evaluate('networkErrorSubdomain()'); - const eventData = envelopeRequestParser(await reqPromise); + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); const errorMap: Record = { chromium: 'Failed to fetch (subdomain.sentry-test-external.io)', @@ -45,6 +52,9 @@ sentryTest('handles fetch network errors on subdomains @firefox', async ({ getLo const error = errorMap[browserName]; + // Verify the error message at JavaScript level includes the hostname + expect(pageErrorMessage).toContain(error); + expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'TypeError', @@ -127,10 +137,13 @@ sentryTest('handles fetch invalid URL scheme errors @firefox', async ({ getLocal const url = await getLocalTestUrl({ testDir: __dirname }); const reqPromise = waitForErrorRequest(page); + const pageErrorPromise = new Promise(resolve => page.on('pageerror', error => resolve(error.message))); + await page.goto(url); await page.evaluate('invalidUrlScheme()'); - const eventData = envelopeRequestParser(await reqPromise); + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); const errorMap: Record = { chromium: 'Failed to fetch (sentry-test-external.io)', @@ -146,6 +159,7 @@ sentryTest('handles fetch invalid URL scheme errors @firefox', async ({ getLocal * But it seems we cannot really access this in the SDK :( */ + expect(pageErrorMessage).toContain(error); expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'TypeError', diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index cc0be3378b8d..9823d596a502 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -8,6 +8,7 @@ import type { StackParser, } from '@sentry/core'; import { + _INTERNAL_enhanceErrorWithSentryInfo, addExceptionMechanism, addExceptionTypeValue, extractExceptionKeysForMessage, @@ -212,10 +213,10 @@ export function extractMessage(ex: Error & { message: { error?: Error } }): stri } if (message.error && typeof message.error.message === 'string') { - return message.error.message; + return _INTERNAL_enhanceErrorWithSentryInfo(message.error); } - return message; + return _INTERNAL_enhanceErrorWithSentryInfo(ex); } /** diff --git a/packages/browser/test/eventbuilder.test.ts b/packages/browser/test/eventbuilder.test.ts index ef360cb9caac..ef233ed58a1f 100644 --- a/packages/browser/test/eventbuilder.test.ts +++ b/packages/browser/test/eventbuilder.test.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import { addNonEnumerableProperty } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { defaultStackParser } from '../src'; import { eventFromMessage, eventFromUnknownInput, extractMessage, extractType } from '../src/eventbuilder'; @@ -260,3 +261,77 @@ describe('eventFromMessage ', () => { expect(event.exception).toBeUndefined(); }); }); + +describe('__sentry_fetch_url_host__ error enhancement', () => { + it('should enhance error message when __sentry_fetch_url_host__ property is present', () => { + const error = new Error('Failed to fetch'); + // Simulate what fetch instrumentation does + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const message = extractMessage(error); + + expect(message).toBe('Failed to fetch (example.com)'); + }); + + it('should not enhance error message when property is missing', () => { + const error = new Error('Failed to fetch'); + + const message = extractMessage(error); + + expect(message).toBe('Failed to fetch'); + }); + + it('should preserve original error message unchanged', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'api.example.com'); + + // Original error message should still be accessible + expect(error.message).toBe('Failed to fetch'); + + // But Sentry exception should have enhanced message + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (api.example.com)'); + }); + + it.each([ + { message: 'Failed to fetch', host: 'example.com', expected: 'Failed to fetch (example.com)' }, + { message: 'Load failed', host: 'api.test.com', expected: 'Load failed (api.test.com)' }, + { + message: 'NetworkError when attempting to fetch resource.', + host: 'localhost:3000', + expected: 'NetworkError when attempting to fetch resource. (localhost:3000)', + }, + ])('should work with all network error types ($message)', ({ message, host, expected }) => { + const error = new Error(message); + + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', host); + + const enhancedMessage = extractMessage(error); + expect(enhancedMessage).toBe(expected); + }); + + it('should not enhance if property value is not a string', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 123); // Not a string + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch'); + }); + + it('should handle errors with stack traces', () => { + const error = new Error('Failed to fetch'); + error.stack = 'TypeError: Failed to fetch\n at fetch (test.js:1:1)'; + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (example.com)'); + }); + + it('should preserve hostname with port', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'localhost:8080'); + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (localhost:8080)'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 387ba0aba4a2..90bca0319194 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -312,7 +312,13 @@ export { isURLObjectRelative, getSanitizedUrlStringFromUrlObject, } from './utils/url'; -export { eventFromMessage, eventFromUnknownInput, exceptionFromError, parseStackFrames } from './utils/eventbuilder'; +export { + eventFromMessage, + eventFromUnknownInput, + exceptionFromError, + parseStackFrames, + _enhanceErrorWithSentryInfo as _INTERNAL_enhanceErrorWithSentryInfo, +} from './utils/eventbuilder'; export { callFrameToStackFrame, watchdogTimer } from './utils/anr'; export { LRUMap } from './utils/lru'; export { generateTraceId, generateSpanId } from './utils/propagationContext'; diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index 0780b25bb29f..b596f967b0ab 100644 --- a/packages/core/src/instrument/fetch.ts +++ b/packages/core/src/instrument/fetch.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getClient } from '../currentScopes'; import type { HandlerDataFetch } from '../types-hoist/instrument'; import type { WebFetchHeaders } from '../types-hoist/webfetchapi'; import { isError, isRequest } from '../utils/is'; @@ -108,12 +109,17 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat addNonEnumerableProperty(error, 'framesToPop', 1); } - // We enhance the not-so-helpful "Failed to fetch" error messages with the host + // We enhance fetch error messages with hostname information based on the configuration. // Possible messages we handle here: // * "Failed to fetch" (chromium) // * "Load failed" (webkit) // * "NetworkError when attempting to fetch resource." (firefox) + const client = getClient(); + const enhanceOption = client?.getOptions().enhanceFetchErrorMessages ?? 'always'; + const shouldEnhance = enhanceOption !== false; + if ( + shouldEnhance && error instanceof TypeError && (error.message === 'Failed to fetch' || error.message === 'Load failed' || @@ -121,7 +127,16 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat ) { try { const url = new URL(handlerData.fetchData.url); - error.message = `${error.message} (${url.host})`; + const hostname = url.host; + + if (enhanceOption === 'always') { + // Modify the error message directly + error.message = `${error.message} (${hostname})`; + } else { + // Store hostname as non-enumerable property for Sentry-only enhancement + // This preserves the original error message for third-party packages + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', hostname); + } } catch { // ignore it if errors happen here } diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index c33d0107df5f..3f50d98df765 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -267,6 +267,21 @@ export interface ClientOptions(error: T): string { + // If the error has a __sentry_fetch_url_host__ property (added by fetch instrumentation), + // enhance the error message with the hostname. + if (hasSentryFetchUrlHost(error)) { + return `${error.message} (${error.__sentry_fetch_url_host__})`; + } + + return error.message; +} + /** * Extracts stack frames from the error and builds a Sentry Exception */ export function exceptionFromError(stackParser: StackParser, error: Error): Exception { const exception: Exception = { type: error.name || error.constructor.name, - value: error.message, + value: _enhanceErrorWithSentryInfo(error), }; const frames = parseStackFrames(stackParser, error); diff --git a/packages/core/test/lib/utils/eventbuilder.test.ts b/packages/core/test/lib/utils/eventbuilder.test.ts index 77fa2ff93d96..b882a4562b1c 100644 --- a/packages/core/test/lib/utils/eventbuilder.test.ts +++ b/packages/core/test/lib/utils/eventbuilder.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, test } from 'vitest'; import type { Client } from '../../../src/client'; -import { eventFromMessage, eventFromUnknownInput } from '../../../src/utils/eventbuilder'; +import { eventFromMessage, eventFromUnknownInput, exceptionFromError } from '../../../src/utils/eventbuilder'; import { nodeStackLineParser } from '../../../src/utils/node-stack-trace'; +import { addNonEnumerableProperty } from '../../../src/utils/object'; import { createStackParser } from '../../../src/utils/stacktrace'; const stackParser = createStackParser(nodeStackLineParser()); @@ -214,4 +215,81 @@ describe('eventFromMessage', () => { message: 'Test Message', }); }); + + describe('__sentry_fetch_url_host__ error enhancement', () => { + it('should enhance error message when __sentry_fetch_url_host__ property is present', () => { + const error = new TypeError('Failed to fetch'); + // Simulate what fetch instrumentation does + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const exception = exceptionFromError(stackParser, error); + + expect(exception.value).toBe('Failed to fetch (example.com)'); + expect(exception.type).toBe('TypeError'); + }); + + it('should not enhance error message when property is missing', () => { + const error = new TypeError('Failed to fetch'); + + const exception = exceptionFromError(stackParser, error); + + expect(exception.value).toBe('Failed to fetch'); + expect(exception.type).toBe('TypeError'); + }); + + it('should preserve original error message unchanged', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'api.example.com'); + + // Original error message should still be accessible + expect(error.message).toBe('Failed to fetch'); + + // But Sentry exception should have enhanced message + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (api.example.com)'); + }); + + it.each([ + { message: 'Failed to fetch', host: 'example.com', expected: 'Failed to fetch (example.com)' }, + { message: 'Load failed', host: 'api.test.com', expected: 'Load failed (api.test.com)' }, + { + message: 'NetworkError when attempting to fetch resource.', + host: 'localhost:3000', + expected: 'NetworkError when attempting to fetch resource. (localhost:3000)', + }, + ])('should work with all network error types ($message)', ({ message, host, expected }) => { + const error = new TypeError(message); + + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', host); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe(expected); + }); + + it('should not enhance if property value is not a string', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 123); // Not a string + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch'); + }); + + it('should handle errors with stack traces', () => { + const error = new TypeError('Failed to fetch'); + error.stack = 'TypeError: Failed to fetch\n at fetch (test.js:1:1)'; + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (example.com)'); + expect(exception.type).toBe('TypeError'); + }); + + it('should preserve hostname with port', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'localhost:8080'); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (localhost:8080)'); + }); + }); });