From 0b0b904779b8067b2d70cffae7e73c4bc5b9ed5b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 12 Jan 2026 14:34:10 +0100 Subject: [PATCH 01/12] ref(nextjs): Split `withSentryConfig` (#18777) The function became bloated and unreadable. This PR just splits the function and refactors parts into utils. Closes #18778 (added automatically) --- .../nextjs/src/config/withSentryConfig.ts | 680 ------------------ .../src/config/withSentryConfig/buildTime.ts | 114 +++ .../src/config/withSentryConfig/constants.ts | 32 + .../deprecatedWebpackOptions.ts | 97 +++ .../withSentryConfig/getFinalConfigObject.ts | 99 +++ .../getFinalConfigObjectBundlerUtils.ts | 291 ++++++++ .../getFinalConfigObjectUtils.ts | 203 ++++++ .../src/config/withSentryConfig/index.ts | 37 + .../src/config/withSentryConfig/tunnel.ts | 117 +++ 9 files changed, 990 insertions(+), 680 deletions(-) delete mode 100644 packages/nextjs/src/config/withSentryConfig.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/buildTime.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/constants.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/deprecatedWebpackOptions.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/getFinalConfigObject.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectBundlerUtils.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/index.ts create mode 100644 packages/nextjs/src/config/withSentryConfig/tunnel.ts 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..469d3e02cc4f --- /dev/null +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -0,0 +1,203 @@ +import { 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 { + if (userSentryOptions.disableManifestInjection) { + return undefined; + } + + return createRouteManifest({ + basePath: incomingUserNextConfigObject.basePath, + }); +} + +/** + * 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 || [])], + }; + } + }; +} From 82b9756b889ad6a2ce600186cb22a093142c162f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 12 Jan 2026 14:34:34 +0100 Subject: [PATCH 02/12] feat(nextjs): Remove tracing from generation function template (#18733) closes https://github.com/getsentry/sentry-javascript/issues/18731 --- .../connected-servercomponent-trace.test.ts | 2 - .../wrapGenerationFunctionWithSentry.ts | 146 ++++++------------ 2 files changed, 48 insertions(+), 100 deletions(-) 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/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()); + }, + ); }, }); } From 090d08c284089acf886d675f06cd516b3f6e06be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 12 Jan 2026 15:11:08 +0100 Subject: [PATCH 03/12] feat(core): Add option to enhance the fetch error message (#18466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #18449) (closes [JS-1281](https://linear.app/getsentry/issue/JS-1281/appending-hostname-to-fetch-error-messages-breaks-is-network-error-and)) ## Problem As of now, the user has no chance to disallow the manipulation of fetch errors, as we overwrite the error. This can cause problems as seen in #18449. ## Solution This adds a new option for the SDK (please be very critical about that new option here, since `fetch` has no integration this has to be added as a `init` option). `always` is the default and acts the same as it is now, so it is acting as feature: ```ts enhanceFetchErrorMessages: 'always' | 'report-only' | false` ``` To give the user full control of how the errors are done there are 3 settings: | | always | report-only | false | | ------------------------------------------ | ------- | ----------- | - | manipulate the error message directly | ✅ | ❌ | ❌ | send only the changed message to Sentry | ✅ | ✅ | ❌ ## Special attention to reviewers When having `report-only` the generated logs locally differ from the ones in Sentry. I am not quite sure if that would cause any problems. This is the only question which I don't have the answer to yet ## Alternative In case the size increase is too much, we can also have a boolean that disables that (which is on by default) --------- Co-authored-by: Lukas Stracke --- .size-limit.js | 16 +-- .../errors/fetch-enhance-messages-off/init.js | 8 ++ .../fetch-enhance-messages-off/subject.js | 49 +++++++ .../errors/fetch-enhance-messages-off/test.ts | 113 +++++++++++++++ .../init.js | 8 ++ .../subject.js | 49 +++++++ .../test.ts | 134 ++++++++++++++++++ .../suites/errors/fetch/test.ts | 49 +++++-- packages/browser/src/eventbuilder.ts | 5 +- packages/browser/test/eventbuilder.test.ts | 75 ++++++++++ packages/core/src/index.ts | 8 +- packages/core/src/instrument/fetch.ts | 19 ++- packages/core/src/types-hoist/options.ts | 15 ++ packages/core/src/utils/eventbuilder.ts | 24 +++- .../core/test/lib/utils/eventbuilder.test.ts | 80 ++++++++++- 15 files changed, 625 insertions(+), 27 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/init.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-off/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/init.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/errors/fetch-enhance-messages-report-only/test.ts diff --git a/.size-limit.js b/.size-limit.js index 215a40d1bf17..65496e02a8ae 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,20 @@ 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, Replay)', @@ -234,7 +234,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 +243,7 @@ module.exports = [ import: createImport('init'), ignore: ['$app/stores'], gzip: true, - limit: '42.5 KB', + limit: '43 KB', }, // Node-Core SDK (ESM) { @@ -261,7 +261,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/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/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index cc0be3378b8d..9823d596a502 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -8,6 +8,7 @@ import type { StackParser, } from '@sentry/core'; import { + _INTERNAL_enhanceErrorWithSentryInfo, addExceptionMechanism, addExceptionTypeValue, extractExceptionKeysForMessage, @@ -212,10 +213,10 @@ export function extractMessage(ex: Error & { message: { error?: Error } }): stri } if (message.error && typeof message.error.message === 'string') { - return message.error.message; + return _INTERNAL_enhanceErrorWithSentryInfo(message.error); } - return message; + return _INTERNAL_enhanceErrorWithSentryInfo(ex); } /** diff --git a/packages/browser/test/eventbuilder.test.ts b/packages/browser/test/eventbuilder.test.ts index ef360cb9caac..ef233ed58a1f 100644 --- a/packages/browser/test/eventbuilder.test.ts +++ b/packages/browser/test/eventbuilder.test.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import { addNonEnumerableProperty } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { defaultStackParser } from '../src'; import { eventFromMessage, eventFromUnknownInput, extractMessage, extractType } from '../src/eventbuilder'; @@ -260,3 +261,77 @@ describe('eventFromMessage ', () => { expect(event.exception).toBeUndefined(); }); }); + +describe('__sentry_fetch_url_host__ error enhancement', () => { + it('should enhance error message when __sentry_fetch_url_host__ property is present', () => { + const error = new Error('Failed to fetch'); + // Simulate what fetch instrumentation does + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const message = extractMessage(error); + + expect(message).toBe('Failed to fetch (example.com)'); + }); + + it('should not enhance error message when property is missing', () => { + const error = new Error('Failed to fetch'); + + const message = extractMessage(error); + + expect(message).toBe('Failed to fetch'); + }); + + it('should preserve original error message unchanged', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'api.example.com'); + + // Original error message should still be accessible + expect(error.message).toBe('Failed to fetch'); + + // But Sentry exception should have enhanced message + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (api.example.com)'); + }); + + it.each([ + { message: 'Failed to fetch', host: 'example.com', expected: 'Failed to fetch (example.com)' }, + { message: 'Load failed', host: 'api.test.com', expected: 'Load failed (api.test.com)' }, + { + message: 'NetworkError when attempting to fetch resource.', + host: 'localhost:3000', + expected: 'NetworkError when attempting to fetch resource. (localhost:3000)', + }, + ])('should work with all network error types ($message)', ({ message, host, expected }) => { + const error = new Error(message); + + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', host); + + const enhancedMessage = extractMessage(error); + expect(enhancedMessage).toBe(expected); + }); + + it('should not enhance if property value is not a string', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 123); // Not a string + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch'); + }); + + it('should handle errors with stack traces', () => { + const error = new Error('Failed to fetch'); + error.stack = 'TypeError: Failed to fetch\n at fetch (test.js:1:1)'; + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (example.com)'); + }); + + it('should preserve hostname with port', () => { + const error = new Error('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'localhost:8080'); + + const message = extractMessage(error); + expect(message).toBe('Failed to fetch (localhost:8080)'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 28495fed10a4..25c018af2d8a 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'; 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/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index ac4ce839ff85..91cc653149a5 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -267,6 +267,21 @@ export interface ClientOptions(error: T): string { + // If the error has a __sentry_fetch_url_host__ property (added by fetch instrumentation), + // enhance the error message with the hostname. + if (hasSentryFetchUrlHost(error)) { + return `${error.message} (${error.__sentry_fetch_url_host__})`; + } + + return error.message; +} + /** * Extracts stack frames from the error and builds a Sentry Exception */ export function exceptionFromError(stackParser: StackParser, error: Error): Exception { const exception: Exception = { type: error.name || error.constructor.name, - value: error.message, + value: _enhanceErrorWithSentryInfo(error), }; const frames = parseStackFrames(stackParser, error); diff --git a/packages/core/test/lib/utils/eventbuilder.test.ts b/packages/core/test/lib/utils/eventbuilder.test.ts index 77fa2ff93d96..b882a4562b1c 100644 --- a/packages/core/test/lib/utils/eventbuilder.test.ts +++ b/packages/core/test/lib/utils/eventbuilder.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, test } from 'vitest'; import type { Client } from '../../../src/client'; -import { eventFromMessage, eventFromUnknownInput } from '../../../src/utils/eventbuilder'; +import { eventFromMessage, eventFromUnknownInput, exceptionFromError } from '../../../src/utils/eventbuilder'; import { nodeStackLineParser } from '../../../src/utils/node-stack-trace'; +import { addNonEnumerableProperty } from '../../../src/utils/object'; import { createStackParser } from '../../../src/utils/stacktrace'; const stackParser = createStackParser(nodeStackLineParser()); @@ -214,4 +215,81 @@ describe('eventFromMessage', () => { message: 'Test Message', }); }); + + describe('__sentry_fetch_url_host__ error enhancement', () => { + it('should enhance error message when __sentry_fetch_url_host__ property is present', () => { + const error = new TypeError('Failed to fetch'); + // Simulate what fetch instrumentation does + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const exception = exceptionFromError(stackParser, error); + + expect(exception.value).toBe('Failed to fetch (example.com)'); + expect(exception.type).toBe('TypeError'); + }); + + it('should not enhance error message when property is missing', () => { + const error = new TypeError('Failed to fetch'); + + const exception = exceptionFromError(stackParser, error); + + expect(exception.value).toBe('Failed to fetch'); + expect(exception.type).toBe('TypeError'); + }); + + it('should preserve original error message unchanged', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'api.example.com'); + + // Original error message should still be accessible + expect(error.message).toBe('Failed to fetch'); + + // But Sentry exception should have enhanced message + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (api.example.com)'); + }); + + it.each([ + { message: 'Failed to fetch', host: 'example.com', expected: 'Failed to fetch (example.com)' }, + { message: 'Load failed', host: 'api.test.com', expected: 'Load failed (api.test.com)' }, + { + message: 'NetworkError when attempting to fetch resource.', + host: 'localhost:3000', + expected: 'NetworkError when attempting to fetch resource. (localhost:3000)', + }, + ])('should work with all network error types ($message)', ({ message, host, expected }) => { + const error = new TypeError(message); + + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', host); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe(expected); + }); + + it('should not enhance if property value is not a string', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 123); // Not a string + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch'); + }); + + it('should handle errors with stack traces', () => { + const error = new TypeError('Failed to fetch'); + error.stack = 'TypeError: Failed to fetch\n at fetch (test.js:1:1)'; + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'example.com'); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (example.com)'); + expect(exception.type).toBe('TypeError'); + }); + + it('should preserve hostname with port', () => { + const error = new TypeError('Failed to fetch'); + addNonEnumerableProperty(error, '__sentry_fetch_url_host__', 'localhost:8080'); + + const exception = exceptionFromError(stackParser, error); + expect(exception.value).toBe('Failed to fetch (localhost:8080)'); + }); + }); }); From 83445ebad0878940d07a8947e40ec8fe4b4c715b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 13 Jan 2026 11:38:40 +0100 Subject: [PATCH 04/12] feat(tanstackstart-react): Add wrappers for manual instrumentation of servers-side middlewares (#18680) This PR adds a middleware wrapper to the `tanstackstart` SDK that allows users to add tracing to their application [middleware](https://tanstack.com/start/latest/docs/framework/react/guide/middleware). Eventually we will want to patch this automatically, but that is a bit tricky since it requires build-time magic. This API provides a manual alternative for now and can later still act as a fallback for cases where auto-instrumentation doesn't work. **How it works** The wrapper patches the middleware `options.server` function that gets executed whenever a middleware is run. Each middleware invocation creates a span with: - op: middleware.tanstackstart - origin: manual.middleware.tanstackstart - name: The instrumentation automatically assigns the middleware name based on the variable name assigned to the middleware. At first I had the issue that if multiple middlewares were used they would be nested (i.e. first middleware is parent of second etc.). This is because the middlewares call `next()` to move down the middleware chain, so trivially starting a span for the middleware execution would actually create a span that would last for the current middleware and any middlewares that come after in the middleware chain. I fixed that by also proxying `next()`, where I end the middleware span and then also reattach the middleware spans to the parent request span instead of the previous middleware span. **Usage** ``` import { wrapMiddlewaresWithSentry } from '@sentry/tanstackstart-react'; const [wrappedAuth, wrappedLogging] = wrapMiddlewaresWithSentry({ authMiddleware, loggingMiddleware, }); ``` **Tests** Added E2E tests for: - if multiple middlewares are executed we get spans for both and they are sibling spans (i.e. children of the same parent) - global request middleware - global function middleware - request middleware - middleware that throws an exception - middleware that does not call `next()` Closes https://github.com/getsentry/sentry-javascript/issues/18666 --- CHANGELOG.md | 16 ++ .../tanstackstart-react/src/middleware.ts | 55 +++++ .../src/routes/api.test-middleware.ts | 13 ++ .../src/routes/test-middleware.tsx | 86 ++++++++ .../tanstackstart-react/src/start.ts | 9 + .../tests/middleware.test.ts | 192 ++++++++++++++++++ .../tests/transaction.test.ts | 14 +- .../tanstackstart-react/src/client/index.ts | 10 + .../tanstackstart-react/src/common/index.ts | 2 +- .../tanstackstart-react/src/common/types.ts | 7 + .../tanstackstart-react/src/index.client.ts | 2 - .../tanstackstart-react/src/index.types.ts | 2 + .../tanstackstart-react/src/server/index.ts | 1 + .../src/server/middleware.ts | 110 ++++++++++ .../tanstackstart-react/src/server/utils.ts | 17 ++ 15 files changed, 527 insertions(+), 9 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.test-middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/test-middleware.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/start.ts create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/middleware.test.ts create mode 100644 packages/tanstackstart-react/src/common/types.ts create mode 100644 packages/tanstackstart-react/src/server/middleware.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc2613d9ddc..9caa93dc01d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **feat(tanstackstart-react): Add `wrapMiddlewaresWithSentry` for manual middleware instrumentation** + + 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 }); + ``` + ## 10.33.0 ### Important Changes 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/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', + }, + }; +} From d350c81a9338fcf4c5719f958163b37d897a2de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Jan 2026 17:42:44 +0100 Subject: [PATCH 05/12] feat(core,node-core): Consolidate bun and node types with ServerRuntimeOptions (#18734) closes #18437 closes [JS-1272](https://linear.app/getsentry/issue/JS-1272/extend-bunoptions-with-nodeoptions) This adds a new type `ServerRuntimeOptions` inside `@sentry/core`, a type which can be used for all SDKs without OpenTelemetry suppport. In case OpenTelemetry support is needed `OpenTelemetryServerRuntimeOptions` are exported from `@sentry/node-core`, which extends `ServerRuntimeOptions` with the options which are needed to support OTel. For now we don't have a nice testing strategy for Bun yet, and I didn't want to copy paste all Node integration/e2e tests just for this, I'm still up for suggestions. --- packages/bun/src/types.ts | 58 ++--------- packages/core/src/index.ts | 2 +- packages/core/src/types-hoist/options.ts | 89 +++++++++++++++++ packages/node-core/src/index.ts | 2 +- packages/node-core/src/types.ts | 117 ++++++----------------- packages/node/src/types.ts | 113 ++-------------------- 6 files changed, 138 insertions(+), 243 deletions(-) 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/core/src/index.ts b/packages/core/src/index.ts index 25c018af2d8a..0fdd328a42d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -393,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/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 91cc653149a5..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. 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; } /** From 69034074c4774a5a01e2793235f9ec7ee61e3b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Jan 2026 09:57:51 +0100 Subject: [PATCH 06/12] fix(deno,cloudflare): Prioritize name from params over name from options (#18800) I was playing around with #15466 and saw that the span names for the Prisma integration are different than the ones with actual OTel support, such as `@sentry/node`. Cloudflare (no `prisma:client:` prefix): Screenshot 2026-01-13 at 15 48 41 Express / Node (prefix is there): Screenshot 2026-01-13 at 17 18 05 Within the `@prisma/instrumentation`, which is used for our integration, the name [is added properly](https://github.com/prisma/prisma/blob/d4ec055ee9e13e62351bf72643fc978b3d315ae3/packages/instrumentation/src/ActiveTracingHelper.ts#L83), but the `options` are not updated on purpose, as the source of truth is the `name` itself - OTel also uses the name directly: https://github.com/open-telemetry/opentelemetry-js/blob/87a0b455e5f7f36d9b05b41b6bf11d114dcc854c/packages/opentelemetry-sdk-trace-base/src/Tracer.ts#L149 There is no further explanation in #16714 why [the `name` came before the spreading `options`](https://github.com/getsentry/sentry-javascript/pull/16714/files#diff-595e62985088cbceb347c68deb88b69569b35edee895929d72a7f690ac13ecf7R59). --- With this PR the Prisma integration does work correctly: Screenshot 2026-01-13 at 17 23 06 --- Since the same code was copied over to Deno, I also fixed it there. --- .../cloudflare/src/opentelemetry/tracer.ts | 4 +- .../cloudflare/test/opentelemetry.test.ts | 55 +++++++++++++++++ packages/deno/src/opentelemetry/tracer.ts | 4 +- packages/deno/test/opentelemetry.test.ts | 60 +++++++++++++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) 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/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(); From ca02322475df6e8f2a12ff82550dbff625f07703 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Wed, 14 Jan 2026 14:00:27 +0100 Subject: [PATCH 07/12] test(e2e): Pin @shopify/remix-oxygen to unblock ci (#18811) The `remix-hydrogen` E2E test started failing, pinning for now. Closes #18814 (added automatically) --- .../e2e-tests/test-applications/remix-hydrogen/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From 7a975c96344362b2f88313c693f6434dc96f66c2 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:33:20 +0100 Subject: [PATCH 08/12] fix(web-vitals): Add error handling for invalid object keys in `WeakMap` (#18809) A customer experienced the following issue on iOS Safari 18.6.2: `TypeError: WeakMap keys must be objects or non-registered symbols`. The culprit is probably in web vitals `initUnique` function (which is vendored in). This fix adds a try/catch to handle edge cases where invalid keys are passed to WeakMap, returning a new instance without caching when validation fails. Closes #18810 (added automatically) Co-authored-by: Nicolas Hrubec --- .../src/metrics/web-vitals/lib/initUnique.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 --- } From ad3dd016f9c5dcbbb8dad24f8cb0697648407fe2 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Jan 2026 14:42:19 +0100 Subject: [PATCH 09/12] fix(core): Don't record outcomes for failed client reports (#18808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Sentry changed IPs](https://status.sentry.io/incidents/qmh29yyv8bbv) and this caused problems with client report sending. The current problem is that client report sending is stuck in a loop if the client report itself cannot be sent: `Event fails → Client Report #1 fails → Client Report #2 fails → Client Report #3 fails → ∞` With this fix, failed client reports are not sent anymore to prevent this infinite feedback loop. The offline transport already drops client reports when they cannot be sent: https://github.com/getsentry/sentry-javascript/blob/1b41126666e27a311884cf6f7c1ef915f95477de/packages/core/src/transports/offline.ts#L83-L86 I tested this locally by adding an entry to `/etc/hosts`: `0.0.0.0 o1.ingest.sentry.io` (the example in the issue below) Closes https://github.com/getsentry/sentry-javascript/issues/18802 --- packages/core/src/transports/base.ts | 6 ++ .../core/test/lib/transports/base.test.ts | 89 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) 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/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'); + }); + }); }); }); From 419a0e6e11d44d054aae61c0da7b33b7b3f64cd8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 14 Jan 2026 14:57:54 +0100 Subject: [PATCH 10/12] feat(browser): Add CDN bundle for `tracing.logs.metrics` (#18784) closes https://github.com/getsentry/sentry-javascript/issues/18782 ref https://github.com/getsentry/sentry-javascript/issues/16314 Note that metrics were bundled into the base config and will be moved into this bundle starting with v11 (ref https://github.com/getsentry/sentry-javascript/issues/18583) --- .github/workflows/build.yml | 1 + .size-limit.js | 13 +++++++ .../browser-integration-tests/README.md | 5 +-- .../browser-integration-tests/package.json | 3 ++ .../public-api/logger/integration/test.ts | 6 ++-- .../public-api/logger/scopeAttributes/test.ts | 4 +-- .../suites/public-api/logger/simple/test.ts | 6 ++-- .../metrics/afterCaptureMetric/test.ts | 5 +-- .../suites/public-api/metrics/simple/test.ts | 10 ++++-- .../utils/generatePlugin.ts | 7 +++- .../utils/helpers.ts | 24 +++++++++++++ packages/browser/rollup.bundle.config.mjs | 8 +++++ .../src/index.bundle.tracing.logs.metrics.ts | 35 +++++++++++++++++++ .../index.bundle.tracing.logs.metrics.test.ts | 17 +++++++++ 14 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 packages/browser/src/index.bundle.tracing.logs.metrics.ts create mode 100644 packages/browser/test/index.bundle.tracing.logs.metrics.test.ts 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 65496e02a8ae..a43d4d61e527 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -186,6 +186,12 @@ module.exports = [ gzip: true, 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)', path: createCDNPath('bundle.tracing.replay.min.js'), @@ -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'), 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/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/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/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/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); + }); +}); From 5da93d8de0b25c7fa5e6186b90da815726f57df2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 14 Jan 2026 16:04:55 +0200 Subject: [PATCH 11/12] feat(nextjs): Add routeManifestInjection option to exclude routes from client bundle (#18798) Added a new `routeManifestInjection` configuration option that allows users to exclude specific routes from the route manifest injected into the client bundle. This addresses concerns about sensitive or unreleased route patterns being exposed in the client-side code. This also deprecated `disableManifestInjection` option since it would be possible to have conflicting options present which wouldn't be a great DX. Users can disable it entirely by passing `false`, otherwise they can use an object with an `exclude` property. The property can be an array of string/regex values, or a predicate function. The value typings prevent disabling the manifest and excluding it at the same time, also deprecation annotations and build-time warnings should point users towards the new option. ```ts // Disable route manifest injection entirely withSentryConfig(nextConfig, { routeManifestInjection: false }) // Exclude specific routes withSentryConfig(nextConfig, { routeManifestInjection: { exclude: [ '/admin', // Exact match /^\/internal\//, // Regex: routes starting with /internal/ /\/secret-/, // Regex: routes containing /secret- ] } }) // Exclude using a function withSentryConfig(nextConfig, { routeManifestInjection: { exclude: (route) => route.includes('hidden') } }) ``` closes #18713 --- packages/nextjs/src/config/types.ts | 71 ++++++-- .../getFinalConfigObjectUtils.ts | 50 +++++- .../excludeRoutesFromManifest.test.ts | 164 ++++++++++++++++++ 3 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 packages/nextjs/test/config/manifest/excludeRoutesFromManifest.test.ts 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/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 469d3e02cc4f..b56fa1894362 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -1,4 +1,4 @@ -import { parseSemver } from '@sentry/core'; +import { isMatchingPattern, parseSemver } from '@sentry/core'; import { getSentryRelease } from '@sentry/node'; import { createRouteManifest } from '../manifest/createRouteManifest'; import type { RouteManifest } from '../manifest/types'; @@ -89,13 +89,59 @@ 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; } - return createRouteManifest({ + // 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)), + }; } /** 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); + }); + }); +}); From f9e4fc1f0e29a0e11ea5b7f9931859b366208b08 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 14 Jan 2026 15:29:31 +0100 Subject: [PATCH 12/12] meta(changelog): Update changelog for 10.34.0 --- CHANGELOG.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9caa93dc01d1..aeaf887eded2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -- **feat(tanstackstart-react): Add `wrapMiddlewaresWithSentry` for manual middleware instrumentation** +## 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. @@ -20,6 +32,23 @@ 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