diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25797f31a008..d65491640642 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -568,6 +568,7 @@ jobs: - bundle_min - bundle_replay - bundle_tracing + - bundle_tracing_logs_metrics - bundle_tracing_replay - bundle_tracing_replay_feedback - bundle_tracing_replay_feedback_min diff --git a/.size-limit.js b/.size-limit.js index 215a40d1bf17..a43d4d61e527 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init'), gzip: true, - limit: '25 KB', + limit: '25.5 KB', }, { name: '@sentry/browser - with treeshaking flags', @@ -148,7 +148,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '44 KB', + limit: '44.5 KB', }, // Vue SDK (ESM) { @@ -171,20 +171,26 @@ module.exports = [ path: 'packages/svelte/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '25 KB', + limit: '25.5 KB', }, // Browser CDN bundles { name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '27.5 KB', + limit: '28 KB', }, { name: 'CDN Bundle (incl. Tracing)', path: createCDNPath('bundle.tracing.min.js'), gzip: true, - limit: '42.5 KB', + limit: '43 KB', + }, + { + name: 'CDN Bundle (incl. Tracing, Logs, Metrics)', + path: createCDNPath('bundle.tracing.logs.metrics.min.js'), + gzip: true, + limit: '44 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay)', @@ -213,6 +219,13 @@ module.exports = [ brotli: false, limit: '127 KB', }, + { + name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', + path: createCDNPath('bundle.tracing.logs.metrics.min.js'), + gzip: false, + brotli: false, + limit: '130 KB', + }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', path: createCDNPath('bundle.tracing.replay.min.js'), @@ -234,7 +247,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '46.5 KB', + limit: '47 KB', }, // SvelteKit SDK (ESM) { @@ -243,7 +256,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42.5 KB', + limit: '43 KB', }, // Node-Core SDK (ESM) { @@ -261,7 +274,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '162.5 KB', + limit: '163 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc2613d9ddc..aeaf887eded2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.34.0 + +### Important Changes + +- **feat(core): Add option to enhance the fetch error message ([#18466](https://github.com/getsentry/sentry-javascript/pull/18466))** + + You can now enable enhanced fetch error messages by setting the `enhancedFetchErrorMessage` option. When enabled, the SDK will include additional context in fetch error messages to help with debugging. + +- **feat(nextjs): Add routeManifestInjection option to exclude routes from client bundle ([#18798](https://github.com/getsentry/sentry-javascript/pull/18798))** + + A new `routeManifestInjection` option allows you to exclude sensitive routes from being injected into the client bundle. + +- **feat(tanstackstart-react): Add `wrapMiddlewaresWithSentry` for manual middleware instrumentation ([#18680](https://github.com/getsentry/sentry-javascript/pull/18680))** + + You can now wrap your middlewares using `wrapMiddlewaresWithSentry`, allowing you to trace middleware execution in your TanStack Start application. + + ```ts + import { createMiddleware } from '@tanstack/react-start'; + import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; + + const loggingMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Request started'); + return next(); + }); + + export const [wrappedLoggingMiddleware] = wrapMiddlewaresWithSentry({ loggingMiddleware }); + ``` + +### Other Changes + +- feat(browser): Add CDN bundle for `tracing.logs.metrics` ([#18784](https://github.com/getsentry/sentry-javascript/pull/18784)) +- feat(core,node-core): Consolidate bun and node types with ServerRuntimeOptions ([#18734](https://github.com/getsentry/sentry-javascript/pull/18734)) +- feat(nextjs): Remove tracing from generation function template ([#18733](https://github.com/getsentry/sentry-javascript/pull/18733)) +- fix(core): Don't record outcomes for failed client reports ([#18808](https://github.com/getsentry/sentry-javascript/pull/18808)) +- fix(deno,cloudflare): Prioritize name from params over name from options ([#18800](https://github.com/getsentry/sentry-javascript/pull/18800)) +- fix(web-vitals): Add error handling for invalid object keys in `WeakMap` ([#18809](https://github.com/getsentry/sentry-javascript/pull/18809)) + +
+ Internal Changes + +- ref(nextjs): Split `withSentryConfig` ([#18777](https://github.com/getsentry/sentry-javascript/pull/18777)) +- test(e2e): Pin @shopify/remix-oxygen to unblock ci ([#18811](https://github.com/getsentry/sentry-javascript/pull/18811)) + +
+ ## 10.33.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/README.md b/dev-packages/browser-integration-tests/README.md index 6d1f69cde973..c5fa72bf6747 100644 --- a/dev-packages/browser-integration-tests/README.md +++ b/dev-packages/browser-integration-tests/README.md @@ -74,8 +74,9 @@ To filter tests by their title: You can refer to [Playwright documentation](https://playwright.dev/docs/test-cli) for other CLI options. -You can set env variable `PW_BUNDLE` to set specific build or bundle to test against. Available options: `esm`, `cjs`, -`bundle`, `bundle_min` +You can set env variable `PW_BUNDLE` to set specific build or bundle to test against. Available options include: `esm`, `cjs`, +`bundle`, `bundle_min`, `bundle_tracing`, `bundle_tracing_logs_metrics`, `bundle_replay`, `bundle_tracing_replay_feedback`, and more. +See `package.json` scripts for the full list of `test:bundle:*` commands. ### Troubleshooting diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 9e178c0d6a91..dab25fa1e7f1 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -23,6 +23,9 @@ "test:bundle:replay:min": "PW_BUNDLE=bundle_replay_min yarn test", "test:bundle:tracing": "PW_BUNDLE=bundle_tracing yarn test", "test:bundle:tracing:min": "PW_BUNDLE=bundle_tracing_min yarn test", + "test:bundle:tracing_logs_metrics": "PW_BUNDLE=bundle_tracing_logs_metrics yarn test", + "test:bundle:tracing_logs_metrics:min": "PW_BUNDLE=bundle_tracing_logs_metrics_min yarn test", + "test:bundle:tracing_logs_metrics:debug_min": "PW_BUNDLE=bundle_tracing_logs_metrics_debug_min yarn test", "test:bundle:full": "PW_BUNDLE=bundle_tracing_replay_feedback yarn test", "test:bundle:full:min": "PW_BUNDLE=bundle_tracing_replay_feedback_min yarn test", "test:cjs": "PW_BUNDLE=cjs yarn test", 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..3045a8676549 --- /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', + 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', + 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', + 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..ad17eef1321d --- /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', + 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', + 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', + 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..57c655e74dde 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,29 +137,44 @@ 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 errorMap: 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 error = errorMap[browserName]; + const [req, pageErrorMessage] = await Promise.all([reqPromise, pageErrorPromise]); + const eventData = envelopeRequestParser(req); /** * This kind of error does show a helpful warning in the console, e.g.: * Fetch API cannot load blub://sentry-test-external.io/invalid-scheme. URL scheme "blub" is not supported. * But it seems we cannot really access this in the SDK :( + * + * Note: On WebKit, invalid URL schemes trigger TWO different errors: + * 1. A synchronous "access control checks" error (captured by pageerror) + * 2. A "Load failed" error from the fetch rejection (which we enhance) + * So we use separate error maps for pageError and sentryError on this test. */ + const pageErrorMap: Record = { + chromium: 'Failed to fetch (sentry-test-external.io)', + webkit: '/sentry-test-external.io/invalid-scheme due to access control checks.', + firefox: 'NetworkError when attempting to fetch resource. (sentry-test-external.io)', + }; + + const sentryErrorMap: 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 pageError = pageErrorMap[browserName]; + const sentryError = sentryErrorMap[browserName]; + expect(pageErrorMessage).toContain(pageError); expect(eventData.exception?.values).toHaveLength(1); expect(eventData.exception?.values?.[0]).toMatchObject({ type: 'TypeError', - value: error, + value: sentryError, mechanism: { handled: false, type: 'auto.browser.global_handlers.onunhandledrejection', diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts index 29d1eabab1b3..40c2d18d29bd 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/integration/test.ts @@ -4,12 +4,12 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser, - testingCdnBundle, + shouldSkipLogsTest, } from '../../../../utils/helpers'; sentryTest('should capture console object calls', async ({ getLocalTestUrl, page }) => { - // Only run this for npm package exports - sentryTest.skip(testingCdnBundle()); + // Only run this for npm package exports and CDN bundles with logs + sentryTest.skip(shouldSkipLogsTest()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts index 5f0f49bf21a9..c02a110046dd 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/scopeAttributes/test.ts @@ -4,11 +4,11 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser, - testingCdnBundle, + shouldSkipLogsTest, } from '../../../../utils/helpers'; sentryTest('captures logs with scope attributes', async ({ getLocalTestUrl, page }) => { - sentryTest.skip(testingCdnBundle()); + sentryTest.skip(shouldSkipLogsTest()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts index 8477ca6b52c8..aa2159d13bc1 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/logger/simple/test.ts @@ -4,12 +4,12 @@ import { sentryTest } from '../../../../utils/fixtures'; import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser, - testingCdnBundle, + shouldSkipLogsTest, } from '../../../../utils/helpers'; sentryTest('should capture all logging methods', async ({ getLocalTestUrl, page }) => { - // Only run this for npm package exports - sentryTest.skip(testingCdnBundle()); + // Only run this for npm package exports and CDN bundles with logs + sentryTest.skip(shouldSkipLogsTest()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts index a89bdea81902..3361bbc50ab7 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/afterCaptureMetric/test.ts @@ -1,11 +1,12 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; +import { shouldSkipMetricsTest } from '../../../../utils/helpers'; sentryTest( 'should emit afterCaptureMetric event with processed metric from beforeSendMetric', async ({ getLocalTestUrl, page }) => { - const bundle = process.env.PW_BUNDLE || ''; - if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { + // Only run this for npm package exports and CDN bundles with metrics + if (shouldSkipMetricsTest()) { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts index 655458c008a1..a983d9fbe728 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/metrics/simple/test.ts @@ -1,11 +1,15 @@ import { expect } from '@playwright/test'; import type { MetricEnvelope } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { getFirstSentryEnvelopeRequest, properFullEnvelopeRequestParser } from '../../../../utils/helpers'; +import { + getFirstSentryEnvelopeRequest, + properFullEnvelopeRequestParser, + shouldSkipMetricsTest, +} from '../../../../utils/helpers'; sentryTest('should capture all metric types', async ({ getLocalTestUrl, page }) => { - const bundle = process.env.PW_BUNDLE || ''; - if (bundle.startsWith('bundle') || bundle.startsWith('loader')) { + // Only run this for npm package exports and CDN bundles with metrics + if (shouldSkipMetricsTest()) { sentryTest.skip(); } diff --git a/dev-packages/browser-integration-tests/utils/generatePlugin.ts b/dev-packages/browser-integration-tests/utils/generatePlugin.ts index b1b1df410ca3..64a82f5f0e62 100644 --- a/dev-packages/browser-integration-tests/utils/generatePlugin.ts +++ b/dev-packages/browser-integration-tests/utils/generatePlugin.ts @@ -56,6 +56,9 @@ const BUNDLE_PATHS: Record> = { bundle_replay_min: 'build/bundles/bundle.replay.min.js', bundle_tracing: 'build/bundles/bundle.tracing.js', bundle_tracing_min: 'build/bundles/bundle.tracing.min.js', + bundle_tracing_logs_metrics: 'build/bundles/bundle.tracing.logs.metrics.js', + bundle_tracing_logs_metrics_min: 'build/bundles/bundle.tracing.logs.metrics.min.js', + bundle_tracing_logs_metrics_debug_min: 'build/bundles/bundle.tracing.logs.metrics.debug.min.js', bundle_tracing_replay: 'build/bundles/bundle.tracing.replay.js', bundle_tracing_replay_min: 'build/bundles/bundle.tracing.replay.min.js', bundle_tracing_replay_feedback: 'build/bundles/bundle.tracing.replay.feedback.js', @@ -245,7 +248,9 @@ class SentryScenarioGenerationPlugin { .replace('loader_', 'bundle_') .replace('_replay', '') .replace('_tracing', '') - .replace('_feedback', ''); + .replace('_feedback', '') + .replace('_logs', '') + .replace('_metrics', ''); // For feedback bundle, make sure to add modal & screenshot integrations if (bundleKey.includes('_feedback')) { diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 0888b3e286b0..6cc5188d3c29 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -314,6 +314,30 @@ export function shouldSkipTracingTest(): boolean { return bundle != null && !bundle.includes('tracing') && !bundle.includes('esm') && !bundle.includes('cjs'); } +/** + * We can only test metrics tests in certain bundles/packages: + * - NPM (ESM, CJS) + * - CDN bundles that contain metrics + * + * @returns `true` if we should skip the metrics test + */ +export function shouldSkipMetricsTest(): boolean { + const bundle = process.env.PW_BUNDLE; + return bundle != null && !bundle.includes('metrics') && !bundle.includes('esm') && !bundle.includes('cjs'); +} + +/** + * We can only test logs tests in certain bundles/packages: + * - NPM (ESM, CJS) + * - CDN bundles that contain logs + * + * @returns `true` if we should skip the logs test + */ +export function shouldSkipLogsTest(): boolean { + const bundle = process.env.PW_BUNDLE; + return bundle != null && !bundle.includes('logs') && !bundle.includes('esm') && !bundle.includes('cjs'); +} + /** * @returns `true` if we are testing a CDN bundle */ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts index e14573254dfb..8616aafadba8 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/connected-servercomponent-trace.test.ts @@ -17,7 +17,6 @@ test('Will create a transaction with spans for every server component and metada expect(spanDescriptions).toContainEqual('render route (app) /nested-layout'); expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/page'); - expect(spanDescriptions).toContainEqual('Page.generateMetadata (/(nested-layout)/nested-layout)'); // Next.js 13 has limited OTEL support for server components, so we don't expect to see the following spans if (!isNext13) { @@ -46,7 +45,6 @@ test('Will create a transaction with spans for every server component and metada expect(spanDescriptions).toContainEqual('render route (app) /nested-layout/[dynamic]'); expect(spanDescriptions).toContainEqual('generateMetadata /(nested-layout)/nested-layout/[dynamic]/page'); - expect(spanDescriptions).toContainEqual('Page.generateMetadata (/(nested-layout)/nested-layout/[dynamic])'); // Next.js 13 has limited OTEL support for server components, so we don't expect to see the following spans if (!isNext13) { diff --git a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json index 40da7f5fb859..1ec7d2833a65 100644 --- a/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-hydrogen/package.json @@ -21,7 +21,7 @@ "@sentry/remix": "latest || *", "@sentry/vite-plugin": "^4.6.1", "@shopify/hydrogen": "2025.4.0", - "@shopify/remix-oxygen": "^2.0.10", + "@shopify/remix-oxygen": "2.0.10", "graphql": "^16.6.0", "graphql-tag": "^2.12.6", "isbot": "^3.8.0", diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts new file mode 100644 index 000000000000..daf81ea97e10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts @@ -0,0 +1,55 @@ +import { createMiddleware } from '@tanstack/react-start'; +import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; + +// Global request middleware - runs on every request +const globalRequestMiddleware = createMiddleware().server(async ({ next }) => { + console.log('Global request middleware executed'); + return next(); +}); + +// Global function middleware - runs on every server function +const globalFunctionMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Global function middleware executed'); + return next(); +}); + +// Server function middleware +const serverFnMiddleware = createMiddleware({ type: 'function' }).server(async ({ next }) => { + console.log('Server function middleware executed'); + return next(); +}); + +// Server route request middleware +const serverRouteRequestMiddleware = createMiddleware().server(async ({ next }) => { + console.log('Server route request middleware executed'); + return next(); +}); + +// Early return middleware - returns without calling next() +const earlyReturnMiddleware = createMiddleware({ type: 'function' }).server(async () => { + console.log('Early return middleware executed - not calling next()'); + return { earlyReturn: true, message: 'Middleware returned early without calling next()' }; +}); + +// Error middleware - throws an exception +const errorMiddleware = createMiddleware({ type: 'function' }).server(async () => { + console.log('Error middleware executed - throwing error'); + throw new Error('Middleware Error Test'); +}); + +// Manually wrap middlewares with Sentry +export const [ + wrappedGlobalRequestMiddleware, + wrappedGlobalFunctionMiddleware, + wrappedServerFnMiddleware, + wrappedServerRouteRequestMiddleware, + wrappedEarlyReturnMiddleware, + wrappedErrorMiddleware, +] = wrapMiddlewaresWithSentry({ + globalRequestMiddleware, + globalFunctionMiddleware, + serverFnMiddleware, + serverRouteRequestMiddleware, + earlyReturnMiddleware, + errorMiddleware, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts new file mode 100644 index 000000000000..1bf3fdb1c5da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { wrappedServerRouteRequestMiddleware } from '../middleware'; + +export const Route = createFileRoute('/api/test-middleware')({ + server: { + middleware: [wrappedServerRouteRequestMiddleware], + handlers: { + GET: async () => { + return { message: 'Server route middleware test' }; + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx new file mode 100644 index 000000000000..83ac81c75a62 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx @@ -0,0 +1,86 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { createServerFn } from '@tanstack/react-start'; +import { wrappedServerFnMiddleware, wrappedEarlyReturnMiddleware, wrappedErrorMiddleware } from '../middleware'; + +// Server function with specific middleware (also gets global function middleware) +const serverFnWithMiddleware = createServerFn() + .middleware([wrappedServerFnMiddleware]) + .handler(async () => { + console.log('Server function with specific middleware executed'); + return { message: 'Server function middleware test' }; + }); + +// Server function without specific middleware (only gets global function middleware) +const serverFnWithoutMiddleware = createServerFn().handler(async () => { + console.log('Server function without specific middleware executed'); + return { message: 'Global middleware only test' }; +}); + +// Server function with early return middleware (middleware returns without calling next) +const serverFnWithEarlyReturnMiddleware = createServerFn() + .middleware([wrappedEarlyReturnMiddleware]) + .handler(async () => { + console.log('This should not be executed - middleware returned early'); + return { message: 'This should not be returned' }; + }); + +// Server function with error middleware (middleware throws an error) +const serverFnWithErrorMiddleware = createServerFn() + .middleware([wrappedErrorMiddleware]) + .handler(async () => { + console.log('This should not be executed - middleware threw error'); + return { message: 'This should not be returned' }; + }); + +export const Route = createFileRoute('/test-middleware')({ + component: TestMiddleware, +}); + +function TestMiddleware() { + return ( +
+

Test Middleware Page

+ + + + +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts new file mode 100644 index 000000000000..eecd2816e492 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts @@ -0,0 +1,9 @@ +import { createStart } from '@tanstack/react-start'; +import { wrappedGlobalRequestMiddleware, wrappedGlobalFunctionMiddleware } from './middleware'; + +export const startInstance = createStart(() => { + return { + requestMiddleware: [wrappedGlobalRequestMiddleware], + functionMiddleware: [wrappedGlobalFunctionMiddleware], + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts new file mode 100644 index 000000000000..824a611bc2ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts @@ -0,0 +1,192 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends spans for multiple middlewares and verifies they are siblings under the same parent span', async ({ + page, +}) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-middleware-btn')).toBeVisible(); + await page.locator('#server-fn-middleware-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Find both middleware spans + const serverFnMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'serverFnMiddleware' && span.origin === 'manual.middleware.tanstackstart', + ); + const globalFunctionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'manual.middleware.tanstackstart', + ); + + // Verify both middleware spans exist with expected properties + expect(serverFnMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'serverFnMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ); + expect(globalFunctionMiddlewareSpan).toEqual( + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ); + + // Both middleware spans should be siblings under the same parent + expect(serverFnMiddlewareSpan?.parent_span_id).toBe(globalFunctionMiddlewareSpan?.parent_span_id); +}); + +test('Sends spans for global function middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-global-only-btn')).toBeVisible(); + await page.locator('#server-fn-global-only-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global function middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalFunctionMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for global request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-middleware' + ); + }); + + await page.goto('/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the global request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'globalRequestMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends spans for server route request middleware', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /api/test-middleware' + ); + }); + + await page.goto('/api/test-middleware'); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the server route request middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'serverRouteRequestMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends span for middleware that returns early without calling next()', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-early-return-btn')).toBeVisible(); + await page.locator('#server-fn-early-return-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the early return middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'earlyReturnMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + status: 'ok', + }), + ]), + ); +}); + +test('Sends span for middleware that throws an error', async ({ page }) => { + const transactionEventPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + !!transactionEvent?.transaction?.startsWith('GET /_serverFn') + ); + }); + + await page.goto('/test-middleware'); + await expect(page.locator('#server-fn-error-btn')).toBeVisible(); + await page.locator('#server-fn-error-btn').click(); + + const transactionEvent = await transactionEventPromise; + + expect(Array.isArray(transactionEvent?.spans)).toBe(true); + + // Check for the error middleware span + expect(transactionEvent?.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'errorMiddleware', + op: 'middleware.tanstackstart', + origin: 'manual.middleware.tanstackstart', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts index d2ebbffb0ec0..3ef96e887bd2 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/transaction.test.ts @@ -84,17 +84,19 @@ test('Sends a server function transaction for a nested server function only if i ]), ); - // Verify that the auto span is the parent of the nested span - const autoSpan = transactionEvent?.spans?.find( - (span: { op?: string; origin?: string }) => - span.op === 'function.tanstackstart' && span.origin === 'auto.function.tanstackstart.server', + // Verify that globalFunctionMiddleware and testNestedLog are sibling spans under the root + const functionMiddlewareSpan = transactionEvent?.spans?.find( + (span: { description?: string; origin?: string }) => + span.description === 'globalFunctionMiddleware' && span.origin === 'manual.middleware.tanstackstart', ); const nestedSpan = transactionEvent?.spans?.find( (span: { description?: string; origin?: string }) => span.description === 'testNestedLog' && span.origin === 'manual', ); - expect(autoSpan).toBeDefined(); + expect(functionMiddlewareSpan).toBeDefined(); expect(nestedSpan).toBeDefined(); - expect(nestedSpan?.parent_span_id).toBe(autoSpan?.span_id); + + // Both spans should be siblings under the same parent (root transaction) + expect(nestedSpan?.parent_span_id).toBe(functionMiddlewareSpan?.parent_span_id); }); diff --git a/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts index 1eda48705b08..ef3e721dc09e 100644 --- a/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts +++ b/packages/browser-utils/src/metrics/web-vitals/lib/initUnique.ts @@ -22,8 +22,16 @@ const instanceMap: WeakMap = new WeakMap(); * identity object was previously used. */ export function initUnique(identityObj: object, ClassObj: new () => T): T { - if (!instanceMap.get(identityObj)) { - instanceMap.set(identityObj, new ClassObj()); + try { + if (!instanceMap.get(identityObj)) { + instanceMap.set(identityObj, new ClassObj()); + } + return instanceMap.get(identityObj)! as T; + } catch (e) { + // --- START Sentry-custom code (try/catch wrapping) --- + // Fix for cases where identityObj is not a valid key for WeakMap (sometimes a problem in Safari) + // Just return a new instance without caching it in instanceMap + return new ClassObj(); } - return instanceMap.get(identityObj)! as T; + // --- END Sentry-custom code --- } diff --git a/packages/browser/rollup.bundle.config.mjs b/packages/browser/rollup.bundle.config.mjs index 57f1bd80b748..684db929b111 100644 --- a/packages/browser/rollup.bundle.config.mjs +++ b/packages/browser/rollup.bundle.config.mjs @@ -104,6 +104,13 @@ const tracingReplayFeedbackBaseBundleConfig = makeBaseBundleConfig({ outputFileBase: () => 'bundles/bundle.tracing.replay.feedback', }); +const tracingLogsMetricsBaseBundleConfig = makeBaseBundleConfig({ + bundleType: 'standalone', + entrypoints: ['src/index.bundle.tracing.logs.metrics.ts'], + licenseTitle: '@sentry/browser (Performance Monitoring, Logs, and Metrics)', + outputFileBase: () => 'bundles/bundle.tracing.logs.metrics', +}); + builds.push( ...makeBundleConfigVariants(baseBundleConfig), ...makeBundleConfigVariants(tracingBaseBundleConfig), @@ -112,6 +119,7 @@ builds.push( ...makeBundleConfigVariants(tracingReplayBaseBundleConfig), ...makeBundleConfigVariants(replayFeedbackBaseBundleConfig), ...makeBundleConfigVariants(tracingReplayFeedbackBaseBundleConfig), + ...makeBundleConfigVariants(tracingLogsMetricsBaseBundleConfig), ); export default builds; 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/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts new file mode 100644 index 000000000000..ce6a65061385 --- /dev/null +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -0,0 +1,35 @@ +import { registerSpanErrorInstrumentation } from '@sentry/core'; +import { feedbackIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; + +registerSpanErrorInstrumentation(); + +export * from './index.bundle.base'; + +// TODO(v11): Export metrics here once we remove it from the base bundle. +export { logger, consoleLoggingIntegration } from '@sentry/core'; + +export { + getActiveSpan, + getRootSpan, + getSpanDescendants, + setMeasurement, + startInactiveSpan, + startNewTrace, + startSpan, + startSpanManual, + withActiveSpan, +} from '@sentry/core'; + +export { + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from './tracing/browserTracingIntegration'; +export { reportPageLoaded } from './tracing/reportPageLoaded'; +export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; + +export { + feedbackIntegrationShim as feedbackAsyncIntegration, + feedbackIntegrationShim as feedbackIntegration, + replayIntegrationShim as replayIntegration, +}; 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/browser/test/index.bundle.tracing.logs.metrics.test.ts b/packages/browser/test/index.bundle.tracing.logs.metrics.test.ts new file mode 100644 index 000000000000..19b3701ebf77 --- /dev/null +++ b/packages/browser/test/index.bundle.tracing.logs.metrics.test.ts @@ -0,0 +1,17 @@ +import { logger as coreLogger, metrics as coreMetrics } from '@sentry/core'; +import { feedbackIntegrationShim, replayIntegrationShim } from '@sentry-internal/integration-shims'; +import { describe, expect, it } from 'vitest'; +import { browserTracingIntegration } from '../src'; +import * as TracingLogsMetricsBundle from '../src/index.bundle.tracing.logs.metrics'; + +describe('index.bundle.tracing.logs.metrics', () => { + it('has correct exports', () => { + expect(TracingLogsMetricsBundle.browserTracingIntegration).toBe(browserTracingIntegration); + expect(TracingLogsMetricsBundle.feedbackAsyncIntegration).toBe(feedbackIntegrationShim); + expect(TracingLogsMetricsBundle.feedbackIntegration).toBe(feedbackIntegrationShim); + expect(TracingLogsMetricsBundle.replayIntegration).toBe(replayIntegrationShim); + + expect(TracingLogsMetricsBundle.logger).toBe(coreLogger); + expect(TracingLogsMetricsBundle.metrics).toBe(coreMetrics); + }); +}); diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index 91686f9cf8c3..a48adf27a23e 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -1,54 +1,12 @@ -import type { BaseTransportOptions, ClientOptions, Options, TracePropagationTargets } from '@sentry/core'; +import type { BaseTransportOptions, ClientOptions, Options } from '@sentry/core'; +import type { OpenTelemetryServerRuntimeOptions } from '@sentry/node-core'; -export interface BaseBunOptions { - /** - * List of strings/regex controlling to which outgoing requests - * the SDK will attach tracing headers. - * - * By default the SDK will attach those headers to all outgoing - * requests. If this option is provided, the SDK will match the - * request URL of outgoing requests against the items in this - * array, and only attach tracing headers if a match was found. - * - * @example - * ```js - * Sentry.init({ - * tracePropagationTargets: ['api.site.com'], - * }); - * ``` - */ - tracePropagationTargets?: TracePropagationTargets; - - /** Sets an optional server name (device name) */ - serverName?: string; - - /** - * If you use Spotlight by Sentry during development, use - * this option to forward captured Sentry events to Spotlight. - * - * Either set it to true, or provide a specific Spotlight Sidecar URL. - * - * More details: https://spotlightjs.com/ - * - * IMPORTANT: Only set this option to `true` while developing, not in production! - */ - spotlight?: boolean | string; - - /** - * If this is set to true, the SDK will not set up OpenTelemetry automatically. - * In this case, you _have_ to ensure to set it up correctly yourself, including: - * * The `SentrySpanProcessor` - * * The `SentryPropagator` - * * The `SentryContextManager` - * * The `SentrySampler` - * - * If you are registering your own OpenTelemetry Loader Hooks (or `import-in-the-middle` hooks), it is also recommended to set the `registerEsmLoaderHooks` option to false. - */ - skipOpenTelemetrySetup?: boolean; - - /** Callback that is executed when a fatal global error occurs. */ - onFatalError?(this: void, error: Error): void; -} +/** + * Base options for the Sentry Bun SDK. + * Extends the common WinterTC options with OpenTelemetry support shared with Node.js and other server-side SDKs. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface BaseBunOptions extends OpenTelemetryServerRuntimeOptions {} /** * Configuration options for the Sentry Bun SDK diff --git a/packages/cloudflare/src/opentelemetry/tracer.ts b/packages/cloudflare/src/opentelemetry/tracer.ts index a180346f7cce..bb83a8550588 100644 --- a/packages/cloudflare/src/opentelemetry/tracer.ts +++ b/packages/cloudflare/src/opentelemetry/tracer.ts @@ -27,8 +27,8 @@ class SentryCloudflareTraceProvider implements TracerProvider { class SentryCloudflareTracer implements Tracer { public startSpan(name: string, options?: SpanOptions): Span { return startInactiveSpan({ - name, ...options, + name, attributes: { ...options?.attributes, 'sentry.cloudflare_tracer': true, @@ -56,8 +56,8 @@ class SentryCloudflareTracer implements Tracer { const opts = (typeof options === 'object' && options !== null ? options : {}) as SpanOptions; const spanOpts = { - name, ...opts, + name, attributes: { ...opts.attributes, 'sentry.cloudflare_tracer': true, diff --git a/packages/cloudflare/test/opentelemetry.test.ts b/packages/cloudflare/test/opentelemetry.test.ts index f918afff90cc..d7c28ca424cd 100644 --- a/packages/cloudflare/test/opentelemetry.test.ts +++ b/packages/cloudflare/test/opentelemetry.test.ts @@ -142,4 +142,59 @@ describe('opentelemetry compatibility', () => { }), ]); }); + + test('name parameter should take precedence over options.name in startSpan', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + + // Pass options with a different name property - the first parameter should take precedence + // This is important for integrations like Prisma that add prefixes to span names + const span = tracer.startSpan('prisma:client:operation', { name: 'operation' } as any); + span.end(); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.transaction).toBe('prisma:client:operation'); + }); + + test('name parameter should take precedence over options.name in startActiveSpan', async () => { + const transactionEvents: TransactionEvent[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }); + + const tracer = trace.getTracer('test'); + + // Pass options with a different name property - the first parameter should take precedence + // This is important for integrations like Prisma that add prefixes to span names + tracer.startActiveSpan('prisma:client:operation', { name: 'operation' } as any, span => { + span.end(); + }); + + await client!.flush(); + + expect(transactionEvents).toHaveLength(1); + const [transactionEvent] = transactionEvents; + + expect(transactionEvent?.transaction).toBe('prisma:client:operation'); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 28495fed10a4..0fdd328a42d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -314,7 +314,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'; @@ -387,7 +393,7 @@ export type { Extra, Extras } from './types-hoist/extra'; export type { Integration, IntegrationFn } from './types-hoist/integration'; export type { Mechanism } from './types-hoist/mechanism'; export type { ExtractedNodeRequestData, HttpHeaderValue, Primitive, WorkerLocation } from './types-hoist/misc'; -export type { ClientOptions, CoreOptions as Options } from './types-hoist/options'; +export type { ClientOptions, CoreOptions as Options, ServerRuntimeOptions } from './types-hoist/options'; export type { Package } from './types-hoist/package'; export type { PolymorphicEvent, PolymorphicRequest } from './types-hoist/polymorphics'; export type { diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts index ef69ba8223e0..590830ab4e20 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/transports/base.ts b/packages/core/src/transports/base.ts index d85d9305bbe9..b0fc1abcb433 100644 --- a/packages/core/src/transports/base.ts +++ b/packages/core/src/transports/base.ts @@ -10,6 +10,7 @@ import type { import { debug } from '../utils/debug-logger'; import { createEnvelope, + envelopeContainsItemType, envelopeItemTypeToDataCategory, forEachEnvelopeItem, serializeEnvelope, @@ -57,6 +58,11 @@ export function createTransport( // Creates client report for each item in an envelope const recordEnvelopeLoss = (reason: EventDropReason): void => { + // Don't record outcomes for client reports - we don't want to create a feedback loop if client reports themselves fail to send + if (envelopeContainsItemType(filteredEnvelope, ['client_report'])) { + DEBUG_BUILD && debug.warn(`Dropping client report. Will not send outcomes (reason: ${reason}).`); + return; + } forEachEnvelopeItem(filteredEnvelope, (item, type) => { options.recordDroppedEvent(reason, envelopeItemTypeToDataCategory(type)); }); diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index ac4ce839ff85..9f8baca5b428 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -11,6 +11,95 @@ import type { StackLineParser, StackParser } from './stacktrace'; import type { TracePropagationTargets } from './tracing'; import type { BaseTransportOptions, Transport } from './transport'; +/** + * Base options for WinterTC-compatible server-side JavaScript runtimes. + * This interface contains common configuration options shared between + * SDKs. + */ +export interface ServerRuntimeOptions { + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + + /** + * Sets an optional server name (device name). + * + * This is useful for identifying which server or instance is sending events. + */ + serverName?: string; + + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; + + /** + * If set to `false`, the SDK will not automatically detect the `serverName`. + * + * This is useful if you are using the SDK in a CLI app or Electron where the + * hostname might be considered PII. + * + * @default true + */ + includeServerName?: boolean; + + /** + * By default, the SDK will try to identify problems with your instrumentation setup and warn you about it. + * If you want to disable these warnings, set this to `true`. + */ + disableInstrumentationWarnings?: boolean; + + /** + * Controls how many milliseconds to wait before shutting down. The default is 2 seconds. Setting this too low can cause + * problems for sending events from command line applications. Setting it too + * high can cause the application to block for users with network connectivity + * problems. + */ + shutdownTimeout?: number; + + /** + * Configures in which interval client reports will be flushed. Defaults to `60_000` (milliseconds). + */ + clientReportFlushInterval?: number; + + /** + * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. + * The SDK will automatically clean up spans that have no finished parent after this duration. + * This is necessary to prevent memory leaks in case of parent spans that are never finished or otherwise dropped/missing. + * However, if you have very long-running spans in your application, a shorter duration might cause spans to be discarded too early. + * In this case, you can increase this duration to a value that fits your expected data. + * + * Defaults to 300 seconds (5 minutes). + */ + maxSpanWaitDuration?: number; + + /** + * Callback that is executed when a fatal global error occurs. + */ + onFatalError?(this: void, error: Error): void; +} + /** * A filter object for ignoring spans. * At least one of the properties (`op` or `name`) must be set. @@ -267,6 +356,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/transports/base.test.ts b/packages/core/test/lib/transports/base.test.ts index ef2220ac1f8b..df11d0fafc29 100644 --- a/packages/core/test/lib/transports/base.test.ts +++ b/packages/core/test/lib/transports/base.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { createTransport } from '../../../src/transports/base'; +import type { ClientReport } from '../../../src/types-hoist/clientreport'; import type { AttachmentItem, EventEnvelope, EventItem } from '../../../src/types-hoist/envelope'; import type { TransportMakeRequestResponse } from '../../../src/types-hoist/transport'; +import { createClientReportEnvelope } from '../../../src/utils/clientreport'; import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; -import type { PromiseBuffer } from '../../../src/utils/promisebuffer'; +import { type PromiseBuffer, SENTRY_BUFFER_FULL_ERROR } from '../../../src/utils/promisebuffer'; import { resolvedSyncPromise } from '../../../src/utils/syncpromise'; const ERROR_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ @@ -31,6 +33,25 @@ const ATTACHMENT_ENVELOPE = createEnvelope( ], ); +const defaultDiscardedEvents: ClientReport['discarded_events'] = [ + { + reason: 'before_send', + category: 'error', + quantity: 30, + }, + { + reason: 'network_error', + category: 'transaction', + quantity: 23, + }, +]; + +const CLIENT_REPORT_ENVELOPE = createClientReportEnvelope( + defaultDiscardedEvents, + 'https://public@dsn.ingest.sentry.io/1337', + 123456, +); + const transportOptions = { recordDroppedEvent: () => undefined, // noop }; @@ -304,5 +325,71 @@ describe('createTransport', () => { expect(recordDroppedEventCallback).not.toHaveBeenCalled(); }); }); + + describe('Client Reports', () => { + it('should not record outcomes when client reports fail to send', async () => { + expect.assertions(2); + + const mockRecordDroppedEventCallback = vi.fn(); + + const transport = createTransport({ recordDroppedEvent: mockRecordDroppedEventCallback }, req => { + expect(req.body).toEqual(serializeEnvelope(CLIENT_REPORT_ENVELOPE)); + return Promise.reject(new Error('Network error')); + }); + + try { + await transport.send(CLIENT_REPORT_ENVELOPE); + } catch (e) { + // Expected to throw + } + + // recordDroppedEvent should NOT be called when a client report fails + expect(mockRecordDroppedEventCallback).not.toHaveBeenCalled(); + }); + + it('should not record outcomes when client reports fail due to buffer overflow', async () => { + expect.assertions(2); + + const mockRecordDroppedEventCallback = vi.fn(); + const mockBuffer: PromiseBuffer = { + $: [], + add: vi.fn(() => Promise.reject(SENTRY_BUFFER_FULL_ERROR)), + drain: vi.fn(), + }; + + const transport = createTransport( + { recordDroppedEvent: mockRecordDroppedEventCallback }, + _ => resolvedSyncPromise({}), + mockBuffer, + ); + + const result = await transport.send(CLIENT_REPORT_ENVELOPE); + + // Should resolve without throwing + expect(result).toEqual({}); + // recordDroppedEvent should NOT be called when a client report fails + expect(mockRecordDroppedEventCallback).not.toHaveBeenCalled(); + }); + + it('should record outcomes when regular events fail to send', async () => { + expect.assertions(2); + + const mockRecordDroppedEventCallback = vi.fn(); + + const transport = createTransport({ recordDroppedEvent: mockRecordDroppedEventCallback }, req => { + expect(req.body).toEqual(serializeEnvelope(ERROR_ENVELOPE)); + return Promise.reject(new Error('Network error')); + }); + + try { + await transport.send(ERROR_ENVELOPE); + } catch (e) { + // Expected to throw + } + + // recordDroppedEvent SHOULD be called for regular events + expect(mockRecordDroppedEventCallback).toHaveBeenCalledWith('network_error', '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)'); + }); + }); }); diff --git a/packages/deno/src/opentelemetry/tracer.ts b/packages/deno/src/opentelemetry/tracer.ts index 801badefa19f..3176616bc04c 100644 --- a/packages/deno/src/opentelemetry/tracer.ts +++ b/packages/deno/src/opentelemetry/tracer.ts @@ -35,8 +35,8 @@ class SentryDenoTracer implements Tracer { const op = this._mapSpanKindToOp(options?.kind); return startInactiveSpan({ - name, ...options, + name, attributes: { ...options?.attributes, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', @@ -69,8 +69,8 @@ class SentryDenoTracer implements Tracer { const op = this._mapSpanKindToOp(opts.kind); const spanOpts = { - name, ...opts, + name, attributes: { ...opts.attributes, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual', diff --git a/packages/deno/test/opentelemetry.test.ts b/packages/deno/test/opentelemetry.test.ts index 2c37ddc48843..30723e033dd4 100644 --- a/packages/deno/test/opentelemetry.test.ts +++ b/packages/deno/test/opentelemetry.test.ts @@ -178,6 +178,66 @@ Deno.test('should be compatible with native Deno OpenTelemetry', async () => { await client.flush(); }); +// Test that name parameter takes precedence over options.name for both startSpan and startActiveSpan +Deno.test('name parameter should take precedence over options.name in startSpan', async () => { + resetSdk(); + const transactionEvents: any[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const tracer = trace.getTracer('test'); + + // Pass options with a different name property - the first parameter should take precedence + // This is important for integrations like Prisma that add prefixes to span names + const span = tracer.startSpan('prisma:client:operation', { name: 'operation' } as any); + span.end(); + + await client.flush(); + + assertEquals(transactionEvents.length, 1); + const [transactionEvent] = transactionEvents; + + // The span name should be 'prisma:client:operation', not 'operation' + assertEquals(transactionEvent?.transaction, 'prisma:client:operation'); +}); + +Deno.test('name parameter should take precedence over options.name in startActiveSpan', async () => { + resetSdk(); + const transactionEvents: any[] = []; + + const client = init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const tracer = trace.getTracer('test'); + + // Pass options with a different name property - the first parameter should take precedence + // This is important for integrations like Prisma that add prefixes to span names + tracer.startActiveSpan('prisma:client:operation', { name: 'operation' } as any, span => { + span.end(); + }); + + await client.flush(); + + assertEquals(transactionEvents.length, 1); + const [transactionEvent] = transactionEvents; + + // The span name should be 'prisma:client:operation', not 'operation' + assertEquals(transactionEvent?.transaction, 'prisma:client:operation'); +}); + Deno.test('should verify native Deno OpenTelemetry works when enabled', async () => { resetSdk(); diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 85969ef1064d..295b06548af4 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -1,133 +1,83 @@ -import type { RequestEventData, WebFetchHeaders } from '@sentry/core'; +import type { RequestEventData } from '@sentry/core'; import { captureException, getActiveSpan, - getCapturedScopesOnSpan, - getRootSpan, + getIsolationScope, handleCallbackErrors, - propagationContextFromHeaders, - Scope, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - setCapturedScopesOnSpan, SPAN_STATUS_ERROR, SPAN_STATUS_OK, - startSpanManual, winterCGHeadersToDict, - withIsolationScope, - withScope, } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; -import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; -import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; +import { flushSafelyWithTimeout, waitUntil } from './utils/responseEnd'; + /** - * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. + * Wraps a generation function (e.g. generateMetadata) with Sentry error instrumentation. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function wrapGenerationFunctionWithSentry any>( generationFunction: F, context: GenerationFunctionContext, ): F { - const { requestAsyncStorage, componentRoute, componentType, generationFunctionIdentifier } = context; return new Proxy(generationFunction, { apply: (originalFunction, thisArg, args) => { - const requestTraceId = getActiveSpan()?.spanContext().traceId; - let headers: WebFetchHeaders | undefined = undefined; - // We try-catch here just in case anything goes wrong with the async storage here goes wrong since it is Next.js internal API + const isolationScope = getIsolationScope(); + + let headers = undefined; + // We try-catch here just in case anything goes wrong with the async storage since it is Next.js internal API try { - headers = requestAsyncStorage?.getStore()?.headers; + headers = context.requestAsyncStorage?.getStore()?.headers; } catch { /** empty */ } - const isolationScope = commonObjectToIsolationScope(headers); - - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const { scope } = getCapturedScopesOnSpan(rootSpan); - setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); - } - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - return withIsolationScope(isolationScope, () => { - return withScope(scope => { - scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); + isolationScope.setSDKProcessingMetadata({ + normalizedRequest: { + headers: headersDict, + } satisfies RequestEventData, + }); - isolationScope.setSDKProcessingMetadata({ - normalizedRequest: { - headers: headersDict, - } satisfies RequestEventData, - }); + return handleCallbackErrors( + () => originalFunction.apply(thisArg, args), + error => { + const span = getActiveSpan(); + const { componentRoute, componentType, generationFunctionIdentifier } = context; + let shouldCapture = true; + isolationScope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); - const activeSpan = getActiveSpan(); - if (activeSpan) { - const rootSpan = getRootSpan(activeSpan); - const sentryTrace = headersDict?.['sentry-trace']; - if (sentryTrace) { - rootSpan.setAttribute(TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL, sentryTrace); + if (span) { + if (isNotFoundNavigationError(error)) { + // We don't want to report "not-found"s + shouldCapture = false; + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); + } else if (isRedirectNavigationError(error)) { + // We don't want to report redirects + shouldCapture = false; + span.setStatus({ code: SPAN_STATUS_OK }); + } else { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } } - const propagationContext = commonObjectToPropagationContext( - headers, - propagationContextFromHeaders(headersDict?.['sentry-trace'], headersDict?.['baggage']), - ); - - if (requestTraceId) { - propagationContext.traceId = requestTraceId; - } - - scope.setPropagationContext(propagationContext); - - return startSpanManual( - { - op: 'function.nextjs', - name: `${componentType}.${generationFunctionIdentifier} (${componentRoute})`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs', - 'sentry.nextjs.ssr.function.type': generationFunctionIdentifier, - 'sentry.nextjs.ssr.function.route': componentRoute, - }, - }, - span => { - return handleCallbackErrors( - () => originalFunction.apply(thisArg, args), - err => { - // When you read this code you might think: "Wait a minute, shouldn't we set the status on the root span too?" - // The answer is: "No." - The status of the root span is determined by whatever status code Next.js decides to put on the response. - if (isNotFoundNavigationError(err)) { - // We don't want to report "not-found"s - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' }); - } else if (isRedirectNavigationError(err)) { - // We don't want to report redirects - span.setStatus({ code: SPAN_STATUS_OK }); - } else { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - getRootSpan(span).setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(err, { - mechanism: { - handled: false, - type: 'auto.function.nextjs.generation_function', - data: { - function: generationFunctionIdentifier, - }, - }, - }); - } - }, - () => { - span.end(); + if (shouldCapture) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.function.nextjs.generation_function', + data: { + function: generationFunctionIdentifier, }, - ); - }, - ); - }); - }); + }, + }); + } + }, + () => { + waitUntil(flushSafelyWithTimeout()); + }, + ); }, }); } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 085e5c874184..cdc6e68f053d 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -491,7 +491,7 @@ export type SentryBuildOptions = { * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. */ ignoredComponents?: string[]; - }; + }; // TODO(v11): remove this option /** * Options to be passed directly to the Sentry Webpack Plugin (`@sentry/webpack-plugin`) that ships with the Sentry Next.js SDK. @@ -500,7 +500,7 @@ export type SentryBuildOptions = { * Please note that this option is unstable and may change in a breaking way in any release. * @deprecated Use `webpack.unstable_sentryWebpackPluginOptions` instead. */ - unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; + unstable_sentryWebpackPluginOptions?: SentryWebpackPluginOptions; // TODO(v11): remove this option /** * Include Next.js-internal code and code from dependencies when uploading source maps. @@ -522,19 +522,19 @@ export type SentryBuildOptions = { * Defaults to `true`. * @deprecated Use `webpack.autoInstrumentServerFunctions` instead. */ - autoInstrumentServerFunctions?: boolean; + autoInstrumentServerFunctions?: boolean; // TODO(v11): remove this option /** * Automatically instrument Next.js middleware with error and performance monitoring. Defaults to `true`. * @deprecated Use `webpack.autoInstrumentMiddleware` instead. */ - autoInstrumentMiddleware?: boolean; + autoInstrumentMiddleware?: boolean; // TODO(v11): remove this option /** * Automatically instrument components in the `app` directory with error monitoring. Defaults to `true`. * @deprecated Use `webpack.autoInstrumentAppDirectory` instead. */ - autoInstrumentAppDirectory?: boolean; + autoInstrumentAppDirectory?: boolean; // TODO(v11): remove this option /** * Exclude certain serverside API routes or pages from being instrumented with Sentry during build-time. This option @@ -567,7 +567,7 @@ export type SentryBuildOptions = { * * @deprecated Use `webpack.treeshake.removeDebugLogging` instead. */ - disableLogger?: boolean; + disableLogger?: boolean; // TODO(v11): remove this option /** * Automatically create cron monitors in Sentry for your Vercel Cron Jobs if configured via `vercel.json`. @@ -576,7 +576,7 @@ export type SentryBuildOptions = { * * @deprecated Use `webpack.automaticVercelMonitors` instead. */ - automaticVercelMonitors?: boolean; + automaticVercelMonitors?: boolean; // TODO(v11): remove this option /** * When an error occurs during release creation or sourcemaps upload, the plugin will call this function. @@ -603,20 +603,59 @@ export type SentryBuildOptions = { /** * Disables automatic injection of the route manifest into the client bundle. * + * @deprecated Use `routeManifestInjection: false` instead. + * + * @default false + */ + disableManifestInjection?: boolean; // TODO(v11): remove this option + + /** + * Options for the route manifest injection feature. + * * The route manifest is a build-time generated mapping of your Next.js App Router * routes that enables Sentry to group transactions by parameterized route names * (e.g., `/users/:id` instead of `/users/123`, `/users/456`, etc.). * - * **Disable this option if:** - * - You want to minimize client bundle size - * - You're experiencing build issues related to route scanning - * - You're using custom routing that the scanner can't detect - * - You prefer raw URLs in transaction names - * - You're only using Pages Router (this feature is only supported in the App Router) + * Set to `false` to disable route manifest injection entirely. * - * @default false + * @example + * ```js + * // Disable route manifest injection + * routeManifestInjection: false + * + * // Exclude specific routes + * routeManifestInjection: { + * exclude: [ + * '/admin', // Exact match + * /^\/internal\//, // Regex: all routes starting with /internal/ + * /\/secret-/, // Regex: any route containing /secret- + * ] + * } + * + * // Exclude using a function + * routeManifestInjection: { + * exclude: (route) => route.includes('hidden') + * } + * ``` */ - disableManifestInjection?: boolean; + routeManifestInjection?: + | false + | { + /** + * Exclude specific routes from the route manifest. + * + * Use this option to prevent certain routes from being included in the client bundle's + * route manifest. This is useful for: + * - Hiding confidential or unreleased feature routes + * - Excluding internal/admin routes you don't want exposed + * - Reducing bundle size by omitting rarely-used routes + * + * Can be specified as: + * - An array of strings (exact match) or RegExp patterns + * - A function that receives a route path and returns `true` to exclude it + */ + exclude?: Array | ((route: string) => boolean); + }; /** * Disables automatic injection of Sentry's Webpack configuration. @@ -630,7 +669,7 @@ export type SentryBuildOptions = { * * @default false */ - disableSentryWebpackConfig?: boolean; + disableSentryWebpackConfig?: boolean; // TODO(v11): remove this option /** * When true (and Next.js >= 15), use the runAfterProductionCompile hook to consolidate sourcemap uploads diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts deleted file mode 100644 index df203edad29e..000000000000 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ /dev/null @@ -1,680 +0,0 @@ -/* eslint-disable max-lines */ -/* eslint-disable complexity */ -import { isThenable, parseSemver } from '@sentry/core'; -import { getSentryRelease } from '@sentry/node'; -import * as childProcess from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { handleRunAfterProductionCompile } from './handleRunAfterProductionCompile'; -import { createRouteManifest } from './manifest/createRouteManifest'; -import type { RouteManifest } from './manifest/types'; -import { constructTurbopackConfig } from './turbopack'; -import type { - ExportedNextConfig as NextConfig, - NextConfigFunction, - NextConfigObject, - SentryBuildOptions, - TurbopackOptions, -} from './types'; -import { - detectActiveBundler, - getNextjsVersion, - requiresInstrumentationHook, - supportsProductionCompileHook, -} from './util'; -import { constructWebpackConfigFunction } from './webpack'; - -let showedExportModeTunnelWarning = false; -let showedExperimentalBuildModeWarning = false; - -// Packages we auto-instrument need to be external for instrumentation to work -// Next.js externalizes some packages by default, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages -// Others we need to add ourselves -// -// NOTE: 'ai' (Vercel AI SDK) is intentionally NOT included in this list. -// When externalized, Next.js doesn't properly handle the package's conditional exports, -// specifically the "react-server" export condition. This causes client-side code to be -// loaded in server components instead of the appropriate server-side functions. -export const DEFAULT_SERVER_EXTERNAL_PACKAGES = [ - 'amqplib', - 'connect', - 'dataloader', - 'express', - 'generic-pool', - 'graphql', - '@hapi/hapi', - 'ioredis', - 'kafkajs', - 'koa', - 'lru-memoizer', - 'mongodb', - 'mongoose', - 'mysql', - 'mysql2', - 'knex', - 'pg', - 'pg-pool', - '@node-redis/client', - '@redis/client', - 'redis', - 'tedious', -]; - -/** - * Modifies the passed in Next.js configuration with automatic build-time instrumentation and source map upload. - * - * @param nextConfig A Next.js configuration object, as usually exported in `next.config.js` or `next.config.mjs`. - * @param sentryBuildOptions Additional options to configure instrumentation and - * @returns The modified config to be exported - */ -export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { - const castNextConfig = (nextConfig as NextConfig) || {}; - if (typeof castNextConfig === 'function') { - return function (this: unknown, ...webpackConfigFunctionArgs: unknown[]): ReturnType { - const maybePromiseNextConfig: ReturnType = castNextConfig.apply( - this, - webpackConfigFunctionArgs, - ); - - if (isThenable(maybePromiseNextConfig)) { - return maybePromiseNextConfig.then(promiseResultNextConfig => { - return getFinalConfigObject(promiseResultNextConfig, sentryBuildOptions); - }); - } - - return getFinalConfigObject(maybePromiseNextConfig, sentryBuildOptions); - } as C; - } else { - return getFinalConfigObject(castNextConfig, sentryBuildOptions) as C; - } -} - -/** - * Generates a random tunnel route path that's less likely to be blocked by ad-blockers - */ -function generateRandomTunnelRoute(): string { - // Generate a random 8-character alphanumeric string - // eslint-disable-next-line @sentry-internal/sdk/no-unsafe-random-apis - const randomString = Math.random().toString(36).substring(2, 10); - return `/${randomString}`; -} - -/** - * Migrates deprecated top-level webpack options to the new `webpack.*` path for backward compatibility. - * The new path takes precedence over deprecated options. This mutates the userSentryOptions object. - */ -function migrateDeprecatedWebpackOptions(userSentryOptions: SentryBuildOptions): void { - // Initialize webpack options if not present - userSentryOptions.webpack = userSentryOptions.webpack || {}; - - const webpack = userSentryOptions.webpack; - - const withDeprecatedFallback = ( - newValue: T | undefined, - deprecatedValue: T | undefined, - message: string, - ): T | undefined => { - if (deprecatedValue !== undefined) { - // eslint-disable-next-line no-console - console.warn(message); - } - - return newValue ?? deprecatedValue; - }; - - const deprecatedMessage = (deprecatedPath: string, newPath: string): string => { - const message = `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; - - // In Turbopack builds, webpack configuration is not applied, so webpack-scoped options won't have any effect. - if (detectActiveBundler() === 'turbopack' && newPath.startsWith('webpack.')) { - return `${message} (Not supported with Turbopack.)`; - } - - return message; - }; - - /* eslint-disable deprecation/deprecation */ - // Migrate each deprecated option to the new path, but only if the new path isn't already set - webpack.autoInstrumentServerFunctions = withDeprecatedFallback( - webpack.autoInstrumentServerFunctions, - userSentryOptions.autoInstrumentServerFunctions, - deprecatedMessage('autoInstrumentServerFunctions', 'webpack.autoInstrumentServerFunctions'), - ); - - webpack.autoInstrumentMiddleware = withDeprecatedFallback( - webpack.autoInstrumentMiddleware, - userSentryOptions.autoInstrumentMiddleware, - deprecatedMessage('autoInstrumentMiddleware', 'webpack.autoInstrumentMiddleware'), - ); - - webpack.autoInstrumentAppDirectory = withDeprecatedFallback( - webpack.autoInstrumentAppDirectory, - userSentryOptions.autoInstrumentAppDirectory, - deprecatedMessage('autoInstrumentAppDirectory', 'webpack.autoInstrumentAppDirectory'), - ); - - webpack.excludeServerRoutes = withDeprecatedFallback( - webpack.excludeServerRoutes, - userSentryOptions.excludeServerRoutes, - deprecatedMessage('excludeServerRoutes', 'webpack.excludeServerRoutes'), - ); - - webpack.unstable_sentryWebpackPluginOptions = withDeprecatedFallback( - webpack.unstable_sentryWebpackPluginOptions, - userSentryOptions.unstable_sentryWebpackPluginOptions, - deprecatedMessage('unstable_sentryWebpackPluginOptions', 'webpack.unstable_sentryWebpackPluginOptions'), - ); - - webpack.disableSentryConfig = withDeprecatedFallback( - webpack.disableSentryConfig, - userSentryOptions.disableSentryWebpackConfig, - deprecatedMessage('disableSentryWebpackConfig', 'webpack.disableSentryConfig'), - ); - - // Handle treeshake.removeDebugLogging specially since it's nested - if (userSentryOptions.disableLogger !== undefined) { - webpack.treeshake = webpack.treeshake || {}; - webpack.treeshake.removeDebugLogging = withDeprecatedFallback( - webpack.treeshake.removeDebugLogging, - userSentryOptions.disableLogger, - deprecatedMessage('disableLogger', 'webpack.treeshake.removeDebugLogging'), - ); - } - - webpack.automaticVercelMonitors = withDeprecatedFallback( - webpack.automaticVercelMonitors, - userSentryOptions.automaticVercelMonitors, - deprecatedMessage('automaticVercelMonitors', 'webpack.automaticVercelMonitors'), - ); - - webpack.reactComponentAnnotation = withDeprecatedFallback( - webpack.reactComponentAnnotation, - userSentryOptions.reactComponentAnnotation, - deprecatedMessage('reactComponentAnnotation', 'webpack.reactComponentAnnotation'), - ); -} - -// Modify the materialized object form of the user's next config by deleting the `sentry` property and wrapping the -// `webpack` property -function getFinalConfigObject( - incomingUserNextConfigObject: NextConfigObject, - userSentryOptions: SentryBuildOptions, -): NextConfigObject { - // Migrate deprecated webpack options to new webpack path for backward compatibility - migrateDeprecatedWebpackOptions(userSentryOptions); - - // Only determine a release name if release creation is not explicitly disabled - // This prevents injection of Git commit hashes that break build determinism - const shouldCreateRelease = userSentryOptions.release?.create !== false; - const releaseName = shouldCreateRelease - ? (userSentryOptions.release?.name ?? getSentryRelease() ?? getGitRevision()) - : userSentryOptions.release?.name; - - if (userSentryOptions?.tunnelRoute) { - if (incomingUserNextConfigObject.output === 'export') { - if (!showedExportModeTunnelWarning) { - showedExportModeTunnelWarning = true; - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] The Sentry Next.js SDK `tunnelRoute` option will not work in combination with Next.js static exports. The `tunnelRoute` option uses server-side features that cannot be accessed in export mode. If you still want to tunnel Sentry events, set up your own tunnel: https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option', - ); - } - } else { - // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); - } - } - - if (process.argv.includes('--experimental-build-mode')) { - if (!showedExperimentalBuildModeWarning) { - showedExperimentalBuildModeWarning = true; - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] The Sentry Next.js SDK does not currently fully support next build --experimental-build-mode', - ); - } - if (process.argv.includes('generate')) { - // Next.js v15.3.0-canary.1 splits the experimental build into two phases: - // 1. compile: Code compilation - // 2. generate: Environment variable inlining and prerendering (We don't instrument this phase, we inline in the compile phase) - // - // We assume a single "full" build and reruns Webpack instrumentation in both phases. - // During the generate step it collides with Next.js's inliner - // producing malformed JS and build failures. - // We skip Sentry processing during generate to avoid this issue. - return incomingUserNextConfigObject; - } - } - - let routeManifest: RouteManifest | undefined; - if (!userSentryOptions.disableManifestInjection) { - routeManifest = createRouteManifest({ - basePath: incomingUserNextConfigObject.basePath, - }); - } - - setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); - - const nextJsVersion = getNextjsVersion(); - - // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). - // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. - if (nextJsVersion) { - const { major, minor } = parseSemver(nextJsVersion); - if (major !== undefined && minor !== undefined && (major >= 15 || (major === 14 && minor >= 3))) { - incomingUserNextConfigObject.experimental = incomingUserNextConfigObject.experimental || {}; - incomingUserNextConfigObject.experimental.clientTraceMetadata = [ - 'baggage', - 'sentry-trace', - ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), - ]; - } - } else { - // eslint-disable-next-line no-console - console.log( - "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.", - ); - } - - // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will - // print a warning when it is set, so we need to conditionally provide it for lower versions. - if (nextJsVersion && requiresInstrumentationHook(nextJsVersion)) { - if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', - ); - } - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; - } else if (!nextJsVersion) { - // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. - if ( - incomingUserNextConfigObject.experimental && - 'instrumentationHook' in incomingUserNextConfigObject.experimental - ) { - if (incomingUserNextConfigObject.experimental.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You set `experimental.instrumentationHook` to `false`. If you are using Next.js version 15 or greater, you can remove that option. If you are using Next.js version 14 or lower, you need to set `experimental.instrumentationHook` in your `next.config.(js|mjs)` to `true` for the SDK to be properly initialized in combination with `instrumentation.(js|ts)`.', - ); - } - } else { - // eslint-disable-next-line no-console - console.log( - "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, Next.js will probably show you a warning about the `experimental.instrumentationHook` being set. To silence Next.js' warning, explicitly set the `experimental.instrumentationHook` option in your `next.config.(js|mjs|ts)` to `undefined`. If you are on Next.js version 14 or lower, you can silence this particular warning by explicitly setting the `experimental.instrumentationHook` option in your `next.config.(js|mjs)` to `true`.", - ); - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; - } - } - - // We wanna check whether the user added a `onRouterTransitionStart` handler to their client instrumentation file. - const instrumentationClientFileContents = getInstrumentationClientFileContents(); - if ( - instrumentationClientFileContents !== undefined && - !instrumentationClientFileContents.includes('onRouterTransitionStart') && - !userSentryOptions.suppressOnRouterTransitionStartWarning - ) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] ACTION REQUIRED: To instrument navigations, the Sentry SDK requires you to export an `onRouterTransitionStart` hook from your `instrumentation-client.(js|ts)` file. You can do so by adding `export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;` to the file.', - ); - } - - let nextMajor: number | undefined; - if (nextJsVersion) { - const { major } = parseSemver(nextJsVersion); - nextMajor = major; - } - - const activeBundler = detectActiveBundler(); - const isTurbopack = activeBundler === 'turbopack'; - const isWebpack = activeBundler === 'webpack'; - const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); - - // Warn if using turbopack with an unsupported Next.js version - if (!isTurbopackSupported && isTurbopack) { - // eslint-disable-next-line no-console - console.warn( - `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, - ); - } - - // Webpack case - warn if trying to use runAfterProductionCompile hook with unsupported Next.js version - if ( - userSentryOptions.useRunAfterProductionCompileHook && - !supportsProductionCompileHook(nextJsVersion ?? '') && - isWebpack - ) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] The configured `useRunAfterProductionCompileHook` option is not compatible with your current Next.js version. This option is only supported on Next.js version 15.4.1 or later. Will not run source map and release management logic.', - ); - } - - let turboPackConfig: TurbopackOptions | undefined; - - if (isTurbopack) { - turboPackConfig = constructTurbopackConfig({ - userNextConfig: incomingUserNextConfigObject, - userSentryOptions, - routeManifest, - nextJsVersion, - }); - } - - // If not explicitly set, turbopack uses the runAfterProductionCompile hook (as there are no alternatives), webpack does not. - const shouldUseRunAfterProductionCompileHook = - userSentryOptions?.useRunAfterProductionCompileHook ?? (isTurbopack ? true : false); - - if (shouldUseRunAfterProductionCompileHook && supportsProductionCompileHook(nextJsVersion ?? '')) { - if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { - incomingUserNextConfigObject.compiler ??= {}; - - incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { - await handleRunAfterProductionCompile( - { - releaseName, - distDir, - buildTool: isTurbopack ? 'turbopack' : 'webpack', - usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, - }, - userSentryOptions, - ); - }; - } else if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') { - incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy( - incomingUserNextConfigObject.compiler.runAfterProductionCompile, - { - async apply(target, thisArg, argArray) { - const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; - await target.apply(thisArg, argArray); - await handleRunAfterProductionCompile( - { - releaseName, - distDir, - buildTool: isTurbopack ? 'turbopack' : 'webpack', - usesNativeDebugIds: isTurbopack ? turboPackConfig?.debugIds : undefined, - }, - userSentryOptions, - ); - }, - }, - ); - } else { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', - ); - } - } - - // Enable source maps for turbopack builds - if (isTurbopackSupported && isTurbopack && !userSentryOptions.sourcemaps?.disable) { - // Only set if not already configured by user - if (incomingUserNextConfigObject.productionBrowserSourceMaps === undefined) { - if (userSentryOptions.debug) { - // eslint-disable-next-line no-console - console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); - } - incomingUserNextConfigObject.productionBrowserSourceMaps = true; - - // Enable source map deletion if not explicitly disabled - if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload === undefined) { - if (userSentryOptions.debug) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', - ); - } - - userSentryOptions.sourcemaps = { - ...userSentryOptions.sourcemaps, - deleteSourcemapsAfterUpload: true, - }; - } - } - } - - return { - ...incomingUserNextConfigObject, - ...(nextMajor && nextMajor >= 15 - ? { - serverExternalPackages: [ - ...(incomingUserNextConfigObject.serverExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], - } - : { - experimental: { - ...incomingUserNextConfigObject.experimental, - serverComponentsExternalPackages: [ - ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), - ...DEFAULT_SERVER_EXTERNAL_PACKAGES, - ], - }, - }), - ...(isWebpack && !userSentryOptions.webpack?.disableSentryConfig - ? { - webpack: constructWebpackConfigFunction({ - userNextConfig: incomingUserNextConfigObject, - userSentryOptions, - releaseName, - routeManifest, - nextJsVersion, - useRunAfterProductionCompileHook: shouldUseRunAfterProductionCompileHook, - }), - } - : {}), - ...(isTurbopackSupported && isTurbopack - ? { - turbopack: turboPackConfig, - } - : {}), - }; -} - -/** - * Injects rewrite rules into the Next.js config provided by the user to tunnel - * requests from the `tunnelPath` to Sentry. - * - * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. - */ -function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { - const originalRewrites = userNextConfig.rewrites; - // Allow overriding the tunnel destination for E2E tests via environment variable - const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; - - // Make sure destinations are statically defined at build time - const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; - const destinationWithRegion = - destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; - - // This function doesn't take any arguments at the time of writing but we future-proof - // here in case Next.js ever decides to pass some - userNextConfig.rewrites = async (...args: unknown[]) => { - const tunnelRouteRewrite = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - ], - destination, - }; - - const tunnelRouteRewriteWithRegion = { - // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` - // Nextjs will automatically convert `source` into a regex for us - source: `${tunnelPath}(/?)`, - has: [ - { - type: 'query', - key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers - value: '(?\\d*)', - }, - { - type: 'query', - key: 'r', // short for region - we keep it short so matching is harder for ad-blockers - value: '(?[a-z]{2})', - }, - ], - destination: destinationWithRegion, - }; - - // Order of these is important, they get applied first to last. - const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; - - if (typeof originalRewrites !== 'function') { - return newRewrites; - } - - // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it - const originalRewritesResult = await originalRewrites(...args); - - if (Array.isArray(originalRewritesResult)) { - return [...newRewrites, ...originalRewritesResult]; - } else { - return { - ...originalRewritesResult, - beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], - }; - } - }; -} - -function setUpBuildTimeVariables( - userNextConfig: NextConfigObject, - userSentryOptions: SentryBuildOptions, - releaseName: string | undefined, -): void { - const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; - const basePath = userNextConfig.basePath ?? ''; - - const rewritesTunnelPath = - userSentryOptions.tunnelRoute !== undefined && - userNextConfig.output !== 'export' && - typeof userSentryOptions.tunnelRoute === 'string' - ? `${basePath}${userSentryOptions.tunnelRoute}` - : undefined; - - const buildTimeVariables: Record = { - // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape - // characters) - _sentryRewriteFramesDistDir: userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next', - // Get the path part of `assetPrefix`, minus any trailing slash. (We use a placeholder for the origin if - // `assetPrefix` doesn't include one. Since we only care about the path, it doesn't matter what it is.) - _sentryRewriteFramesAssetPrefixPath: assetPrefix - ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') - : '', - }; - - if (userNextConfig.assetPrefix) { - buildTimeVariables._assetsPrefix = userNextConfig.assetPrefix; - } - - if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { - buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; - } - - if (rewritesTunnelPath) { - buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; - } - - if (basePath) { - buildTimeVariables._sentryBasePath = basePath; - } - - if (userNextConfig.assetPrefix) { - buildTimeVariables._sentryAssetPrefix = userNextConfig.assetPrefix; - } - - if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { - buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; - } - - if (releaseName) { - buildTimeVariables._sentryRelease = releaseName; - } - - if (typeof userNextConfig.env === 'object') { - userNextConfig.env = { ...buildTimeVariables, ...userNextConfig.env }; - } else if (userNextConfig.env === undefined) { - userNextConfig.env = buildTimeVariables; - } -} - -function getGitRevision(): string | undefined { - let gitRevision: string | undefined; - try { - gitRevision = childProcess - .execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) - .toString() - .trim(); - } catch { - // noop - } - return gitRevision; -} - -function getInstrumentationClientFileContents(): string | void { - const potentialInstrumentationClientFileLocations = [ - ['src', 'instrumentation-client.ts'], - ['src', 'instrumentation-client.js'], - ['instrumentation-client.ts'], - ['instrumentation-client.js'], - ]; - - for (const pathSegments of potentialInstrumentationClientFileLocations) { - try { - return fs.readFileSync(path.join(process.cwd(), ...pathSegments), 'utf-8'); - } catch { - // noop - } - } -} - -/** - * Resolves the tunnel route based on the user's configuration and the environment. - * @param tunnelRoute - The user-provided tunnel route option - */ -function resolveTunnelRoute(tunnelRoute: string | true): string { - if (process.env.__SENTRY_TUNNEL_ROUTE__) { - // Reuse cached value from previous build (server/client) - return process.env.__SENTRY_TUNNEL_ROUTE__; - } - - const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); - - // Cache for subsequent builds (only during build time) - // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. - // env works well here - // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel - if (resolvedTunnelRoute) { - process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; - } - - return resolvedTunnelRoute; -} diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts new file mode 100644 index 000000000000..c468b4a1f18e --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -0,0 +1,114 @@ +import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; + +/** + * Adds Sentry-related build-time variables to `nextConfig.env`. + * + * Note: this mutates `userNextConfig`. + * + * @param userNextConfig - The user's Next.js config object + * @param userSentryOptions - The Sentry build options passed to `withSentryConfig` + * @param releaseName - The resolved release name, if any + */ +export function setUpBuildTimeVariables( + userNextConfig: NextConfigObject, + userSentryOptions: SentryBuildOptions, + releaseName: string | undefined, +): void { + const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; + const basePath = userNextConfig.basePath ?? ''; + + const rewritesTunnelPath = + userSentryOptions.tunnelRoute !== undefined && + userNextConfig.output !== 'export' && + typeof userSentryOptions.tunnelRoute === 'string' + ? `${basePath}${userSentryOptions.tunnelRoute}` + : undefined; + + const buildTimeVariables: Record = { + // Make sure that if we have a windows path, the backslashes are interpreted as such (rather than as escape + // characters) + _sentryRewriteFramesDistDir: userNextConfig.distDir?.replace(/\\/g, '\\\\') || '.next', + // Get the path part of `assetPrefix`, minus any trailing slash. (We use a placeholder for the origin if + // `assetPrefix` doesn't include one. Since we only care about the path, it doesn't matter what it is.) + _sentryRewriteFramesAssetPrefixPath: assetPrefix + ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') + : '', + }; + + if (userNextConfig.assetPrefix) { + buildTimeVariables._assetsPrefix = userNextConfig.assetPrefix; + } + + if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { + buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; + } + + if (rewritesTunnelPath) { + buildTimeVariables._sentryRewritesTunnelPath = rewritesTunnelPath; + } + + if (basePath) { + buildTimeVariables._sentryBasePath = basePath; + } + + if (userNextConfig.assetPrefix) { + buildTimeVariables._sentryAssetPrefix = userNextConfig.assetPrefix; + } + + if (userSentryOptions._experimental?.thirdPartyOriginStackFrames) { + buildTimeVariables._experimentalThirdPartyOriginStackFrames = 'true'; + } + + if (releaseName) { + buildTimeVariables._sentryRelease = releaseName; + } + + if (typeof userNextConfig.env === 'object') { + userNextConfig.env = { ...buildTimeVariables, ...userNextConfig.env }; + } else if (userNextConfig.env === undefined) { + userNextConfig.env = buildTimeVariables; + } +} + +/** + * Returns the current git SHA (HEAD), if available. + * + * This is a best-effort helper and returns `undefined` if git isn't available or the cwd isn't a git repo. + */ +export function getGitRevision(): string | undefined { + let gitRevision: string | undefined; + try { + gitRevision = childProcess + .execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch { + // noop + } + return gitRevision; +} + +/** + * Reads the project's `instrumentation-client.(js|ts)` file contents, if present. + * + * @returns The file contents, or `undefined` if the file can't be found/read + */ +export function getInstrumentationClientFileContents(): string | void { + const potentialInstrumentationClientFileLocations = [ + ['src', 'instrumentation-client.ts'], + ['src', 'instrumentation-client.js'], + ['instrumentation-client.ts'], + ['instrumentation-client.js'], + ]; + + for (const pathSegments of potentialInstrumentationClientFileLocations) { + try { + return fs.readFileSync(path.join(process.cwd(), ...pathSegments), 'utf-8'); + } catch { + // noop + } + } +} diff --git a/packages/nextjs/src/config/withSentryConfig/constants.ts b/packages/nextjs/src/config/withSentryConfig/constants.ts new file mode 100644 index 000000000000..b3a39a96fd9c --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/constants.ts @@ -0,0 +1,32 @@ +// Packages we auto-instrument need to be external for instrumentation to work +// Next.js externalizes some packages by default, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages +// Others we need to add ourselves +// +// NOTE: 'ai' (Vercel AI SDK) is intentionally NOT included in this list. +// When externalized, Next.js doesn't properly handle the package's conditional exports, +// specifically the "react-server" export condition. This causes client-side code to be +// loaded in server components instead of the appropriate server-side functions. +export const DEFAULT_SERVER_EXTERNAL_PACKAGES = [ + 'amqplib', + 'connect', + 'dataloader', + 'express', + 'generic-pool', + 'graphql', + '@hapi/hapi', + 'ioredis', + 'kafkajs', + 'koa', + 'lru-memoizer', + 'mongodb', + 'mongoose', + 'mysql', + 'mysql2', + 'knex', + 'pg', + 'pg-pool', + '@node-redis/client', + '@redis/client', + 'redis', + 'tedious', +]; diff --git a/packages/nextjs/src/config/withSentryConfig/deprecatedWebpackOptions.ts b/packages/nextjs/src/config/withSentryConfig/deprecatedWebpackOptions.ts new file mode 100644 index 000000000000..497475c3d50a --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/deprecatedWebpackOptions.ts @@ -0,0 +1,97 @@ +import type { SentryBuildOptions } from '../types'; +import { detectActiveBundler } from '../util'; + +/** + * Migrates deprecated top-level webpack options to the new `webpack.*` path for backward compatibility. + * The new path takes precedence over deprecated options. This mutates the userSentryOptions object. + */ +export function migrateDeprecatedWebpackOptions(userSentryOptions: SentryBuildOptions): void { + // Initialize webpack options if not present + userSentryOptions.webpack = userSentryOptions.webpack || {}; + + const webpack = userSentryOptions.webpack; + + const withDeprecatedFallback = ( + newValue: T | undefined, + deprecatedValue: T | undefined, + message: string, + ): T | undefined => { + if (deprecatedValue !== undefined) { + // eslint-disable-next-line no-console + console.warn(message); + } + + return newValue ?? deprecatedValue; + }; + + const deprecatedMessage = (deprecatedPath: string, newPath: string): string => { + const message = `[@sentry/nextjs] DEPRECATION WARNING: ${deprecatedPath} is deprecated and will be removed in a future version. Use ${newPath} instead.`; + + // In Turbopack builds, webpack configuration is not applied, so webpack-scoped options won't have any effect. + if (detectActiveBundler() === 'turbopack' && newPath.startsWith('webpack.')) { + return `${message} (Not supported with Turbopack.)`; + } + + return message; + }; + + /* eslint-disable deprecation/deprecation */ + // Migrate each deprecated option to the new path, but only if the new path isn't already set + webpack.autoInstrumentServerFunctions = withDeprecatedFallback( + webpack.autoInstrumentServerFunctions, + userSentryOptions.autoInstrumentServerFunctions, + deprecatedMessage('autoInstrumentServerFunctions', 'webpack.autoInstrumentServerFunctions'), + ); + + webpack.autoInstrumentMiddleware = withDeprecatedFallback( + webpack.autoInstrumentMiddleware, + userSentryOptions.autoInstrumentMiddleware, + deprecatedMessage('autoInstrumentMiddleware', 'webpack.autoInstrumentMiddleware'), + ); + + webpack.autoInstrumentAppDirectory = withDeprecatedFallback( + webpack.autoInstrumentAppDirectory, + userSentryOptions.autoInstrumentAppDirectory, + deprecatedMessage('autoInstrumentAppDirectory', 'webpack.autoInstrumentAppDirectory'), + ); + + webpack.excludeServerRoutes = withDeprecatedFallback( + webpack.excludeServerRoutes, + userSentryOptions.excludeServerRoutes, + deprecatedMessage('excludeServerRoutes', 'webpack.excludeServerRoutes'), + ); + + webpack.unstable_sentryWebpackPluginOptions = withDeprecatedFallback( + webpack.unstable_sentryWebpackPluginOptions, + userSentryOptions.unstable_sentryWebpackPluginOptions, + deprecatedMessage('unstable_sentryWebpackPluginOptions', 'webpack.unstable_sentryWebpackPluginOptions'), + ); + + webpack.disableSentryConfig = withDeprecatedFallback( + webpack.disableSentryConfig, + userSentryOptions.disableSentryWebpackConfig, + deprecatedMessage('disableSentryWebpackConfig', 'webpack.disableSentryConfig'), + ); + + // Handle treeshake.removeDebugLogging specially since it's nested + if (userSentryOptions.disableLogger !== undefined) { + webpack.treeshake = webpack.treeshake || {}; + webpack.treeshake.removeDebugLogging = withDeprecatedFallback( + webpack.treeshake.removeDebugLogging, + userSentryOptions.disableLogger, + deprecatedMessage('disableLogger', 'webpack.treeshake.removeDebugLogging'), + ); + } + + webpack.automaticVercelMonitors = withDeprecatedFallback( + webpack.automaticVercelMonitors, + userSentryOptions.automaticVercelMonitors, + deprecatedMessage('automaticVercelMonitors', 'webpack.automaticVercelMonitors'), + ); + + webpack.reactComponentAnnotation = withDeprecatedFallback( + webpack.reactComponentAnnotation, + userSentryOptions.reactComponentAnnotation, + deprecatedMessage('reactComponentAnnotation', 'webpack.reactComponentAnnotation'), + ); +} diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts new file mode 100644 index 000000000000..ce26241cced4 --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts @@ -0,0 +1,99 @@ +import type { NextConfigObject, SentryBuildOptions } from '../types'; +import { getNextjsVersion } from '../util'; +import { setUpBuildTimeVariables } from './buildTime'; +import { migrateDeprecatedWebpackOptions } from './deprecatedWebpackOptions'; +import { + getBundlerInfo, + getServerExternalPackagesPatch, + getTurbopackPatch, + getWebpackPatch, + maybeConstructTurbopackConfig, + maybeEnableTurbopackSourcemaps, + maybeSetUpRunAfterProductionCompileHook, + maybeWarnAboutUnsupportedRunAfterProductionCompileHook, + maybeWarnAboutUnsupportedTurbopack, + resolveUseRunAfterProductionCompileHookOption, +} from './getFinalConfigObjectBundlerUtils'; +import { + getNextMajor, + maybeCreateRouteManifest, + maybeSetClientTraceMetadataOption, + maybeSetInstrumentationHookOption, + maybeSetUpTunnelRouteRewriteRules, + resolveReleaseName, + shouldReturnEarlyInExperimentalBuildMode, + warnIfMissingOnRouterTransitionStartHook, +} from './getFinalConfigObjectUtils'; + +/** + * Materializes the final Next.js config object with Sentry's build-time integrations applied. + * + * Note: this mutates both `incomingUserNextConfigObject` and `userSentryOptions` (to apply defaults/migrations). + */ +export function getFinalConfigObject( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, +): NextConfigObject { + migrateDeprecatedWebpackOptions(userSentryOptions); + const releaseName = resolveReleaseName(userSentryOptions); + + maybeSetUpTunnelRouteRewriteRules(incomingUserNextConfigObject, userSentryOptions); + + if (shouldReturnEarlyInExperimentalBuildMode()) { + return incomingUserNextConfigObject; + } + + const routeManifest = maybeCreateRouteManifest(incomingUserNextConfigObject, userSentryOptions); + setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); + + const nextJsVersion = getNextjsVersion(); + const nextMajor = getNextMajor(nextJsVersion); + + maybeSetClientTraceMetadataOption(incomingUserNextConfigObject, nextJsVersion); + maybeSetInstrumentationHookOption(incomingUserNextConfigObject, nextJsVersion); + warnIfMissingOnRouterTransitionStartHook(userSentryOptions); + + const bundlerInfo = getBundlerInfo(nextJsVersion); + maybeWarnAboutUnsupportedTurbopack(nextJsVersion, bundlerInfo); + maybeWarnAboutUnsupportedRunAfterProductionCompileHook(nextJsVersion, userSentryOptions, bundlerInfo); + + const turboPackConfig = maybeConstructTurbopackConfig( + incomingUserNextConfigObject, + userSentryOptions, + routeManifest, + nextJsVersion, + bundlerInfo, + ); + + const shouldUseRunAfterProductionCompileHook = resolveUseRunAfterProductionCompileHookOption( + userSentryOptions, + bundlerInfo, + ); + + maybeSetUpRunAfterProductionCompileHook({ + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + nextJsVersion, + bundlerInfo, + turboPackConfig, + shouldUseRunAfterProductionCompileHook, + }); + + maybeEnableTurbopackSourcemaps(incomingUserNextConfigObject, userSentryOptions, bundlerInfo); + + return { + ...incomingUserNextConfigObject, + ...getServerExternalPackagesPatch(incomingUserNextConfigObject, nextMajor), + ...getWebpackPatch({ + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + nextJsVersion, + shouldUseRunAfterProductionCompileHook, + bundlerInfo, + }), + ...getTurbopackPatch(bundlerInfo, turboPackConfig), + }; +} diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts new file mode 100644 index 000000000000..92503e1cbabc --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts @@ -0,0 +1,291 @@ +import { handleRunAfterProductionCompile } from '../handleRunAfterProductionCompile'; +import type { RouteManifest } from '../manifest/types'; +import { constructTurbopackConfig } from '../turbopack'; +import type { NextConfigObject, SentryBuildOptions, TurbopackOptions } from '../types'; +import { detectActiveBundler, supportsProductionCompileHook } from '../util'; +import { constructWebpackConfigFunction } from '../webpack'; +import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from './constants'; + +/** + * Information about the active bundler and feature support based on Next.js version. + */ +export type BundlerInfo = { + isTurbopack: boolean; + isWebpack: boolean; + isTurbopackSupported: boolean; +}; + +/** + * Detects which bundler is active (webpack vs turbopack) and whether turbopack features are supported. + */ +export function getBundlerInfo(nextJsVersion: string | undefined): BundlerInfo { + const activeBundler = detectActiveBundler(); + const isTurbopack = activeBundler === 'turbopack'; + const isWebpack = activeBundler === 'webpack'; + const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); + + return { isTurbopack, isWebpack, isTurbopackSupported }; +} + +/** + * Warns if turbopack is in use but the detected Next.js version is unsupported. + */ +export function maybeWarnAboutUnsupportedTurbopack(nextJsVersion: string | undefined, bundlerInfo: BundlerInfo): void { + // Warn if using turbopack with an unsupported Next.js version + if (!bundlerInfo.isTurbopackSupported && bundlerInfo.isTurbopack) { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] WARNING: You are using the Sentry SDK with Turbopack. The Sentry SDK is compatible with Turbopack on Next.js version 15.4.1 or later. You are currently on ${nextJsVersion}. Please upgrade to a newer Next.js version to use the Sentry SDK with Turbopack.`, + ); + } +} + +/** + * Warns if `useRunAfterProductionCompileHook` is enabled in webpack mode but the Next.js version is unsupported. + */ +export function maybeWarnAboutUnsupportedRunAfterProductionCompileHook( + nextJsVersion: string | undefined, + userSentryOptions: SentryBuildOptions, + bundlerInfo: BundlerInfo, +): void { + // Webpack case - warn if trying to use runAfterProductionCompile hook with unsupported Next.js version + if ( + userSentryOptions.useRunAfterProductionCompileHook && + !supportsProductionCompileHook(nextJsVersion ?? '') && + bundlerInfo.isWebpack + ) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The configured `useRunAfterProductionCompileHook` option is not compatible with your current Next.js version. This option is only supported on Next.js version 15.4.1 or later. Will not run source map and release management logic.', + ); + } +} + +/** + * Constructs turbopack config when turbopack is active. + */ +export function maybeConstructTurbopackConfig( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, + routeManifest: RouteManifest | undefined, + nextJsVersion: string | undefined, + bundlerInfo: BundlerInfo, +): TurbopackOptions | undefined { + if (!bundlerInfo.isTurbopack) { + return undefined; + } + + return constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + userSentryOptions, + routeManifest, + nextJsVersion, + }); +} + +/** + * Resolves whether to use the `runAfterProductionCompile` hook based on options and bundler. + */ +export function resolveUseRunAfterProductionCompileHookOption( + userSentryOptions: SentryBuildOptions, + bundlerInfo: BundlerInfo, +): boolean { + // If not explicitly set, turbopack uses the runAfterProductionCompile hook (as there are no alternatives), webpack does not. + return userSentryOptions.useRunAfterProductionCompileHook ?? (bundlerInfo.isTurbopack ? true : false); +} + +/** + * Hooks into Next.js' `compiler.runAfterProductionCompile` to run Sentry release/sourcemap handling. + * + * Note: this mutates `incomingUserNextConfigObject`. + */ +export function maybeSetUpRunAfterProductionCompileHook({ + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + nextJsVersion, + bundlerInfo, + turboPackConfig, + shouldUseRunAfterProductionCompileHook, +}: { + incomingUserNextConfigObject: NextConfigObject; + userSentryOptions: SentryBuildOptions; + releaseName: string | undefined; + nextJsVersion: string | undefined; + bundlerInfo: BundlerInfo; + turboPackConfig: TurbopackOptions | undefined; + shouldUseRunAfterProductionCompileHook: boolean; +}): void { + if (!shouldUseRunAfterProductionCompileHook) { + return; + } + + if (!supportsProductionCompileHook(nextJsVersion ?? '')) { + return; + } + + if (incomingUserNextConfigObject?.compiler?.runAfterProductionCompile === undefined) { + incomingUserNextConfigObject.compiler ??= {}; + + incomingUserNextConfigObject.compiler.runAfterProductionCompile = async ({ distDir }) => { + await handleRunAfterProductionCompile( + { + releaseName, + distDir, + buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + }, + userSentryOptions, + ); + }; + return; + } + + if (typeof incomingUserNextConfigObject.compiler.runAfterProductionCompile === 'function') { + incomingUserNextConfigObject.compiler.runAfterProductionCompile = new Proxy( + incomingUserNextConfigObject.compiler.runAfterProductionCompile, + { + async apply(target, thisArg, argArray) { + const { distDir }: { distDir: string } = argArray[0] ?? { distDir: '.next' }; + await target.apply(thisArg, argArray); + await handleRunAfterProductionCompile( + { + releaseName, + distDir, + buildTool: bundlerInfo.isTurbopack ? 'turbopack' : 'webpack', + usesNativeDebugIds: bundlerInfo.isTurbopack ? turboPackConfig?.debugIds : undefined, + }, + userSentryOptions, + ); + }, + }, + ); + return; + } + + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The configured `compiler.runAfterProductionCompile` option is not a function. Will not run source map and release management logic.', + ); +} + +/** + * For supported turbopack builds, auto-enables browser sourcemaps and defaults to deleting them after upload. + * + * Note: this mutates both `incomingUserNextConfigObject` and `userSentryOptions`. + */ +export function maybeEnableTurbopackSourcemaps( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, + bundlerInfo: BundlerInfo, +): void { + // Enable source maps for turbopack builds + if (!bundlerInfo.isTurbopackSupported || !bundlerInfo.isTurbopack || userSentryOptions.sourcemaps?.disable) { + return; + } + + // Only set if not already configured by user + if (incomingUserNextConfigObject.productionBrowserSourceMaps !== undefined) { + return; + } + + if (userSentryOptions.debug) { + // eslint-disable-next-line no-console + console.log('[@sentry/nextjs] Automatically enabling browser source map generation for turbopack build.'); + } + incomingUserNextConfigObject.productionBrowserSourceMaps = true; + + // Enable source map deletion if not explicitly disabled + if (userSentryOptions.sourcemaps?.deleteSourcemapsAfterUpload !== undefined) { + return; + } + + if (userSentryOptions.debug) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] Source maps will be automatically deleted after being uploaded to Sentry. If you want to keep the source maps, set the `sourcemaps.deleteSourcemapsAfterUpload` option to false in `withSentryConfig()`. If you do not want to generate and upload sourcemaps at all, set the `sourcemaps.disable` option to true.', + ); + } + + userSentryOptions.sourcemaps = { + ...userSentryOptions.sourcemaps, + deleteSourcemapsAfterUpload: true, + }; +} + +/** + * Returns the patch which ensures server-side auto-instrumented packages are externalized. + */ +export function getServerExternalPackagesPatch( + incomingUserNextConfigObject: NextConfigObject, + nextMajor: number | undefined, +): Partial { + if (nextMajor && nextMajor >= 15) { + return { + serverExternalPackages: [ + ...(incomingUserNextConfigObject.serverExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + }; + } + + return { + experimental: { + ...incomingUserNextConfigObject.experimental, + serverComponentsExternalPackages: [ + ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + }, + }; +} + +/** + * Returns the patch for injecting Sentry's webpack config function (if enabled and applicable). + */ +export function getWebpackPatch({ + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + nextJsVersion, + shouldUseRunAfterProductionCompileHook, + bundlerInfo, +}: { + incomingUserNextConfigObject: NextConfigObject; + userSentryOptions: SentryBuildOptions; + releaseName: string | undefined; + routeManifest: RouteManifest | undefined; + nextJsVersion: string | undefined; + shouldUseRunAfterProductionCompileHook: boolean; + bundlerInfo: BundlerInfo; +}): Partial { + if (!bundlerInfo.isWebpack || userSentryOptions.webpack?.disableSentryConfig) { + return {}; + } + + return { + webpack: constructWebpackConfigFunction({ + userNextConfig: incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + nextJsVersion, + useRunAfterProductionCompileHook: shouldUseRunAfterProductionCompileHook, + }), + }; +} + +/** + * Returns the patch for adding turbopack config (if enabled and supported). + */ +export function getTurbopackPatch( + bundlerInfo: BundlerInfo, + turboPackConfig: TurbopackOptions | undefined, +): Partial { + if (!bundlerInfo.isTurbopackSupported || !bundlerInfo.isTurbopack) { + return {}; + } + + return { turbopack: turboPackConfig }; +} diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts new file mode 100644 index 000000000000..b56fa1894362 --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -0,0 +1,249 @@ +import { isMatchingPattern, parseSemver } from '@sentry/core'; +import { getSentryRelease } from '@sentry/node'; +import { createRouteManifest } from '../manifest/createRouteManifest'; +import type { RouteManifest } from '../manifest/types'; +import type { NextConfigObject, SentryBuildOptions } from '../types'; +import { requiresInstrumentationHook } from '../util'; +import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; +import { resolveTunnelRoute, setUpTunnelRewriteRules } from './tunnel'; + +let showedExportModeTunnelWarning = false; +let showedExperimentalBuildModeWarning = false; + +/** + * Resolves the Sentry release name to use for build-time behavior. + * + * Note: if `release.create === false`, we avoid falling back to git to preserve build determinism. + */ +export function resolveReleaseName(userSentryOptions: SentryBuildOptions): string | undefined { + const shouldCreateRelease = userSentryOptions.release?.create !== false; + return shouldCreateRelease + ? (userSentryOptions.release?.name ?? getSentryRelease() ?? getGitRevision()) + : userSentryOptions.release?.name; +} + +/** + * Applies tunnel-route rewrites, if configured. + * + * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + */ +export function maybeSetUpTunnelRouteRewriteRules( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, +): void { + if (!userSentryOptions.tunnelRoute) { + return; + } + + if (incomingUserNextConfigObject.output === 'export') { + if (!showedExportModeTunnelWarning) { + showedExportModeTunnelWarning = true; + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The Sentry Next.js SDK `tunnelRoute` option will not work in combination with Next.js static exports. The `tunnelRoute` option uses server-side features that cannot be accessed in export mode. If you still want to tunnel Sentry events, set up your own tunnel: https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option', + ); + } + return; + } + + // Update the global options object to use the resolved value everywhere + const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); + userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; + + setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); +} + +/** + * Handles Next's experimental build-mode warning/early return behavior. + * + * @returns `true` if Sentry config processing should be skipped for the current process invocation + */ +export function shouldReturnEarlyInExperimentalBuildMode(): boolean { + if (!process.argv.includes('--experimental-build-mode')) { + return false; + } + + if (!showedExperimentalBuildModeWarning) { + showedExperimentalBuildModeWarning = true; + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The Sentry Next.js SDK does not currently fully support next build --experimental-build-mode', + ); + } + + // Next.js v15.3.0-canary.1 splits the experimental build into two phases: + // 1. compile: Code compilation + // 2. generate: Environment variable inlining and prerendering (We don't instrument this phase, we inline in the compile phase) + // + // We assume a single "full" build and reruns Webpack instrumentation in both phases. + // During the generate step it collides with Next.js's inliner + // producing malformed JS and build failures. + // We skip Sentry processing during generate to avoid this issue. + return process.argv.includes('generate'); +} + +/** + * Creates the route manifest used for client-side route name normalization, unless disabled. + */ +export function maybeCreateRouteManifest( + incomingUserNextConfigObject: NextConfigObject, + userSentryOptions: SentryBuildOptions, +): RouteManifest | undefined { + // Handle deprecated option with warning + // eslint-disable-next-line deprecation/deprecation + if (userSentryOptions.disableManifestInjection) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] The `disableManifestInjection` option is deprecated. Use `routeManifestInjection: false` instead.', + ); + } + + // If explicitly disabled, skip + if (userSentryOptions.routeManifestInjection === false) { + return undefined; + } + + // Still check the deprecated option if the new option is not set + // eslint-disable-next-line deprecation/deprecation + if (userSentryOptions.routeManifestInjection === undefined && userSentryOptions.disableManifestInjection) { + return undefined; + } + + const manifest = createRouteManifest({ + basePath: incomingUserNextConfigObject.basePath, + }); + + // Apply route exclusion filter if configured + const excludeFilter = userSentryOptions.routeManifestInjection?.exclude; + return filterRouteManifest(manifest, excludeFilter); +} + +type ExcludeFilter = ((route: string) => boolean) | (string | RegExp)[] | undefined; + +/** + * Filters routes from the manifest based on the exclude filter. + * (Exported only for testing) + */ +export function filterRouteManifest(manifest: RouteManifest, excludeFilter: ExcludeFilter): RouteManifest { + if (!excludeFilter) { + return manifest; + } + + const shouldExclude = (route: string): boolean => { + if (typeof excludeFilter === 'function') { + return excludeFilter(route); + } + + return excludeFilter.some(pattern => isMatchingPattern(route, pattern)); + }; + + return { + staticRoutes: manifest.staticRoutes.filter(r => !shouldExclude(r.path)), + dynamicRoutes: manifest.dynamicRoutes.filter(r => !shouldExclude(r.path)), + isrRoutes: manifest.isrRoutes.filter(r => !shouldExclude(r)), + }; +} + +/** + * Adds `experimental.clientTraceMetadata` for supported Next.js versions. + */ +export function maybeSetClientTraceMetadataOption( + incomingUserNextConfigObject: NextConfigObject, + nextJsVersion: string | undefined, +): void { + // Add the `clientTraceMetadata` experimental option based on Next.js version. The option got introduced in Next.js version 15.0.0 (actually 14.3.0-canary.64). + // Adding the option on lower versions will cause Next.js to print nasty warnings we wouldn't confront our users with. + if (nextJsVersion) { + const { major, minor } = parseSemver(nextJsVersion); + if (major !== undefined && minor !== undefined && (major >= 15 || (major === 14 && minor >= 3))) { + incomingUserNextConfigObject.experimental = incomingUserNextConfigObject.experimental || {}; + incomingUserNextConfigObject.experimental.clientTraceMetadata = [ + 'baggage', + 'sentry-trace', + ...(incomingUserNextConfigObject.experimental?.clientTraceMetadata || []), + ]; + } + } else { + // eslint-disable-next-line no-console + console.log( + "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, please add `experimental.clientTraceMetadata: ['sentry-trace', 'baggage']` to your Next.js config to enable pageload tracing for App Router.", + ); + } +} + +/** + * Ensures Next.js' `experimental.instrumentationHook` is set for versions which require it. + */ +export function maybeSetInstrumentationHookOption( + incomingUserNextConfigObject: NextConfigObject, + nextJsVersion: string | undefined, +): void { + // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will + // print a warning when it is set, so we need to conditionally provide it for lower versions. + if (nextJsVersion && requiresInstrumentationHook(nextJsVersion)) { + if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', + ); + } + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + return; + } + + if (nextJsVersion) { + return; + } + + // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. + if (incomingUserNextConfigObject.experimental && 'instrumentationHook' in incomingUserNextConfigObject.experimental) { + if (incomingUserNextConfigObject.experimental.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You set `experimental.instrumentationHook` to `false`. If you are using Next.js version 15 or greater, you can remove that option. If you are using Next.js version 14 or lower, you need to set `experimental.instrumentationHook` in your `next.config.(js|mjs)` to `true` for the SDK to be properly initialized in combination with `instrumentation.(js|ts)`.', + ); + } + } else { + // eslint-disable-next-line no-console + console.log( + "[@sentry/nextjs] The Sentry SDK was not able to determine your Next.js version. If you are using Next.js version 15 or greater, Next.js will probably show you a warning about the `experimental.instrumentationHook` being set. To silence Next.js' warning, explicitly set the `experimental.instrumentationHook` option in your `next.config.(js|mjs|ts)` to `undefined`. If you are on Next.js version 14 or lower, you can silence this particular warning by explicitly setting the `experimental.instrumentationHook` option in your `next.config.(js|mjs)` to `true`.", + ); + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + } +} + +/** + * Warns if the project has an `instrumentation-client` file but doesn't export `onRouterTransitionStart`. + */ +export function warnIfMissingOnRouterTransitionStartHook(userSentryOptions: SentryBuildOptions): void { + // We wanna check whether the user added a `onRouterTransitionStart` handler to their client instrumentation file. + const instrumentationClientFileContents = getInstrumentationClientFileContents(); + if ( + instrumentationClientFileContents !== undefined && + !instrumentationClientFileContents.includes('onRouterTransitionStart') && + !userSentryOptions.suppressOnRouterTransitionStartWarning + ) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] ACTION REQUIRED: To instrument navigations, the Sentry SDK requires you to export an `onRouterTransitionStart` hook from your `instrumentation-client.(js|ts)` file. You can do so by adding `export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;` to the file.', + ); + } +} + +/** + * Parses the major Next.js version number from a semver string. + */ +export function getNextMajor(nextJsVersion: string | undefined): number | undefined { + if (!nextJsVersion) { + return undefined; + } + + const { major } = parseSemver(nextJsVersion); + return major; +} diff --git a/packages/nextjs/src/config/withSentryConfig/index.ts b/packages/nextjs/src/config/withSentryConfig/index.ts new file mode 100644 index 000000000000..68a9d8769235 --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/index.ts @@ -0,0 +1,37 @@ +import { isThenable } from '@sentry/core'; +import type { ExportedNextConfig as NextConfig, NextConfigFunction, SentryBuildOptions } from '../types'; +import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from './constants'; +import { getFinalConfigObject } from './getFinalConfigObject'; + +export { DEFAULT_SERVER_EXTERNAL_PACKAGES }; + +/** + * Wraps a user's Next.js config and applies Sentry build-time behavior (instrumentation + sourcemap upload). + * + * Supports both object and function Next.js configs. + * + * @param nextConfig - The user's exported Next.js config + * @param sentryBuildOptions - Options to configure Sentry's build-time behavior + * @returns The wrapped Next.js config (same shape as the input) + */ +export function withSentryConfig(nextConfig?: C, sentryBuildOptions: SentryBuildOptions = {}): C { + const castNextConfig = (nextConfig as NextConfig) || {}; + if (typeof castNextConfig === 'function') { + return function (this: unknown, ...webpackConfigFunctionArgs: unknown[]): ReturnType { + const maybePromiseNextConfig: ReturnType = castNextConfig.apply( + this, + webpackConfigFunctionArgs, + ); + + if (isThenable(maybePromiseNextConfig)) { + return maybePromiseNextConfig.then(promiseResultNextConfig => { + return getFinalConfigObject(promiseResultNextConfig, sentryBuildOptions); + }); + } + + return getFinalConfigObject(maybePromiseNextConfig, sentryBuildOptions); + } as C; + } else { + return getFinalConfigObject(castNextConfig, sentryBuildOptions) as C; + } +} diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts new file mode 100644 index 000000000000..78b050a525bd --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -0,0 +1,117 @@ +import { _INTERNAL_safeMathRandom } from '@sentry/core'; +import type { NextConfigObject } from '../types'; + +/** + * Generates a random tunnel route path that's less likely to be blocked by ad-blockers + */ +function generateRandomTunnelRoute(): string { + // Generate a random 8-character alphanumeric string + const randomString = _INTERNAL_safeMathRandom().toString(36).substring(2, 10); + return `/${randomString}`; +} + +/** + * Resolves the tunnel route based on the user's configuration and the environment. + * @param tunnelRoute - The user-provided tunnel route option + */ +export function resolveTunnelRoute(tunnelRoute: string | true): string { + if (process.env.__SENTRY_TUNNEL_ROUTE__) { + // Reuse cached value from previous build (server/client) + return process.env.__SENTRY_TUNNEL_ROUTE__; + } + + const resolvedTunnelRoute = typeof tunnelRoute === 'string' ? tunnelRoute : generateRandomTunnelRoute(); + + // Cache for subsequent builds (only during build time) + // Turbopack runs the config twice, so we need a shared context to avoid generating a new tunnel route for each build. + // env works well here + // https://linear.app/getsentry/issue/JS-549/adblock-plus-blocking-requests-to-sentry-and-monitoring-tunnel + if (resolvedTunnelRoute) { + process.env.__SENTRY_TUNNEL_ROUTE__ = resolvedTunnelRoute; + } + + return resolvedTunnelRoute; +} + +/** + * Injects rewrite rules into the Next.js config provided by the user to tunnel + * requests from the `tunnelPath` to Sentry. + * + * See https://nextjs.org/docs/api-reference/next.config.js/rewrites. + */ +export function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void { + const originalRewrites = userNextConfig.rewrites; + // Allow overriding the tunnel destination for E2E tests via environment variable + const destinationOverride = process.env._SENTRY_TUNNEL_DESTINATION_OVERRIDE; + + // Make sure destinations are statically defined at build time + const destination = destinationOverride || 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0'; + const destinationWithRegion = + destinationOverride || 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0'; + + // This function doesn't take any arguments at the time of writing but we future-proof + // here in case Next.js ever decides to pass some + userNextConfig.rewrites = async (...args: unknown[]) => { + const tunnelRouteRewrite = { + // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]` + // Nextjs will automatically convert `source` into a regex for us + source: `${tunnelPath}(/?)`, + has: [ + { + type: 'query', + key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers + value: '(?\\d*)', + }, + { + type: 'query', + key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers + value: '(?\\d*)', + }, + ], + destination, + }; + + const tunnelRouteRewriteWithRegion = { + // Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]?r=[region]` + // Nextjs will automatically convert `source` into a regex for us + source: `${tunnelPath}(/?)`, + has: [ + { + type: 'query', + key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers + value: '(?\\d*)', + }, + { + type: 'query', + key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers + value: '(?\\d*)', + }, + { + type: 'query', + key: 'r', // short for region - we keep it short so matching is harder for ad-blockers + value: '(?[a-z]{2})', + }, + ], + destination: destinationWithRegion, + }; + + // Order of these is important, they get applied first to last. + const newRewrites = [tunnelRouteRewriteWithRegion, tunnelRouteRewrite]; + + if (typeof originalRewrites !== 'function') { + return newRewrites; + } + + // @ts-expect-error Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it + const originalRewritesResult = await originalRewrites(...args); + + if (Array.isArray(originalRewritesResult)) { + return [...newRewrites, ...originalRewritesResult]; + } else { + return { + ...originalRewritesResult, + beforeFiles: [...newRewrites, ...(originalRewritesResult.beforeFiles || [])], + }; + } + }; +} diff --git a/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts new file mode 100644 index 000000000000..a22af530b332 --- /dev/null +++ b/packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { filterRouteManifest } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; + +describe('routeManifestInjection.exclude', () => { + const mockManifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/about' }, + { path: '/admin' }, + { path: '/admin/dashboard' }, + { path: '/internal/secret' }, + { path: '/public/page' }, + ], + dynamicRoutes: [ + { path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }, + { path: '/admin/users/:id', regex: '^/admin/users/([^/]+)$', paramNames: ['id'] }, + { path: '/secret-feature/:id', regex: '^/secret-feature/([^/]+)$', paramNames: ['id'] }, + ], + isrRoutes: ['/blog', '/admin/reports', '/internal/stats'], + }; + + describe('with no filter', () => { + it('should return manifest unchanged', () => { + const result = filterRouteManifest(mockManifest, undefined); + expect(result).toEqual(mockManifest); + }); + }); + + describe('with string patterns', () => { + it('should exclude routes containing the string pattern (substring match)', () => { + const result = filterRouteManifest(mockManifest, ['/admin']); + + // All routes containing '/admin' are excluded + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); + expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); + }); + + it('should exclude routes matching multiple string patterns', () => { + const result = filterRouteManifest(mockManifest, ['/about', '/blog']); + + expect(result.staticRoutes.map(r => r.path)).toEqual([ + '/', + '/admin', + '/admin/dashboard', + '/internal/secret', + '/public/page', + ]); + expect(result.isrRoutes).toEqual(['/admin/reports', '/internal/stats']); + }); + + it('should match substrings anywhere in the route', () => { + // 'secret' matches '/internal/secret' and '/secret-feature/:id' + const result = filterRouteManifest(mockManifest, ['secret']); + + expect(result.staticRoutes.map(r => r.path)).toEqual([ + '/', + '/about', + '/admin', + '/admin/dashboard', + '/public/page', + ]); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']); + }); + }); + + describe('with regex patterns', () => { + it('should exclude routes matching regex', () => { + const result = filterRouteManifest(mockManifest, [/^\/admin/]); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); + expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); + }); + + it('should support multiple regex patterns', () => { + const result = filterRouteManifest(mockManifest, [/^\/admin/, /^\/internal/]); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); + expect(result.isrRoutes).toEqual(['/blog']); + }); + + it('should support partial regex matches', () => { + const result = filterRouteManifest(mockManifest, [/secret/]); + + expect(result.staticRoutes.map(r => r.path)).toEqual([ + '/', + '/about', + '/admin', + '/admin/dashboard', + '/public/page', + ]); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/admin/users/:id']); + }); + + it('should handle case-insensitive regex', () => { + const result = filterRouteManifest(mockManifest, [/ADMIN/i]); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); + }); + }); + + describe('with mixed patterns', () => { + it('should support both strings and regex', () => { + const result = filterRouteManifest(mockManifest, ['/about', /^\/admin/]); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/internal/secret', '/public/page']); + }); + }); + + describe('with function filter', () => { + it('should exclude routes where function returns true', () => { + const result = filterRouteManifest(mockManifest, (route: string) => route.includes('admin')); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/internal/secret', '/public/page']); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id', '/secret-feature/:id']); + expect(result.isrRoutes).toEqual(['/blog', '/internal/stats']); + }); + + it('should support complex filter logic', () => { + const result = filterRouteManifest(mockManifest, (route: string) => { + // Exclude anything with "secret" or "internal" or admin routes + return route.includes('secret') || route.includes('internal') || route.startsWith('/admin'); + }); + + expect(result.staticRoutes.map(r => r.path)).toEqual(['/', '/about', '/public/page']); + expect(result.dynamicRoutes.map(r => r.path)).toEqual(['/users/:id']); + expect(result.isrRoutes).toEqual(['/blog']); + }); + }); + + describe('edge cases', () => { + it('should handle empty manifest', () => { + const emptyManifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + isrRoutes: [], + }; + + const result = filterRouteManifest(emptyManifest, [/admin/]); + expect(result).toEqual(emptyManifest); + }); + + it('should handle filter that excludes everything', () => { + const result = filterRouteManifest(mockManifest, () => true); + + expect(result.staticRoutes).toEqual([]); + expect(result.dynamicRoutes).toEqual([]); + expect(result.isrRoutes).toEqual([]); + }); + + it('should handle filter that excludes nothing', () => { + const result = filterRouteManifest(mockManifest, () => false); + expect(result).toEqual(mockManifest); + }); + + it('should handle empty filter array', () => { + const result = filterRouteManifest(mockManifest, []); + expect(result).toEqual(mockManifest); + }); + }); +}); diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 8ab20e9dfd4c..46734aa509e6 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -50,7 +50,7 @@ export { NodeClient } from './sdk/client'; export { cron } from './cron'; export { NODE_VERSION } from './nodeVersion'; -export type { NodeOptions } from './types'; +export type { NodeOptions, OpenTelemetryServerRuntimeOptions } from './types'; export { // This needs exporting so the NodeClient can be used without calling init diff --git a/packages/node-core/src/types.ts b/packages/node-core/src/types.ts index a331b876166d..ee94322089b9 100644 --- a/packages/node-core/src/types.ts +++ b/packages/node-core/src/types.ts @@ -1,28 +1,43 @@ import type { Span as WriteableSpan } from '@opentelemetry/api'; import type { Instrumentation } from '@opentelemetry/instrumentation'; import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; -import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropagationTargets } from '@sentry/core'; +import type { ClientOptions, Options, SamplingContext, Scope, ServerRuntimeOptions, Span } from '@sentry/core'; import type { NodeTransportOptions } from './transports'; -export interface BaseNodeOptions { +/** + * Base options for WinterTC-compatible server-side JavaScript runtimes with OpenTelemetry support. + * This interface extends the base ServerRuntimeOptions from @sentry/core with OpenTelemetry-specific configuration options. + * Used by Node.js, Bun, and other WinterTC-compliant runtime SDKs that support OpenTelemetry instrumentation. + */ +export interface OpenTelemetryServerRuntimeOptions extends ServerRuntimeOptions { /** - * List of strings/regex controlling to which outgoing requests - * the SDK will attach tracing headers. - * - * By default the SDK will attach those headers to all outgoing - * requests. If this option is provided, the SDK will match the - * request URL of outgoing requests against the items in this - * array, and only attach tracing headers if a match was found. + * If this is set to true, the SDK will not set up OpenTelemetry automatically. + * In this case, you _have_ to ensure to set it up correctly yourself, including: + * * The `SentrySpanProcessor` + * * The `SentryPropagator` + * * The `SentryContextManager` + * * The `SentrySampler` + */ + skipOpenTelemetrySetup?: boolean; + + /** + * Provide an array of OpenTelemetry Instrumentations that should be registered. * - * @example - * ```js - * Sentry.init({ - * tracePropagationTargets: ['api.site.com'], - * }); - * ``` + * Use this option if you want to register OpenTelemetry instrumentation that the Sentry SDK does not yet have support for. */ - tracePropagationTargets?: TracePropagationTargets; + openTelemetryInstrumentations?: Instrumentation[]; + + /** + * Provide an array of additional OpenTelemetry SpanProcessors that should be registered. + */ + openTelemetrySpanProcessors?: SpanProcessor[]; +} +/** + * Base options for the Sentry Node SDK. + * Extends the common WinterTC options with OpenTelemetry support shared with Bun and other server-side SDKs. + */ +export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { /** * Sets profiling sample rate when @sentry/profiling-node is installed * @@ -61,19 +76,6 @@ export interface BaseNodeOptions { */ profileLifecycle?: 'manual' | 'trace'; - /** - * If set to `false`, the SDK will not automatically detect the `serverName`. - * - * This is useful if you are using the SDK in a CLI app or Electron where the - * hostname might be considered PII. - * - * @default true - */ - includeServerName?: boolean; - - /** Sets an optional server name (device name) */ - serverName?: string; - /** * Include local variables with stack traces. * @@ -81,41 +83,6 @@ export interface BaseNodeOptions { */ includeLocalVariables?: boolean; - /** - * If you use Spotlight by Sentry during development, use - * this option to forward captured Sentry events to Spotlight. - * - * Either set it to true, or provide a specific Spotlight Sidecar URL. - * - * More details: https://spotlightjs.com/ - * - * IMPORTANT: Only set this option to `true` while developing, not in production! - */ - spotlight?: boolean | string; - - /** - * Provide an array of OpenTelemetry Instrumentations that should be registered. - * - * Use this option if you want to register OpenTelemetry instrumentation that the Sentry SDK does not yet have support for. - */ - openTelemetryInstrumentations?: Instrumentation[]; - - /** - * Provide an array of additional OpenTelemetry SpanProcessors that should be registered. - */ - openTelemetrySpanProcessors?: SpanProcessor[]; - - /** - * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. - * The SDK will automatically clean up spans that have no finished parent after this duration. - * This is necessary to prevent memory leaks in case of parent spans that are never finished or otherwise dropped/missing. - * However, if you have very long-running spans in your application, a shorter duration might cause spans to be discarded too early. - * In this case, you can increase this duration to a value that fits your expected data. - * - * Defaults to 300 seconds (5 minutes). - */ - maxSpanWaitDuration?: number; - /** * Whether to register ESM loader hooks to automatically instrument libraries. * This is necessary to auto instrument libraries that are loaded via ESM imports, but it can cause issues @@ -125,28 +92,6 @@ export interface BaseNodeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; - - /** - * Configures in which interval client reports will be flushed. Defaults to `60_000` (milliseconds). - */ - clientReportFlushInterval?: number; - - /** - * By default, the SDK will try to identify problems with your instrumentation setup and warn you about it. - * If you want to disable these warnings, set this to `true`. - */ - disableInstrumentationWarnings?: boolean; - - /** - * Controls how many milliseconds to wait before shutting down. The default is 2 seconds. Setting this too low can cause - * problems for sending events from command line applications. Setting it too - * high can cause the application to block for users with network connectivity - * problems. - */ - shutdownTimeout?: number; - - /** Callback that is executed when a fatal global error occurs. */ - onFatalError?(this: void, error: Error): void; } /** diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 1f84b69a9f28..3a0cb1e7e5fc 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -1,28 +1,13 @@ import type { Span as WriteableSpan } from '@opentelemetry/api'; -import type { Instrumentation } from '@opentelemetry/instrumentation'; -import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; -import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropagationTargets } from '@sentry/core'; -import type { NodeTransportOptions } from '@sentry/node-core'; - -export interface BaseNodeOptions { - /** - * List of strings/regex controlling to which outgoing requests - * the SDK will attach tracing headers. - * - * By default the SDK will attach those headers to all outgoing - * requests. If this option is provided, the SDK will match the - * request URL of outgoing requests against the items in this - * array, and only attach tracing headers if a match was found. - * - * @example - * ```js - * Sentry.init({ - * tracePropagationTargets: ['api.site.com'], - * }); - * ``` - */ - tracePropagationTargets?: TracePropagationTargets; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import type { ClientOptions, Options, SamplingContext, Scope, Span } from '@sentry/core'; +import type { NodeTransportOptions, OpenTelemetryServerRuntimeOptions } from '@sentry/node-core'; +/** + * Base options for the Sentry Node SDK. + * Extends the common WinterTC options with OpenTelemetry support shared with Bun and other server-side SDKs. + */ +export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { /** * Sets profiling sample rate when @sentry/profiling-node is installed * @@ -64,19 +49,6 @@ export interface BaseNodeOptions { */ profileLifecycle?: 'manual' | 'trace'; - /** - * If set to `false`, the SDK will not automatically detect the `serverName`. - * - * This is useful if you are using the SDK in a CLI app or Electron where the - * hostname might be considered PII. - * - * @default true - */ - includeServerName?: boolean; - - /** Sets an optional server name (device name) */ - serverName?: string; - /** * Include local variables with stack traces. * @@ -84,53 +56,6 @@ export interface BaseNodeOptions { */ includeLocalVariables?: boolean; - /** - * If you use Spotlight by Sentry during development, use - * this option to forward captured Sentry events to Spotlight. - * - * Either set it to true, or provide a specific Spotlight Sidecar URL. - * - * More details: https://spotlightjs.com/ - * - * IMPORTANT: Only set this option to `true` while developing, not in production! - */ - spotlight?: boolean | string; - - /** - * If this is set to true, the SDK will not set up OpenTelemetry automatically. - * In this case, you _have_ to ensure to set it up correctly yourself, including: - * * The `SentrySpanProcessor` - * * The `SentryPropagator` - * * The `SentryContextManager` - * * The `SentrySampler` - * - * If you are registering your own OpenTelemetry Loader Hooks (or `import-in-the-middle` hooks), it is also recommended to set the `registerEsmLoaderHooks` option to false. - */ - skipOpenTelemetrySetup?: boolean; - - /** - * Provide an array of OpenTelemetry Instrumentations that should be registered. - * - * Use this option if you want to register OpenTelemetry instrumentation that the Sentry SDK does not yet have support for. - */ - openTelemetryInstrumentations?: Instrumentation[]; - - /** - * Provide an array of additional OpenTelemetry SpanProcessors that should be registered. - */ - openTelemetrySpanProcessors?: SpanProcessor[]; - - /** - * The max. duration in seconds that the SDK will wait for parent spans to be finished before discarding a span. - * The SDK will automatically clean up spans that have no finished parent after this duration. - * This is necessary to prevent memory leaks in case of parent spans that are never finished or otherwise dropped/missing. - * However, if you have very long-running spans in your application, a shorter duration might cause spans to be discarded too early. - * In this case, you can increase this duration to a value that fits your expected data. - * - * Defaults to 300 seconds (5 minutes). - */ - maxSpanWaitDuration?: number; - /** * Whether to register ESM loader hooks to automatically instrument libraries. * This is necessary to auto instrument libraries that are loaded via ESM imports, but it can cause issues @@ -140,28 +65,6 @@ export interface BaseNodeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; - - /** - * Configures in which interval client reports will be flushed. Defaults to `60_000` (milliseconds). - */ - clientReportFlushInterval?: number; - - /** - * By default, the SDK will try to identify problems with your instrumentation setup and warn you about it. - * If you want to disable these warnings, set this to `true`. - */ - disableInstrumentationWarnings?: boolean; - - /** - * Controls how many milliseconds to wait before shutting down. The default is 2 seconds. Setting this too low can cause - * problems for sending events from command line applications. Setting it too - * high can cause the application to block for users with network connectivity - * problems. - */ - shutdownTimeout?: number; - - /** Callback that is executed when a fatal global error occurs. */ - onFatalError?(this: void, error: Error): void; } /** diff --git a/packages/tanstackstart-react/src/client/index.ts b/packages/tanstackstart-react/src/client/index.ts index 2299b46b7d64..b2b9add0d06b 100644 --- a/packages/tanstackstart-react/src/client/index.ts +++ b/packages/tanstackstart-react/src/client/index.ts @@ -1,6 +1,16 @@ // import/export got a false positive, and affects most of our index barrel files // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ +import type { TanStackMiddlewareBase } from '../common/types'; + export * from '@sentry/react'; export { init } from './sdk'; + +/** + * No-op stub for client-side builds. + * The actual implementation is server-only, but this stub is needed to prevent build errors. + */ +export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { + return Object.values(middlewares); +} diff --git a/packages/tanstackstart-react/src/common/index.ts b/packages/tanstackstart-react/src/common/index.ts index cb0ff5c3b541..0fbc5e41ca34 100644 --- a/packages/tanstackstart-react/src/common/index.ts +++ b/packages/tanstackstart-react/src/common/index.ts @@ -1 +1 @@ -export {}; +export type { TanStackMiddlewareBase, MiddlewareWrapperOptions } from './types'; diff --git a/packages/tanstackstart-react/src/common/types.ts b/packages/tanstackstart-react/src/common/types.ts new file mode 100644 index 000000000000..82e20754cb72 --- /dev/null +++ b/packages/tanstackstart-react/src/common/types.ts @@ -0,0 +1,7 @@ +export type TanStackMiddlewareBase = { + options?: { server?: (...args: unknown[]) => unknown }; +}; + +export type MiddlewareWrapperOptions = { + name: string; +}; diff --git a/packages/tanstackstart-react/src/index.client.ts b/packages/tanstackstart-react/src/index.client.ts index 96c65e2ad4b2..452ac69f9e5a 100644 --- a/packages/tanstackstart-react/src/index.client.ts +++ b/packages/tanstackstart-react/src/index.client.ts @@ -1,6 +1,4 @@ // TODO: For now these are empty re-exports, but we may add actual implementations here // so we keep this to be future proof export * from './client'; -// nothing gets exported yet from there -// eslint-disable-next-line import/export export * from './common'; diff --git a/packages/tanstackstart-react/src/index.types.ts b/packages/tanstackstart-react/src/index.types.ts index cf624f5a1a0b..1ad387ea6a6e 100644 --- a/packages/tanstackstart-react/src/index.types.ts +++ b/packages/tanstackstart-react/src/index.types.ts @@ -34,3 +34,5 @@ export declare const openFeatureIntegration: typeof clientSdk.openFeatureIntegra export declare const OpenFeatureIntegrationHook: typeof clientSdk.OpenFeatureIntegrationHook; export declare const statsigIntegration: typeof clientSdk.statsigIntegration; export declare const unleashIntegration: typeof clientSdk.unleashIntegration; + +export declare const wrapMiddlewaresWithSentry: typeof serverSdk.wrapMiddlewaresWithSentry; diff --git a/packages/tanstackstart-react/src/server/index.ts b/packages/tanstackstart-react/src/server/index.ts index 299f1cd85ea9..5765114cd28b 100644 --- a/packages/tanstackstart-react/src/server/index.ts +++ b/packages/tanstackstart-react/src/server/index.ts @@ -5,6 +5,7 @@ export * from '@sentry/node'; export { init } from './sdk'; export { wrapFetchWithSentry } from './wrapFetchWithSentry'; +export { wrapMiddlewaresWithSentry } from './middleware'; /** * A passthrough error boundary for the server that doesn't depend on any react. Error boundaries don't catch SSR errors diff --git a/packages/tanstackstart-react/src/server/middleware.ts b/packages/tanstackstart-react/src/server/middleware.ts new file mode 100644 index 000000000000..4342af8e1c93 --- /dev/null +++ b/packages/tanstackstart-react/src/server/middleware.ts @@ -0,0 +1,110 @@ +import { addNonEnumerableProperty } from '@sentry/core'; +import type { Span } from '@sentry/node'; +import { getActiveSpan, startSpanManual, withActiveSpan } from '@sentry/node'; +import type { MiddlewareWrapperOptions, TanStackMiddlewareBase } from '../common/types'; +import { getMiddlewareSpanOptions } from './utils'; + +const SENTRY_WRAPPED = '__SENTRY_WRAPPED__'; + +/** + * Creates a proxy for the next function that ends the current span and restores the parent span. + * This ensures that subsequent middleware spans are children of the root span, not nested children. + */ +function getNextProxy unknown>( + next: T, + span: Span, + prevSpan: Span | undefined, + nextState: { called: boolean }, +): T { + return new Proxy(next, { + apply: (originalNext, thisArgNext, argsNext) => { + nextState.called = true; + span.end(); + + if (prevSpan) { + return withActiveSpan(prevSpan, () => { + return Reflect.apply(originalNext, thisArgNext, argsNext); + }); + } + + return Reflect.apply(originalNext, thisArgNext, argsNext); + }, + }); +} + +/** + * Wraps a TanStack Start middleware with Sentry instrumentation to create spans. + */ +function wrapMiddlewareWithSentry( + middleware: T, + options: MiddlewareWrapperOptions, +): T { + if ((middleware as TanStackMiddlewareBase & { [SENTRY_WRAPPED]?: boolean })[SENTRY_WRAPPED]) { + // already instrumented + return middleware; + } + + // instrument server middleware + if (middleware.options?.server) { + middleware.options.server = new Proxy(middleware.options.server, { + apply: (originalServer, thisArgServer, argsServer) => { + const prevSpan = getActiveSpan(); + + return startSpanManual(getMiddlewareSpanOptions(options.name), async (span: Span) => { + const nextState = { called: false }; + + // The server function receives { next, context, request } as first argument + // Users call next() inside their middleware to move down the middleware chain. We proxy next() to end the span when it is called. + const middlewareArgs = argsServer[0] as { next?: (...args: unknown[]) => unknown } | undefined; + if (middlewareArgs && typeof middlewareArgs === 'object' && typeof middlewareArgs.next === 'function') { + middlewareArgs.next = getNextProxy(middlewareArgs.next, span, prevSpan, nextState); + } + + try { + const result = await originalServer.apply(thisArgServer, argsServer); + + // End span here if next() wasn't called, else we already ended it in next() + if (!nextState.called) { + span.end(); + } + + return result; + } catch (e) { + span.end(); + throw e; + } + }); + }, + }); + + // mark as instrumented + addNonEnumerableProperty(middleware as unknown as Record, SENTRY_WRAPPED, true); + } + + return middleware; +} + +/** + * Wraps multiple TanStack Start middlewares with Sentry instrumentation. + * Object keys are used as span names to avoid users having to specify this manually. + * + * @example + * ```ts + * import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; + * + * const wrappedMiddlewares = wrapMiddlewaresWithSentry({ + * authMiddleware, + * loggingMiddleware, + * }); + * + * createServerFn().middleware(wrappedMiddlewares) + * ``` + * + * @param middlewares - An object containing middlewares + * @returns An array of wrapped middlewares + */ +export function wrapMiddlewaresWithSentry(middlewares: Record): T[] { + return Object.entries(middlewares).map(([name, middleware]) => { + return wrapMiddlewareWithSentry(middleware, { name }); + }); +} diff --git a/packages/tanstackstart-react/src/server/utils.ts b/packages/tanstackstart-react/src/server/utils.ts index a3ebbd118910..66cfec542dd3 100644 --- a/packages/tanstackstart-react/src/server/utils.ts +++ b/packages/tanstackstart-react/src/server/utils.ts @@ -1,3 +1,6 @@ +import type { StartSpanOptions } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/node'; + /** * Extracts the SHA-256 hash from a server function pathname. * Server function pathnames are structured as `/_serverFn/`. @@ -10,3 +13,17 @@ export function extractServerFunctionSha256(pathname: string): string { const serverFnMatch = pathname.match(/\/_serverFn\/([a-f0-9]{64})/i); return serverFnMatch?.[1] ?? 'unknown'; } + +/** + * Returns span options for TanStack Start middleware spans. + */ +export function getMiddlewareSpanOptions(name: string): StartSpanOptions { + return { + op: 'middleware.tanstackstart', + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'manual.middleware.tanstackstart', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.tanstackstart', + }, + }; +}