From 6be9e8c66a6d1b2f1cfefb0a404c6da1a9eeb272 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 28 Oct 2026 13:56:42 -0400 Subject: [PATCH 01/18] ref(nextjs): Forward tunnel route requests with core's `handleTunnelRequest` The tunnel route handler now leaves DSN normalization, the allowlist and tracing suppression to core. As in every other SDK, `allowedDsns` adds to the server's own DSN, and the default `POST` accepts the DSNs `serveTunnelIntegration` allows. --- packages/nextjs/src/config/types.ts | 4 +- packages/nextjs/src/tunnel/index.ts | 40 +++-------------- packages/nextjs/test/tunnel/index.test.ts | 52 +++++++++++++---------- 3 files changed, 39 insertions(+), 57 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 7c990a480e51..5c42a4f131e8 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -295,8 +295,8 @@ export type SentryBuildOptions = Omit< * export { POST } from '@sentry/nextjs/tunnel'; * ``` * - * This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use - * `createSentryTunnelRoute` from the same import. + * This handler accepts the DSN of the server SDK, also a self-hosted one, and the DSNs `serveTunnelIntegration` + * allows. For other DSNs, use `createSentryTunnelRoute` from the same import. * * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index d86dd2d0a99d..c55e9cfbfa5c 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,9 +1,9 @@ -import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; +import { handleTunnelRequest } from '@sentry/core'; export interface CreateSentryTunnelRouteOptions { /** - * The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403. - * Defaults to the DSN of the server SDK. + * DSNs whose envelopes the route forwards, in addition to the DSN of the server SDK. It rejects envelopes for other + * DSNs with a 403. Defaults to the DSNs `serveTunnelIntegration` allows. */ allowedDsns?: string[]; } @@ -27,24 +27,10 @@ type TunnelRouteHandler = (request: Request) => Promise; * ``` */ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { - const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined; + const allowedDsns = options.allowedDsns?.length ? options.allowedDsns.map(dsn => dsn.trim()) : undefined; return { - POST: async request => { - // Read on each request, because the client can be set up after this module loads. - const allowedDsns = allowedDsnsFromOptions ?? getServerDsns(); - if (!allowedDsns.length) { - return new Response( - allowedDsnsFromOptions - ? 'Tunnel route has no valid DSN in allowedDsns.' - : 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', - { status: 500 }, - ); - } - - // Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers. - return suppressTracing(() => handleTunnelRequest(request, { allowedDsns })); - }, + POST: request => handleTunnelRequest(request, { allowedDsns }), }; } @@ -52,7 +38,8 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the * request headers (such as cookies). * - * It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`. + * It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. For other DSNs, + * use `createSentryTunnelRoute`. * * @example * ```ts @@ -61,16 +48,3 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * ``` */ export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; - -function getServerDsns(): string[] { - const dsn = getClient()?.getDsn(); - return dsn ? [dsnToString(dsn)] : []; -} - -// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings. -function normalizeDsns(dsns: string[]): string[] { - return dsns.flatMap(dsn => { - const dsnComponents = makeDsn(dsn.trim()); - return dsnComponents ? [dsnToString(dsnComponents)] : []; - }); -} diff --git a/packages/nextjs/test/tunnel/index.test.ts b/packages/nextjs/test/tunnel/index.test.ts index 516543a2b5a9..a6fd021e3417 100644 --- a/packages/nextjs/test/tunnel/index.test.ts +++ b/packages/nextjs/test/tunnel/index.test.ts @@ -1,15 +1,9 @@ -import type * as SentryCore from '@sentry/core'; -import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core'; +import type { Client } from '@sentry/core'; +import { createEnvelope, getCurrentScope, makeDsn, serializeEnvelope } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { serveTunnelIntegration } from '@sentry/core/server'; import { createSentryTunnelRoute, POST } from '../../src/tunnel'; -const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() })); - -vi.mock('@sentry/core', async importOriginal => ({ - ...(await importOriginal()), - getClient: mockGetClient, -})); - const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1'; const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2'; @@ -33,7 +27,8 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra } function useClientDsn(dsn: string | undefined): void { - mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined); + const client = dsn ? ({ getDsn: () => makeDsn(dsn), getOptions: () => ({}) } as unknown as Client) : undefined; + getCurrentScope().setClient(client); } let fetchSpy: ReturnType; @@ -75,9 +70,6 @@ describe('POST', () => { const response = await POST(request); expect(response.status).toBe(500); - await expect(response.text()).resolves.toBe( - 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.', - ); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -91,6 +83,22 @@ describe('POST', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it('accepts the DSNs `serveTunnelIntegration` allows', async () => { + const integration = serveTunnelIntegration({ allowedDsns: [BROWSER_DSN] }); + const options = {}; + const client = { + getDsn: () => makeDsn(SERVER_DSN), + getOptions: () => options, + getIntegrationByName: () => integration, + } as unknown as Client; + integration.setup?.(client); + getCurrentScope().setClient(client); + + const response = await POST(createEnvelopeRequest(BROWSER_DSN).request); + + expect(response.status).toBe(200); + }); + it('does not accept DSNs from environment variables', async () => { useClientDsn(SERVER_DSN); vi.stubEnv('SENTRY_DSN', BROWSER_DSN); @@ -104,16 +112,18 @@ describe('POST', () => { }); describe('createSentryTunnelRoute', () => { - it('accepts only the DSNs in `allowedDsns`', async () => { + it('accepts the DSNs in `allowedDsns` and the server DSN', async () => { useClientDsn(SERVER_DSN); const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] }); - const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); - const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + const browserResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request); + const serverResponse = await handler(createEnvelopeRequest(SERVER_DSN).request); + const otherResponse = await handler(createEnvelopeRequest('https://other@o2.ingest.sentry.io/3').request); - expect(allowedResponse.status).toBe(200); - expect(rejectedResponse.status).toBe(403); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(browserResponse.status).toBe(200); + expect(serverResponse.status).toBe(200); + expect(otherResponse.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(2); }); it.each([ @@ -138,15 +148,13 @@ describe('createSentryTunnelRoute', () => { expect(response.status).toBe(200); }); - it('responds with 500 when no allowed DSN is valid', async () => { + it('responds with 500 when no allowed DSN is valid and the server SDK has no DSN', async () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); - useClientDsn(SERVER_DSN); const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] }); const response = await handler(createEnvelopeRequest(SERVER_DSN).request); expect(response.status).toBe(500); - await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.'); expect(fetchSpy).not.toHaveBeenCalled(); }); }); From a070356335ba52e09703bfb7e4f1e27fc7b16ee5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 10:07:50 -0400 Subject: [PATCH 02/18] feat(nextjs): Serve the browser's managed tunnel under the app's `basePath` `tunnel: true` in the browser now sends to `/`, like the SvelteKit and React Router SDKs do with their base paths. A `basePath` the user set and a `tunnel` URL stay as written. --- packages/nextjs/src/client/index.ts | 19 +++++++++++++-- packages/nextjs/test/clientSdk.test.ts | 32 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 9f1ed4e4f768..2327a5ab4955 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -2,7 +2,13 @@ // can be removed once following issue is fixed: https://github.com/import-js/eslint-plugin-import/issues/703 /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, getGlobalScope, GLOBAL_OBJ } from '@sentry/core'; +import { + _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, + applySdkMetadata, + consoleSandbox, + getGlobalScope, + GLOBAL_OBJ, +} from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react'; import { DEBUG_BUILD } from '../common/debug-build'; @@ -55,6 +61,8 @@ export function init(options: BrowserOptions): Client | undefined { } satisfies BrowserOptions; applyTunnelRouteOption(opts); + // The managed tunnel is served under the app's `basePath`, like every other route. A `basePath` the user set wins. + opts.tunnel = applyTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() }); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); opts.ignoreSpans = [ @@ -89,6 +97,13 @@ export function init(options: BrowserOptions): Client | undefined { return client; } +/** + * The app's `basePath`, which `withSentryConfig` injects. + */ +function getInjectedBasePath(): string | undefined { + return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; +} + function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", @@ -104,7 +119,7 @@ function getDefaultIntegrations(options: BrowserOptions): Integration[] { globalWithInjectedValues._sentryRewriteFramesAssetPrefixPath || ''; const assetPrefix = process.env._sentryAssetPrefix || globalWithInjectedValues._sentryAssetPrefix; - const basePath = process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; + const basePath = getInjectedBasePath(); const experimentalThirdPartyOriginStackFrames = process.env._experimentalThirdPartyOriginStackFrames === 'true' || globalWithInjectedValues._experimentalThirdPartyOriginStackFrames === 'true'; diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 9f16580546c7..a0a2dadc3e01 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; +import { debug, getMainCarrier, getTunnelPath, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { close, getClient, getIsolationScope, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -238,6 +238,36 @@ describe('Client init()', () => { }); }); + describe('managed tunnel', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("serves `tunnel: true` under the app's `basePath`", () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: true }); + + expect(client?.getOptions().tunnel).toBe(`/docs${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `basePath` the user set', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: { basePath: '/shop' } }); + + expect(client?.getOptions().tunnel).toBe(`/shop${getTunnelPath(TEST_DSN)}`); + }); + + it('keeps a `tunnel` URL as written', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + + const client = init({ dsn: TEST_DSN, tunnel: '/monitoring' }); + + expect(client?.getOptions().tunnel).toBe('/monitoring'); + }); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); From 2e193f618c1b5c75367674c3fc83979a8ba39207 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 29 Oct 2026 10:14:00 -0400 Subject: [PATCH 03/18] feat(nextjs): Serve the managed tunnel under the app's `basePath` on the server `withSentryConfig` now injects `basePath` into production server and edge builds, not only in dev, and the server, edge and Cloudflare inits pass it to `setManagedTunnelDefaults`. `serveTunnelIntegration` then matches `//`, like the browser sends. --- packages/nextjs/src/client/index.ts | 9 +----- packages/nextjs/src/cloudflare/index.ts | 4 +++ .../devErrorSymbolicationEventProcessor.ts | 4 +-- packages/nextjs/src/common/utils/basePath.ts | 13 ++++++++ .../turbopack/constructTurbopackConfig.ts | 1 + .../turbopack/generateValueInjectionRules.ts | 7 +++++ packages/nextjs/src/config/webpack.ts | 3 +- packages/nextjs/src/edge/index.ts | 5 ++++ packages/nextjs/src/server/index.ts | 5 ++++ .../generateValueInjectionRules.test.ts | 15 ++++++++++ .../webpack/constructWebpackConfig.test.ts | 21 +++++++++++++ packages/nextjs/test/edgeSdk.test.ts | 30 ++++++++++++++++++- packages/nextjs/test/serverSdk.test.ts | 30 ++++++++++++++++++- 13 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/nextjs/src/common/utils/basePath.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 2327a5ab4955..c3d9361201d7 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -15,6 +15,7 @@ import { DEBUG_BUILD } from '../common/debug-build'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { getClientVercelEnv } from '../common/getVercelEnv'; import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { createNextRouteProvider } from './routing/routeProvider'; @@ -30,7 +31,6 @@ export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrume const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesAssetPrefixPath: string; _sentryAssetPrefix?: string; - _sentryBasePath?: string; _sentryRelease?: string; _experimentalThirdPartyOriginStackFrames?: string; }; @@ -97,13 +97,6 @@ export function init(options: BrowserOptions): Client | undefined { return client; } -/** - * The app's `basePath`, which `withSentryConfig` injects. - */ -function getInjectedBasePath(): string | undefined { - return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; -} - function getDefaultIntegrations(options: BrowserOptions): Integration[] { const customDefaultIntegrations = getReactDefaultIntegrations(options); // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 5c8b65007cd4..4de3d8e82310 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -6,7 +6,9 @@ import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; @@ -74,6 +76,8 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; + // Read per request: the global is set once `instrumentation` loads, which can be after the Worker entry. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index f70990a173e8..46d491457c74 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -3,6 +3,7 @@ import { debug, GLOBAL_OBJ, parseSemver, suppressTracing } from '@sentry/core'; import type { StackFrame } from 'stacktrace-parser'; import * as stackTraceParser from 'stacktrace-parser'; import { DEBUG_BUILD } from './debug-build'; +import { getInjectedBasePath } from './utils/basePath'; import { URL_FULL } from '@sentry/conventions/attributes'; type OriginalStackFrameResponse = { @@ -12,7 +13,6 @@ type OriginalStackFrameResponse = { }; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryBasePath?: string; _sentryNextJsVersion: string | undefined; }; @@ -21,7 +21,7 @@ const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { * Returns only the base path when running in the browser (client-side) for relative URLs. */ function getDevServerBaseUrl(): string { - let basePath = process.env._sentryBasePath ?? globalWithInjectedValues._sentryBasePath ?? ''; + let basePath = getInjectedBasePath() ?? ''; // Prefix the basepath with a slash if it doesn't have one if (basePath !== '' && !basePath.match(/^\//)) { diff --git a/packages/nextjs/src/common/utils/basePath.ts b/packages/nextjs/src/common/utils/basePath.ts new file mode 100644 index 000000000000..b3dec3d72e9a --- /dev/null +++ b/packages/nextjs/src/common/utils/basePath.ts @@ -0,0 +1,13 @@ +import { GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; +}; + +/** + * The app's `basePath`, which `withSentryConfig` injects. The client bundle reads it from `env`. On the server, + * `@sentry/nextjs` is external, so `env` isn't inlined there and it reads the injected global instead. + */ +export function getInjectedBasePath(): string | undefined { + return process.env._sentryBasePath || globalWithInjectedValues._sentryBasePath; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index da0ca895c246..bd124eec37ba 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -59,6 +59,7 @@ export function constructTurbopackConfig({ nextJsVersion, tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), vercelCronsConfig, + basePath: userNextConfig.basePath, }); for (const { matcher, rule } of valueInjectionRules) { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 22c81f00102b..b334a096b1f1 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -13,11 +13,13 @@ export function generateValueInjectionRules({ nextJsVersion, tunnelRouteValues, vercelCronsConfig, + basePath, }: { routeManifest?: RouteManifest; nextJsVersion?: string; tunnelRouteValues?: TunnelRouteInjectedValues; vercelCronsConfig?: VercelCronsConfig; + basePath?: string; }): TurbopackMatcherWithRule[] { const rules: TurbopackMatcherWithRule[] = []; const isomorphicValues: Record = { ...tunnelRouteValues }; @@ -29,6 +31,11 @@ export function generateValueInjectionRules({ isomorphicValues._sentryNextJsVersion = nextJsVersion; } + // The server reads it to serve the managed tunnel under the base path. + if (basePath) { + isomorphicValues._sentryBasePath = basePath; + } + if (routeManifest) { clientValues._sentryRouteManifest = JSON.stringify(routeManifest); } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index b7c1efa5cb65..0b885b50a2a2 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -714,7 +714,8 @@ function addValueInjectionLoader({ // Having a release defined in dev-mode spams releases in Sentry so we only set one in non-dev mode // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, - _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + // The server reads it to serve the managed tunnel under the base path, dev symbolication to reach the dev server. + _sentryBasePath: userNextConfig.basePath, // This is used to determine version-based dev-symbolication behavior _sentryNextJsVersion: nextJsVersion, }; diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 132d5f50aef0..700b5a765512 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,9 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { ATTR_NEXT_PAGES_API_ROUTE_TYPE, @@ -114,6 +116,9 @@ export function init(options: VercelEdgeOptions = {}): void { ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + // Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too. try { // @ts-expect-error `process.turbopack` is a magic string that will be replaced by Next.js diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 3a1bf1ab9250..57f2d0312900 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -12,7 +12,9 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; +import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; +import { getInjectedBasePath } from '../common/utils/basePath'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; @@ -159,6 +161,9 @@ export function init(options: NodeOptions): NodeClient | undefined { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. + setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + if (DEBUG_BUILD && opts.debug) { debug.enable(); } diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index d68c002f298b..f1394690a417 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -332,6 +332,21 @@ describe('generateValueInjectionRules', () => { }); }); + describe('with basePath', () => { + it('injects the base path into the client and server bundles', () => { + const result = generateValueInjectionRules({ basePath: '/docs' }); + + const injectedValues = result.map(({ matcher, rule }) => [ + matcher, + (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, + ]); + expect(injectedValues).toEqual([ + ['**/instrumentation-client.*', { _sentryBasePath: '/docs' }], + ['**/instrumentation.*', { _sentryBasePath: '/docs', __SENTRY_SERVER_MODULES__: expect.any(Object) }], + ]); + }); + }); + describe('path resolution', () => { it('should call path.resolve with correct arguments', () => { const pathResolveSpy = vi.spyOn(path, 'resolve'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 766a05f583c8..5c860d18cb31 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -864,3 +864,24 @@ describe('constructWebpackConfigFunction()', () => { }); }); }); + +describe('basePath value injection', () => { + it.each([ + ['server', serverWebpackConfig, serverBuildContext], + ['edge', serverWebpackConfig, edgeBuildContext], + ])( + 'injects the base path into production %s builds', + async (_target, incomingWebpackConfig, incomingWebpackBuildContext) => { + const finalWebpackConfig = await materializeFinalWebpackConfig({ + exportedNextConfig: { ...userNextConfig, basePath: '/docs' }, + incomingWebpackConfig, + incomingWebpackBuildContext, + }); + + const valueInjectionLoader = finalWebpackConfig.module.rules + .flatMap(rule => rule.use ?? []) + .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); + expect(valueInjectionLoader?.options).toMatchObject({ values: { _sentryBasePath: '/docs' } }); + }, + ); +}); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index a3fb9dacc7b8..c597d17a2149 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -1,8 +1,13 @@ import type { Integration } from '@sentry/core'; -import { getGlobalScope, getMainCarrier, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; +import { getGlobalScope, getMainCarrier, getTunnelPath, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetManagedTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/edge'; @@ -11,6 +16,8 @@ import { init } from '../src/edge'; const vercelEdgeInit = vi.spyOn(SentryVercelEdge, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -89,6 +96,27 @@ describe('Edge init()', () => { expect(getGlobalScope().getScopeData().tags.turbopack).toBe(true); }); + describe('managed tunnel', () => { + afterEach(() => { + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + describe('integrations', () => { // Options passed by `@sentry/nextjs`'s `init` to `@sentry/vercel-edge`'s `init` after modifying them type ModifiedInitOptions = { integrations?: Integration[]; defaultIntegrations: Integration[] }; diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 7ef42977be47..59d9ef881391 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -1,9 +1,14 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ, getMainCarrier, withIsolationScope } from '@sentry/core'; +import { GLOBAL_OBJ, getMainCarrier, getTunnelPath, withIsolationScope } from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { close, getCurrentScope, getGlobalScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + _INTERNAL_resetManagedTunnelDefaults, + handleTunnelRequestIfMatched, + serveTunnelIntegration, +} from '@sentry/core/server'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../src/common/span-attributes-with-logic-attached'; import { init } from '../src/server'; import type * as NextSentryPropagatorModule from '../src/server/nextSentryPropagator'; @@ -19,6 +24,8 @@ vi.mock('../src/server/nextSentryPropagator', async importOriginal => ({ const nodeInit = vi.spyOn(SentryNode, 'init'); +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { return integrations.find(integration => integration.name === name); } @@ -97,6 +104,27 @@ describe('Server init()', () => { }); }); + describe('managed tunnel', () => { + afterEach(() => { + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + _INTERNAL_resetManagedTunnelDefaults(); + }); + + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); + + const tunnelPath = getTunnelPath(TEST_DSN); + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + }); + it("doesn't reinitialize the node SDK if already initialized", () => { expect(nodeInit).toHaveBeenCalledTimes(0); init({}); From 52707060fc53aaa0106acdc4bf8ca27906ebf6f0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 30 Oct 2026 11:01:30 -0400 Subject: [PATCH 04/18] test(nextjs): Cover the managed tunnel under a base path --- .../instrumentation-client.ts | 3 +- .../nextjs-16-tunnel/next.config.ts | 7 +- .../nextjs-16-tunnel/package.json | 11 ++ .../nextjs-16-tunnel/sentry-dsn.ts | 6 +- .../nextjs-16-tunnel/sentry.edge.config.ts | 3 +- .../nextjs-16-tunnel/sentry.server.config.ts | 3 +- .../tests/tunnel-managed.test.ts | 108 ++++++++++++++++++ .../tests/tunnel-route.test.ts | 5 + .../tunnel-middleware/middleware.ts | 16 +++ 9 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts index 84b61b956e4a..3762bb3d7d8c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/instrumentation-client.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // The other variants use the tunnelRoute of withSentryConfig + tunnel: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? true : undefined, tracesSampleRate: 1.0, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts index ac79f882a572..e2e151944c8b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -1,9 +1,12 @@ import { withSentryConfig } from '@sentry/nextjs/config'; import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {}; +// The managed variants serve the managed tunnel under a base path, without a tunnel route. +const isManagedMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed'; + +const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs' } : {}; export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: true, + tunnelRoute: isManagedMode ? undefined : true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index c079ce13dadd..96adbaccaf64 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -17,6 +17,7 @@ "test:build-webpack": "pnpm install && pnpm build-webpack", "test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build", "test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack", + "test:build-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", @@ -71,6 +72,16 @@ "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", "label": "nextjs-16-tunnel (handler, webpack, edge)", "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build", + "label": "nextjs-16-tunnel (managed, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert" + }, + { + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build-middleware-webpack", + "label": "nextjs-16-tunnel (managed, webpack, edge middleware)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:assert-webpack" } ], "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts index fa0ad467f9bc..59c72c103e03 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -1,6 +1,8 @@ -// The handler variants send the server events to the event proxy too, so the tests also see server spans. +// The handler and managed variants send the server events to the event proxy too, so the tests also see server +// spans. export const SENTRY_DSN = - process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' || + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? 'http://public@localhost:3031/1337' : // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs 'https://public@o12345.ingest.us.sentry.io/67890'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts index 0333aa29412b..4a999c80e920 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.edge.config.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts index 0333aa29412b..4a999c80e920 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry.server.config.ts @@ -4,7 +4,8 @@ import { SENTRY_DSN } from './sentry-dsn'; Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: SENTRY_DSN, - // No tunnel option - using tunnelRoute from withSentryConfig + // Serves the managed tunnel of the managed variants + integrations: process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' ? [Sentry.serveTunnelIntegration()] : [], tracesSampleRate: 1.0, // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts new file mode 100644 index 000000000000..6bcf452dff80 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; +import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import type { Event, SerializedStreamedSpan } from '@sentry/core'; + +test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'managed', 'Only the managed variants use it'); + +const BASE_PATH = '/docs'; + +function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { + const attribute = span.attributes[key]; + return attribute?.type === 'string' ? attribute.value : undefined; +} + +function isTunnelPath(pathname: string): boolean { + return /^\/docs\/[a-z0-9]+$/.test(pathname); +} + +test('sends the pageload span through the managed tunnel under the base path', async ({ page, baseURL }) => { + const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + const tunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + + const tunnelResponse = await tunnelResponsePromise; + const pageloadSpan = await pageloadSpanPromise; + + expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true); + expect(tunnelResponse.status()).toBe(200); + expect(pageloadSpan.status).toBe('ok'); +}); + +test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel managed cookie test', + ), + ); + + await page.goto(BASE_PATH); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel managed cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel managed cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); +}); + +test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); + }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); + + await page.goto(BASE_PATH); + await pageloadTunnelResponsePromise; + // The first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get(`${BASE_PATH}/api/flush`); + await page.request.get(`${BASE_PATH}/api/flush`); + await barrierSpanPromise; + + // The middleware span of the page request arrived, so the ones of the tunnel requests would have too. + expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true); + + const postSpans = spansBeforeBarrier.filter( + span => + span.name.includes('POST') || + getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' || + getStringAttribute(span, 'http.method') === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index e0c1b5e273ae..eb27dc1af3e3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -8,6 +8,11 @@ function getStringAttribute(span: SerializedStreamedSpan, key: string): string | return attribute?.type === 'string' ? attribute.value : undefined; } +test.skip( + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed', + 'The managed variants have no tunnel route, see tunnel-managed.test.ts', +); + test('Tunnel route should proxy pageload span to Sentry', async ({ page }) => { // Wait for the pageload span to be sent through the tunnel const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts new file mode 100644 index 000000000000..49cfd8ee0af2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-middleware/middleware.ts @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function middleware(request: NextRequest) { + const tunnelResponse = Sentry.handleTunnelRequestIfMatched(request); + if (tunnelResponse) { + return tunnelResponse; + } + + return NextResponse.next(); +} + +export const config = { + matcher: '/:path*', +}; From a7bf47a43748062d03f29db17e23a1d285b44097 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 16:38:55 -0500 Subject: [PATCH 05/18] feat(nextjs): Serve the managed tunnel on Vercel Vercel serves unknown paths from its CDN without running the app, so the runtime never sees managed tunnel requests there. On Vercel builds without `tunnelRoute`, `withSentryConfig` writes the tunnel route handler and rewrites envelope requests to the DSN-derived paths there. The handler answers only when `serveTunnelIntegration` is set up, and on a cold start too. Pages Router apps get an `app` directory with only this route. --- .../getFinalConfigObjectUtils.ts | 6 +++ .../src/config/withSentryConfig/tunnel.ts | 51 +++++++++++++++++++ packages/nextjs/src/tunnel/index.ts | 11 ++++ 3 files changed, 68 insertions(+) diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index a90529b8d24d..fc0df95d93c9 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -13,6 +13,7 @@ import { getGitRevision, getInstrumentationClientFileContents } from './buildTim import { findTunnelRouteHandlerFile, resolveTunnelRoute, + setUpManagedTunnelVercelRoute, setUpTunnelHandlerRewrite, setUpTunnelRewriteRules, } from './tunnel'; @@ -46,6 +47,11 @@ export function maybeSetUpTunnelRouteRewriteRules( userSentryOptions: SentryBuildOptions, ): void { if (userSentryOptions.tunnelRoute === undefined) { + if (process.env.VERCEL && incomingUserNextConfigObject.output !== 'export') { + setUpManagedTunnelVercelRoute(incomingUserNextConfigObject); + return; + } + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); return; } diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index d0b45bd6ec8c..65e42f9fb60c 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -200,3 +200,54 @@ export function getTunnelRouteInjectedValues( : 'rewrite', }; } + +/** + * Makes the managed tunnel (`tunnel: true`) reachable on Vercel, which serves unknown paths without running the app. + * Writes the tunnel route handler if the app has none, and rewrites envelope requests to its DSN-derived paths there. + * The handler answers only when `serveTunnelIntegration` is set up. Vercel's build checkout is thrown away afterwards. + */ +export function setUpManagedTunnelVercelRoute( + userNextConfig: NextConfigObject, + projectDir: string = process.cwd(), +): void { + const pageExtensions = userNextConfig.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; + if (!findTunnelRouteHandlerFile(pageExtensions, projectDir)) { + // A Pages Router app gets an `app` directory with only this route, next to its `pages` (or in `src`). + const appDir = + ['app', 'src/app'].find(dir => getStats(path.join(projectDir, dir))?.isDirectory()) ?? + (getStats(path.join(projectDir, 'src/pages'))?.isDirectory() ? 'src/app' : 'app'); + const extension = ['js', 'ts'].find(ext => pageExtensions.includes(ext)); + if (!extension) { + return; + } + + const routeDir = path.join(projectDir, appDir, TUNNEL_ROUTE_HANDLER_PATH); + fs.mkdirSync(routeDir, { recursive: true }); + fs.writeFileSync( + path.join(routeDir, `route.${extension}`), + "// Generated by @sentry/nextjs for the managed tunnel on Vercel.\nexport { _INTERNAL_servedTunnelPOST as POST } from '@sentry/nextjs/tunnel';\n", + ); + } + + const originalRewrites = userNextConfig.rewrites; + const managedTunnelRewrite = { + // The paths `getTunnelPath` derives from a DSN. Next.js adds the `basePath`. + source: '/:sentryTunnel([a-z0-9]{1,7})', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: TUNNEL_ROUTE_HANDLER_PATH, + }; + + // `afterFiles`: the app's own routes win. + userNextConfig.rewrites = async () => { + const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; + + if (Array.isArray(originalRewritesResult)) { + return { afterFiles: [...originalRewritesResult, managedTunnelRewrite] }; + } + + return { + ...originalRewritesResult, + afterFiles: [...(originalRewritesResult.afterFiles || []), managedTunnelRewrite], + }; + }; +} diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index c55e9cfbfa5c..c3a42fa51679 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,4 +1,5 @@ import { handleTunnelRequest } from '@sentry/core'; +import { _INTERNAL_getTunnelPaths as getTunnelPaths } from '@sentry/core/server'; export interface CreateSentryTunnelRouteOptions { /** @@ -48,3 +49,13 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * ``` */ export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; + +/** + * Route handler `withSentryConfig` generates on Vercel builds, where Vercel serves unknown paths without running the + * app, so the runtime never sees managed tunnel requests. It answers only when `serveTunnelIntegration` is set up, and + * otherwise behaves like the route doesn't exist. + * + * @internal + */ +export const _INTERNAL_servedTunnelPOST: TunnelRouteHandler = async request => + getTunnelPaths().length ? handleTunnelRequest(request) : new Response(null, { status: 404 }); From aa79eeb9eb1b02d573421e28d09a7574a3faf2aa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 16:44:53 -0500 Subject: [PATCH 06/18] feat(nextjs): Deprecate `tunnelRoute` `tunnel: true` in the browser and `serveTunnelIntegration` on the server serve the tunnel on every Next.js deployment, so `tunnelRoute` and the `@sentry/nextjs/tunnel` route handlers are deprecated. `withSentryConfig` warns when `tunnelRoute` is set. --- packages/nextjs/src/config/types.ts | 3 +++ .../getFinalConfigObjectUtils.ts | 17 ++++++++++++++--- .../src/config/withSentryConfig/tunnel.ts | 1 + packages/nextjs/src/tunnel/index.ts | 7 +++++++ .../getFinalConfigObjectUtils.test.ts | 5 +++-- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 5c42a4f131e8..608509216716 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -307,6 +307,9 @@ export type SentryBuildOptions = Omit< * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. * * NOTE: This feature only works with Next.js 11+ + * + * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server + * `Sentry.init` instead. The server answers the tunnel requests, also on Vercel. */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index fc0df95d93c9..af045ed1d30b 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -19,6 +19,7 @@ import { } from './tunnel'; let showedExportModeTunnelWarning = false; +let showedDeprecatedTunnelRouteWarning = false; let showedUnusedTunnelRouteHandlerWarning = false; let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; @@ -46,7 +47,9 @@ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, userSentryOptions: SentryBuildOptions, ): void { - if (userSentryOptions.tunnelRoute === undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working + const tunnelRoute = userSentryOptions.tunnelRoute; + if (tunnelRoute === undefined) { if (process.env.VERCEL && incomingUserNextConfigObject.output !== 'export') { setUpManagedTunnelVercelRoute(incomingUserNextConfigObject); return; @@ -56,7 +59,14 @@ export function maybeSetUpTunnelRouteRewriteRules( return; } - if (!userSentryOptions.tunnelRoute) { + if (!showedDeprecatedTunnelRouteWarning) { + showedDeprecatedTunnelRouteWarning = true; + getBuildLogger(userSentryOptions.silent).warn( + '[@sentry/nextjs] The `tunnelRoute` option is deprecated. Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server `Sentry.init` instead.', + ); + } + + if (!tunnelRoute) { return; } @@ -71,7 +81,8 @@ export function maybeSetUpTunnelRouteRewriteRules( } // Update the global options object to use the resolved value everywhere - const resolvedTunnelRoute = resolveTunnelRoute(userSentryOptions.tunnelRoute); + const resolvedTunnelRoute = resolveTunnelRoute(tunnelRoute); + // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 65e42f9fb60c..0bd3de785579 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -188,6 +188,7 @@ export function getTunnelRouteInjectedValues( userSentryOptions: SentryBuildOptions | undefined, projectDir: string = process.cwd(), ): TunnelRouteInjectedValues { + // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working const tunnelRoute = userSentryOptions?.tunnelRoute; if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { return {}; diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index c3a42fa51679..3c6b6278f3fe 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -26,6 +26,9 @@ type TunnelRouteHandler = (request: Request) => Promise; * allowedDsns: ['https://public@o0.ingest.sentry.io/0'], * }); * ``` + * + * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration({ allowedDsns })` to the + * server `Sentry.init` instead. */ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } { const allowedDsns = options.allowedDsns?.length ? options.allowedDsns.map(dsn => dsn.trim()) : undefined; @@ -47,7 +50,11 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions * // app/sentry-tunnel/route.ts * export { POST } from '@sentry/nextjs/tunnel'; * ``` + * + * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server + * `Sentry.init` instead. */ +// oxlint-disable-next-line typescript/no-deprecated -- the deprecated default of the deprecated factory export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; /** diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index 9cdc46448750..d3d5471fb62d 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -113,7 +113,8 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { expect(nextConfig.rewrites).toBeUndefined(); expect(logSpy).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('will not work in combination with Next.js static exports'), ); @@ -127,7 +128,7 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); - expect(warnSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('but the `tunnelRoute` option is not set')); }); }); From 10e5651548dab7b896ba0c6ba836e3f98138a58b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 23:20:37 -0500 Subject: [PATCH 07/18] ref(nextjs): Use the renamed tunnel defaults --- packages/nextjs/src/cloudflare/index.ts | 4 ++-- packages/nextjs/src/edge/index.ts | 4 ++-- packages/nextjs/src/server/index.ts | 4 ++-- packages/nextjs/test/edgeSdk.test.ts | 4 ++-- packages/nextjs/test/serverSdk.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 4de3d8e82310..bf7abfc07bec 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -6,7 +6,7 @@ import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; -import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { getInjectedBasePath } from '../common/utils/basePath'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; @@ -77,7 +77,7 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; // Read per request: the global is set once `instrumentation` loads, which can be after the Worker entry. - setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + setTunnelDefaults({ basePath: getInjectedBasePath() }); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 700b5a765512..c3c4527047fa 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,7 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; -import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; import { getInjectedBasePath } from '../common/utils/basePath'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; @@ -117,7 +117,7 @@ export function init(options: VercelEdgeOptions = {}): void { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. - setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + setTunnelDefaults({ basePath: getInjectedBasePath() }); // Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too. try { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 57f2d0312900..02e07a914611 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -12,7 +12,7 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; -import { _INTERNAL_setManagedTunnelDefaults as setManagedTunnelDefaults } from '@sentry/core/server'; +import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; import { getInjectedBasePath } from '../common/utils/basePath'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; @@ -162,7 +162,7 @@ export function init(options: NodeOptions): NodeClient | undefined { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. - setManagedTunnelDefaults({ basePath: getInjectedBasePath() }); + setTunnelDefaults({ basePath: getInjectedBasePath() }); if (DEBUG_BUILD && opts.debug) { debug.enable(); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index c597d17a2149..7ea7c5b822fb 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -4,7 +4,7 @@ import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - _INTERNAL_resetManagedTunnelDefaults, + _INTERNAL_resetTunnelDefaults, handleTunnelRequestIfMatched, serveTunnelIntegration, } from '@sentry/core/server'; @@ -99,7 +99,7 @@ describe('Edge init()', () => { describe('managed tunnel', () => { afterEach(() => { delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; - _INTERNAL_resetManagedTunnelDefaults(); + _INTERNAL_resetTunnelDefaults(); }); it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 59d9ef881391..fc6434f01b61 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -5,7 +5,7 @@ import { close, getCurrentScope, getGlobalScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - _INTERNAL_resetManagedTunnelDefaults, + _INTERNAL_resetTunnelDefaults, handleTunnelRequestIfMatched, serveTunnelIntegration, } from '@sentry/core/server'; @@ -107,7 +107,7 @@ describe('Server init()', () => { describe('managed tunnel', () => { afterEach(() => { delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; - _INTERNAL_resetManagedTunnelDefaults(); + _INTERNAL_resetTunnelDefaults(); }); it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { From 8fb222ef55696d58344814ae20344e99b70aa6c1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 23:27:24 -0500 Subject: [PATCH 08/18] ref(nextjs): Use the renamed browser tunnel defaults --- packages/nextjs/src/client/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index c3d9361201d7..83c24e23bb8b 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -3,7 +3,7 @@ /* eslint-disable import/export */ import type { Client, EventProcessor, Integration } from '@sentry/core'; import { - _INTERNAL_applyTunnelDefaults as applyTunnelDefaults, + _INTERNAL_applyBrowserTunnelDefaults as applyBrowserTunnelDefaults, applySdkMetadata, consoleSandbox, getGlobalScope, @@ -62,7 +62,7 @@ export function init(options: BrowserOptions): Client | undefined { applyTunnelRouteOption(opts); // The managed tunnel is served under the app's `basePath`, like every other route. A `basePath` the user set wins. - opts.tunnel = applyTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() }); + opts.tunnel = applyBrowserTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() }); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); opts.ignoreSpans = [ From 0c9528e8120ef734b7b9eb05d537cdd65045ef73 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Nov 2026 11:40:34 -0500 Subject: [PATCH 09/18] ref(nextjs): Generate the Vercel tunnel route with the public POST handler --- packages/nextjs/src/config/withSentryConfig/tunnel.ts | 4 ++-- packages/nextjs/src/tunnel/index.ts | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 0bd3de785579..b958d55d1ed5 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -205,7 +205,7 @@ export function getTunnelRouteInjectedValues( /** * Makes the managed tunnel (`tunnel: true`) reachable on Vercel, which serves unknown paths without running the app. * Writes the tunnel route handler if the app has none, and rewrites envelope requests to its DSN-derived paths there. - * The handler answers only when `serveTunnelIntegration` is set up. Vercel's build checkout is thrown away afterwards. + * Vercel's build checkout is thrown away afterwards. */ export function setUpManagedTunnelVercelRoute( userNextConfig: NextConfigObject, @@ -226,7 +226,7 @@ export function setUpManagedTunnelVercelRoute( fs.mkdirSync(routeDir, { recursive: true }); fs.writeFileSync( path.join(routeDir, `route.${extension}`), - "// Generated by @sentry/nextjs for the managed tunnel on Vercel.\nexport { _INTERNAL_servedTunnelPOST as POST } from '@sentry/nextjs/tunnel';\n", + "// Generated by @sentry/nextjs for the managed tunnel on Vercel.\nexport { POST } from '@sentry/nextjs/tunnel';\n", ); } diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index 3c6b6278f3fe..db7115051b32 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,5 +1,4 @@ import { handleTunnelRequest } from '@sentry/core'; -import { _INTERNAL_getTunnelPaths as getTunnelPaths } from '@sentry/core/server'; export interface CreateSentryTunnelRouteOptions { /** @@ -56,13 +55,3 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions */ // oxlint-disable-next-line typescript/no-deprecated -- the deprecated default of the deprecated factory export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; - -/** - * Route handler `withSentryConfig` generates on Vercel builds, where Vercel serves unknown paths without running the - * app, so the runtime never sees managed tunnel requests. It answers only when `serveTunnelIntegration` is set up, and - * otherwise behaves like the route doesn't exist. - * - * @internal - */ -export const _INTERNAL_servedTunnelPOST: TunnelRouteHandler = async request => - getTunnelPaths().length ? handleTunnelRequest(request) : new Response(null, { status: 404 }); From a74485e00e233745add71f18f3022c71d675974e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Nov 2026 13:14:56 -0500 Subject: [PATCH 10/18] fix(nextjs): Skip the Vercel tunnel route when a page has its path --- .../src/config/withSentryConfig/tunnel.ts | 20 +++++++++++ .../config/withSentryConfig/tunnel.test.ts | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index b958d55d1ed5..caa3f7354093 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -169,6 +169,21 @@ export function findTunnelRouteHandlerFile( .find(file => getStats(path.join(projectDir, file))?.isFile()); } +/** + * Returns true if the project has an App Router or Pages Router page at the tunnel route handler path. + */ +function hasTunnelRoutePage(pageExtensions: string[], projectDir: string): boolean { + const pageFiles = ['app', 'src/app'].map(dir => `${dir}${TUNNEL_ROUTE_HANDLER_PATH}/page`); + const pagesRouterFiles = ['pages', 'src/pages'].flatMap(dir => [ + `${dir}${TUNNEL_ROUTE_HANDLER_PATH}`, + `${dir}${TUNNEL_ROUTE_HANDLER_PATH}/index`, + ]); + + return [...pageFiles, ...pagesRouterFiles].some(file => + pageExtensions.some(extension => getStats(path.join(projectDir, `${file}.${extension}`))?.isFile()), + ); +} + function getStats(filePath: string): fs.Stats | undefined { try { return fs.statSync(filePath); @@ -212,6 +227,11 @@ export function setUpManagedTunnelVercelRoute( projectDir: string = process.cwd(), ): void { const pageExtensions = userNextConfig.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; + // A route handler next to a page at the same path fails the build. + if (hasTunnelRoutePage(pageExtensions, projectDir)) { + return; + } + if (!findTunnelRouteHandlerFile(pageExtensions, projectDir)) { // A Pages Router app gets an `app` directory with only this route, next to its `pages` (or in `src`). const appDir = diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts index cc39280aa7d0..d8c5e9ecd9b2 100644 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -1,8 +1,11 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import type { NextConfigObject } from '../../../src/config/types'; import { findTunnelRouteHandlerFile, getTunnelRouteInjectedValues, + setUpManagedTunnelVercelRoute, setUpTunnelHandlerRewrite, } from '../../../src/config/withSentryConfig/tunnel'; import { makeProject, removeProjects } from './projectFixture'; @@ -133,3 +136,36 @@ describe('setUpTunnelHandlerRewrite', () => { }); }); }); + +describe('setUpManagedTunnelVercelRoute', () => { + it('writes the tunnel route handler and rewrites envelope requests to it', async () => { + const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.js'), 'utf8')).toContain( + "export { POST } from '@sentry/nextjs/tunnel';", + ); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + afterFiles: [expect.objectContaining({ destination: '/sentry-tunnel' })], + }); + }); + + it.each([ + ['an App Router page', 'app/sentry-tunnel/page.tsx'], + ['an App Router page in `src`', 'src/app/sentry-tunnel/page.tsx'], + ['a Pages Router page', 'pages/sentry-tunnel.tsx'], + ['a Pages Router index page', 'src/pages/sentry-tunnel/index.js'], + ])('does nothing when the project has %s at the route path', (_description, file) => { + const projectDir = makeProject([file]); + const nextConfig: NextConfigObject = {}; + + setUpManagedTunnelVercelRoute(nextConfig, projectDir); + + expect(fs.readdirSync(projectDir, { recursive: true }).filter(entry => String(entry).includes('route'))).toEqual( + [], + ); + expect(nextConfig.rewrites).toBeUndefined(); + }); +}); From dce8636d7715123a9ae86552ab5e59a1a937a480 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 5 Nov 2026 11:00:17 -0500 Subject: [PATCH 11/18] fix(nextjs): Post to the managed tunnel with Next's trailing slash With `trailingSlash: true` Next redirected the browser's POST to the tunnel path without the slash. --- .../test-applications/nextjs-16-tunnel/next.config.ts | 4 ++-- .../nextjs-16-tunnel/tests/tunnel-managed.test.ts | 3 ++- packages/nextjs/src/client/index.ts | 11 ++++++++--- packages/nextjs/src/config/types.ts | 2 ++ .../nextjs/src/config/withSentryConfig/buildTime.ts | 5 +++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts index e2e151944c8b..27cf1423ded4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -1,10 +1,10 @@ import { withSentryConfig } from '@sentry/nextjs/config'; import type { NextConfig } from 'next'; -// The managed variants serve the managed tunnel under a base path, without a tunnel route. +// The managed variants serve the managed tunnel under a base path and with trailing slashes, without a tunnel route. const isManagedMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed'; -const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs' } : {}; +const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs', trailingSlash: true } : {}; export default withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts index 6bcf452dff80..fc4d05998566 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-managed.test.ts @@ -12,8 +12,9 @@ function getStringAttribute(span: SerializedStreamedSpan, key: string): string | return attribute?.type === 'string' ? attribute.value : undefined; } +// With `trailingSlash`, a POST to the path without the slash would get a redirect. function isTunnelPath(pathname: string): boolean { - return /^\/docs\/[a-z0-9]+$/.test(pathname); + return /^\/docs\/[a-z0-9]+\/$/.test(pathname); } test('sends the pageload span through the managed tunnel under the base path', async ({ page, baseURL }) => { diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 83c24e23bb8b..bd98c6a78a99 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -1,7 +1,7 @@ // 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 { Client, EventProcessor, Integration } from '@sentry/core'; +import type { BrowserTunnelOptions, Client, EventProcessor, Integration } from '@sentry/core'; import { _INTERNAL_applyBrowserTunnelDefaults as applyBrowserTunnelDefaults, applySdkMetadata, @@ -61,8 +61,13 @@ export function init(options: BrowserOptions): Client | undefined { } satisfies BrowserOptions; applyTunnelRouteOption(opts); - // The managed tunnel is served under the app's `basePath`, like every other route. A `basePath` the user set wins. - opts.tunnel = applyBrowserTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() }); + // The managed tunnel is served under the app's `basePath` and `trailingSlash`, like every other route. Options the + // user set win. + const tunnelDefaults: BrowserTunnelOptions = { basePath: getInjectedBasePath() }; + if (process.env._sentryTrailingSlash) { + tunnelDefaults.trailingSlash = true; + } + opts.tunnel = applyBrowserTunnelDefaults(opts.tunnel, tunnelDefaults); applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']); opts.ignoreSpans = [ diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 608509216716..629d16aea705 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -31,6 +31,8 @@ export type NextConfigObject = { assetPrefix?: string; // The root at which the nextjs app will be served (defaults to "/") basePath?: string; + // Whether URLs end with a slash, which Next.js redirects to for paths without one + trailingSlash?: boolean; // Config which will be available at runtime publicRuntimeConfig?: { [key: string]: unknown }; // File extensions that count as pages in the `pages/` directory diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index 836a3829ea5d..c0d595df6469 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -48,6 +48,11 @@ export function setUpBuildTimeVariables( buildTimeVariables._sentryBasePath = basePath; } + // Next redirects a POST to the tunnel path without the slash, so the browser posts to the one with it. + if (userNextConfig.trailingSlash) { + buildTimeVariables._sentryTrailingSlash = 'true'; + } + if (userNextConfig.assetPrefix) { buildTimeVariables._sentryAssetPrefix = userNextConfig.assetPrefix; } From 9ce025b0d8b7809f66662d1b4cefd00919135b40 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 11:11:50 +0100 Subject: [PATCH 12/18] fix(nextjs): Write the Vercel tunnel route only in Vercel production and preview builds --- .../getFinalConfigObjectUtils.ts | 6 +-- .../src/config/withSentryConfig/tunnel.ts | 21 +++++++-- .../config/withSentryConfig/tunnel.test.ts | 47 ++++++++++++++++--- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index af045ed1d30b..bbc7310a4545 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -50,12 +50,10 @@ export function maybeSetUpTunnelRouteRewriteRules( // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working const tunnelRoute = userSentryOptions.tunnelRoute; if (tunnelRoute === undefined) { - if (process.env.VERCEL && incomingUserNextConfigObject.output !== 'export') { - setUpManagedTunnelVercelRoute(incomingUserNextConfigObject); - return; + if (!setUpManagedTunnelVercelRoute(incomingUserNextConfigObject)) { + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); } - maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); return; } diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index caa3f7354093..63f7166f5773 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -220,16 +220,27 @@ export function getTunnelRouteInjectedValues( /** * Makes the managed tunnel (`tunnel: true`) reachable on Vercel, which serves unknown paths without running the app. * Writes the tunnel route handler if the app has none, and rewrites envelope requests to its DSN-derived paths there. - * Vercel's build checkout is thrown away afterwards. + * + * Only runs in `next build` on Vercel, whose build checkout is thrown away afterwards. `vercel dev` sets `VERCEL` + * locally too, so `next dev` and development builds skip it to keep the file out of the repo. + * + * Returns true if it set up the route. */ export function setUpManagedTunnelVercelRoute( userNextConfig: NextConfigObject, projectDir: string = process.cwd(), -): void { +): boolean { + // Next.js sets `NEXT_PHASE` only after it loads the config. + const isVercelBuild = + !!process.env.VERCEL && process.env.VERCEL_ENV !== 'development' && process.argv.includes('build'); + if (!isVercelBuild || userNextConfig.output === 'export') { + return false; + } + const pageExtensions = userNextConfig.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; // A route handler next to a page at the same path fails the build. if (hasTunnelRoutePage(pageExtensions, projectDir)) { - return; + return false; } if (!findTunnelRouteHandlerFile(pageExtensions, projectDir)) { @@ -239,7 +250,7 @@ export function setUpManagedTunnelVercelRoute( (getStats(path.join(projectDir, 'src/pages'))?.isDirectory() ? 'src/app' : 'app'); const extension = ['js', 'ts'].find(ext => pageExtensions.includes(ext)); if (!extension) { - return; + return false; } const routeDir = path.join(projectDir, appDir, TUNNEL_ROUTE_HANDLER_PATH); @@ -271,4 +282,6 @@ export function setUpManagedTunnelVercelRoute( afterFiles: [...(originalRewritesResult.afterFiles || []), managedTunnelRewrite], }; }; + + return true; } diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts index d8c5e9ecd9b2..eb60d48c39df 100644 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -1,6 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NextConfigObject } from '../../../src/config/types'; import { findTunnelRouteHandlerFile, @@ -138,11 +138,28 @@ describe('setUpTunnelHandlerRewrite', () => { }); describe('setUpManagedTunnelVercelRoute', () => { + const originalArgv = process.argv; + + beforeEach(() => { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('VERCEL_ENV', 'production'); + process.argv = [...originalArgv.slice(0, 2), 'build']; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.argv = originalArgv; + }); + + function hasRouteFile(projectDir: string): boolean { + return fs.readdirSync(projectDir, { recursive: true }).some(entry => String(entry).includes('route')); + } + it('writes the tunnel route handler and rewrites envelope requests to it', async () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - setUpManagedTunnelVercelRoute(nextConfig, projectDir); + expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(true); expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.js'), 'utf8')).toContain( "export { POST } from '@sentry/nextjs/tunnel';", @@ -161,11 +178,29 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject([file]); const nextConfig: NextConfigObject = {}; - setUpManagedTunnelVercelRoute(nextConfig, projectDir); + expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(false); + expect(hasRouteFile(projectDir)).toBe(false); + expect(nextConfig.rewrites).toBeUndefined(); + }); - expect(fs.readdirSync(projectDir, { recursive: true }).filter(entry => String(entry).includes('route'))).toEqual( - [], - ); + it.each([ + ['outside of Vercel', () => vi.stubEnv('VERCEL', '')], + ['in a Vercel development build', () => vi.stubEnv('VERCEL_ENV', 'development')], + ['outside of `next build`', () => (process.argv = [...originalArgv.slice(0, 2), 'dev'])], + ])('does nothing %s', (_description, setUp) => { + setUp(); + const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(false); + expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); }); + + it('does nothing for a static export', () => { + const projectDir = makeProject(['app/page.tsx']); + + expect(setUpManagedTunnelVercelRoute({ output: 'export' }, projectDir)).toBe(false); + expect(hasRouteFile(projectDir)).toBe(false); + }); }); From ebeb874d74c7cac3350fb575b228e4fd163bb2d9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 11:47:28 +0100 Subject: [PATCH 13/18] feat(nextjs): Serve `tunnelRoute` with the managed tunnel The deprecated `tunnelRoute` now turns on the managed tunnel, like `tunnel: true` with `serveTunnelIntegration()`: a string is its fixed path, `true` the path derived from the DSN. The proxy rewrite to sentry.io, which forwarded every header including cookies, and the handler mode rewrite are gone. On Vercel, the generated route also serves the fixed path. --- .../tests/proxy-tunnel.test.ts | 4 +- .../nextjs-16-tunnel/next.config.ts | 5 +- .../nextjs-16-tunnel/package.json | 31 +-- .../nextjs-16-tunnel/sentry-dsn.ts | 11 +- .../tests/tunnel-route.test.ts | 233 +++++------------- .../edge/sentry-tunnel/route.ts | 13 - .../node/sentry-tunnel/route.ts | 1 - .../nextjs-16-tunnel/worker.ts | 6 +- packages/nextjs/src/client/tunnelRoute.ts | 54 +--- packages/nextjs/src/cloudflare/index.ts | 12 +- .../utils/dropMiddlewareTunnelRequests.ts | 69 +++--- .../src/common/utils/tunnelPathnameMatch.ts | 49 ++-- packages/nextjs/src/config/types.ts | 27 +- .../src/config/withSentryConfig/buildTime.ts | 2 +- .../getFinalConfigObjectUtils.ts | 78 +----- .../src/config/withSentryConfig/tunnel.ts | 214 +++------------- packages/nextjs/src/edge/index.ts | 15 +- packages/nextjs/src/server/index.ts | 21 +- packages/nextjs/test/clientSdk.test.ts | 18 ++ .../nextjs/test/cloudflare/withSentry.test.ts | 8 +- .../generateValueInjectionRules.test.ts | 12 +- .../webpack/constructWebpackConfig.test.ts | 2 +- .../test/config/withSentryConfig.test.ts | 6 +- .../config/withSentryConfig/buildTime.test.ts | 12 +- .../getFinalConfigObjectUtils.test.ts | 121 ++++----- .../config/withSentryConfig/tunnel.test.ts | 134 +++++----- packages/nextjs/test/config/wrappers.test.ts | 6 +- packages/nextjs/test/edgeSdk.test.ts | 37 ++- packages/nextjs/test/serverSdk.test.ts | 37 ++- .../dropMiddlewareTunnelRequests.test.ts | 107 +++----- .../nextjs/test/utils/tunnelRoute.test.ts | 186 ++------------ 31 files changed, 531 insertions(+), 1000 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts index 4c8807745ea0..04b8c3ee3d41 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts @@ -18,7 +18,9 @@ test('drops the `Next.request` root span of tunnel requests through the proxy', ); }); - const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' }); + // The SDK answers only a POST before the proxy. A GET reaches the proxy, like a tunnel request on Vercel, where the + // proxy runs outside of the server the SDK hooks into. + const tunnelResponse = await request.get('/monitoring'); expect(tunnelResponse.headers()['x-middleware-next']).toBe('1'); await request.get('/api/proxy-ok'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts index 27cf1423ded4..d060076a192c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/next.config.ts @@ -2,11 +2,12 @@ import { withSentryConfig } from '@sentry/nextjs/config'; import type { NextConfig } from 'next'; // The managed variants serve the managed tunnel under a base path and with trailing slashes, without a tunnel route. -const isManagedMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed'; +const tunnelMode = process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE; +const isManagedMode = tunnelMode === 'managed'; const nextConfig: NextConfig = isManagedMode ? { basePath: '/docs', trailingSlash: true } : {}; export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: isManagedMode ? undefined : true, + tunnelRoute: isManagedMode ? undefined : tunnelMode === 'path' ? '/monitoring' : true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index 96adbaccaf64..54d25e0afd9b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -4,19 +4,17 @@ "private": true, "scripts": { "dev": " next dev", - "build": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", - "dev:webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next dev --webpack", - "build-webpack": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", - "start": "_SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ next start", + "dev:webpack": "next dev --webpack", + "build-webpack": "next build --webpack > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "start": "next start", "lint": "eslint", "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", - "test:dev-webpack": "TEST_ENV=development-webpack _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:dev-webpack": "TEST_ENV=development-webpack playwright test", "test:build": "pnpm install && pnpm build", "test:build-webpack": "pnpm install && pnpm build-webpack", - "test:build-handler": "pnpm install && cp -R tunnel-handler/node/sentry-tunnel app/ && pnpm build", - "test:build-handler-webpack": "pnpm install && cp -R tunnel-handler/edge/sentry-tunnel app/ && pnpm build-webpack", "test:build-middleware-webpack": "pnpm install && rm proxy.ts && cp tunnel-middleware/middleware.ts middleware.ts && pnpm build-webpack", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", @@ -24,7 +22,7 @@ "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", - "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "test:build:cloudflare": "pnpm install && opennextjs-cloudflare build", "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { @@ -55,23 +53,18 @@ "variants": [ { "build-command": "pnpm test:build-webpack", - "label": "nextjs-16-tunnel (webpack)", + "label": "nextjs-16-tunnel (tunnelRoute: true, webpack)", "assert-command": "pnpm test:assert-webpack" }, { "build-command": "pnpm test:build", - "label": "nextjs-16-tunnel (turbopack)", + "label": "nextjs-16-tunnel (tunnelRoute: true, turbopack)", "assert-command": "pnpm test:assert" }, { - "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler", - "label": "nextjs-16-tunnel (handler, turbopack)", - "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert" - }, - { - "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:build-handler-webpack", - "label": "nextjs-16-tunnel (handler, webpack, edge)", - "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=handler pnpm test:assert-webpack" + "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=path pnpm test:build", + "label": "nextjs-16-tunnel (tunnelRoute path, turbopack)", + "assert-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=path pnpm test:assert" }, { "build-command": "NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE=managed pnpm test:build", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts index 59c72c103e03..1030f61fd5cb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/sentry-dsn.ts @@ -1,8 +1,3 @@ -// The handler and managed variants send the server events to the event proxy too, so the tests also see server -// spans. -export const SENTRY_DSN = - process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler' || - process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed' - ? 'http://public@localhost:3031/1337' - : // A fake but properly formatted Sentry SaaS DSN, because the rewrite mode tunnels only SaaS DSNs - 'https://public@o12345.ingest.us.sentry.io/67890'; +// The server forwards the tunnel requests to the host of the DSN, so the event proxy gets the browser events and the +// server spans. +export const SENTRY_DSN = 'http://public@localhost:3031/1337'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index eb27dc1af3e3..4103fe7997bd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -1,139 +1,70 @@ import { expect, test } from '@playwright/test'; -import { HTTP_REQUEST_METHOD, SERVER_ADDRESS, URL_FULL } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, URL_FULL } from '@sentry/conventions/attributes'; import { getSpanOp, waitForRequest, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; import type { Event, SerializedStreamedSpan } from '@sentry/core'; +test.skip( + process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed', + 'The managed variants have no tunnel route, see tunnel-managed.test.ts', +); + function getStringAttribute(span: SerializedStreamedSpan, key: string): string | undefined { const attribute = span.attributes[key]; return attribute?.type === 'string' ? attribute.value : undefined; } -test.skip( - process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'managed', - 'The managed variants have no tunnel route, see tunnel-managed.test.ts', -); +// `tunnelRoute: '/monitoring'` in the `path` variant, `tunnelRoute: true` (the path derived from the DSN) otherwise. +function isTunnelPath(pathname: string): boolean { + return process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'path' + ? pathname === '/monitoring' + : /^\/[a-z0-9]{1,7}$/.test(pathname); +} -test('Tunnel route should proxy pageload span to Sentry', async ({ page }) => { - // Wait for the pageload span to be sent through the tunnel +test('sends the pageload span through the tunnel route', async ({ page, baseURL }) => { const pageloadSpanPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); + const tunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); - // Navigate to the page await page.goto('/'); + const tunnelResponse = await tunnelResponsePromise; const pageloadSpan = await pageloadSpanPromise; - // Verify the pageload span was received successfully - expect(pageloadSpan).toBeDefined(); - expect(pageloadSpan.name).toBe('/'); - expect(getSpanOp(pageloadSpan)).toBe('pageload'); + expect(isTunnelPath(new URL(tunnelResponse.url()).pathname)).toBe(true); + expect(tunnelResponse.status()).toBe(200); expect(pageloadSpan.status).toBe('ok'); }); -test('Tunnel route should send multiple pageload spans consistently', async ({ page }) => { - // This test verifies that the tunnel route remains consistent across multiple page loads - // (important for Turbopack which could generate different tunnel routes for client/server) - - // First pageload +test('sends the pageload spans of every page load through the tunnel route', async ({ page }) => { const firstPageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); await page.goto('/'); - const firstPageload = await firstPageloadPromise; - expect(firstPageload).toBeDefined(); - expect(firstPageload.name).toBe('/'); - expect(getSpanOp(firstPageload)).toBe('pageload'); - expect(firstPageload.status).toBe('ok'); + expect((await firstPageloadPromise).status).toBe('ok'); - // Second pageload (reload) const secondPageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { return getSpanOp(span) === 'pageload' && span.name === '/' && span.is_segment; }); await page.reload(); - const secondPageload = await secondPageloadPromise; - - expect(secondPageload).toBeDefined(); - expect(secondPageload.name).toBe('/'); - expect(getSpanOp(secondPageload)).toBe('pageload'); - expect(secondPageload.status).toBe('ok'); -}); - -test('Tunnel requests should not create middleware or fetch spans', async ({ page }) => { - // This test verifies that our span filtering logic works correctly - // The proxy runs on all routes, so we'll get a middleware span for `/` - // But we should NOT get middleware or fetch spans for the tunnel route itself - - // Accumulate every streamed span for the duration of the test. The callback never returns true, - // so this promise is deliberately left unsettled - the assertions below read the array instead. - const allSpans: SerializedStreamedSpan[] = []; - void waitForStreamedSpans('nextjs-16-tunnel', spans => { - allSpans.push(...spans); - return false; - }); - - // Wait for pageload span - const pageloadPromise = waitForStreamedSpan('nextjs-16-tunnel', span => { - return getSpanOp(span) === 'pageload' && span.is_segment; - }); - - await page.goto('/'); - const pageloadSpan = await pageloadPromise; - - // Trigger errors to force tunnel POST requests - await page - .evaluate(() => { - throw new Error('Test tunnel error 1'); - }) - .catch(() => { - // Expected to throw - }); - - await page - .evaluate(() => { - throw new Error('Test tunnel error 2'); - }) - .catch(() => { - // Expected to throw - }); - - // Wait for events to be sent through tunnel, and for the spans they would wrongly produce to arrive - await page.waitForTimeout(3000); - - // We should have received the pageload span - expect(pageloadSpan).toBeDefined(); - expect(getSpanOp(pageloadSpan)).toBe('pageload'); - - const middlewareSpans = allSpans.filter(span => getSpanOp(span) === 'middleware'); - - // We WILL have a middleware span for GET / (the pageload) - // But we should NOT have middleware spans for POST requests (tunnel route) - const postMiddlewareSpans = middlewareSpans.filter( - span => span.name.includes('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', - ); - - expect(postMiddlewareSpans).toHaveLength(0); - - // We should NOT have any fetch spans to Sentry ingest. Matched on the host attribute rather than a - // substring of the full URL, which would also match an arbitrary host with `sentry.io` elsewhere in it. - const sentryFetchSpans = allSpans.filter(span => { - const host = getStringAttribute(span, SERVER_ADDRESS) ?? ''; - return getSpanOp(span) === 'http.client' && (host === 'sentry.io' || host.endsWith('.sentry.io')); - }); - expect(sentryFetchSpans).toHaveLength(0); + expect((await secondPageloadPromise).status).toBe('ok'); }); -test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => { - test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies'); +test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => envelope[1].some( ([itemHeader, item]) => - itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel cookie test', + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel route cookie test', ), ); @@ -143,89 +74,57 @@ test('Tunnel requests are sent without the cookies of the browser', async ({ pag request => request.method() === 'POST' && new URL(request.url()).origin === baseURL && - (request.postData() ?? '').includes('Tunnel cookie test'), + (request.postData() ?? '').includes('Tunnel route cookie test'), ); await page.evaluate(() => { setTimeout(() => { - throw new Error('Tunnel cookie test'); + throw new Error('Tunnel route cookie test'); }); }); const tunnelRequest = await tunnelRequestPromise; const proxyRequest = await proxyRequestPromise; - expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d'); - expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined(); + expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); }); -test.describe('Tunnel route handler', () => { - test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); - - test('forwards events without the cookies of the browser', async ({ page, context, baseURL }) => { - await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); - const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => - envelope[1].some( - ([itemHeader, item]) => - itemHeader.type === 'event' && - (item as Event).exception?.values?.[0]?.value === 'Tunnel route handler cookie test', - ), - ); - - await page.goto('/'); - await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); - const tunnelRequestPromise = page.waitForRequest( - request => - request.method() === 'POST' && - new URL(request.url()).origin === baseURL && - (request.postData() ?? '').includes('Tunnel route handler cookie test'), - ); - await page.evaluate(() => { - setTimeout(() => { - throw new Error('Tunnel route handler cookie test'); - }); - }); - - const tunnelRequest = await tunnelRequestPromise; - const proxyRequest = await proxyRequestPromise; - - expect(new URL(tunnelRequest.url()).search).toBe(''); - expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); - expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); - expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); +test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { + // One listener, so it gets every span that the proxy received before the barrier span. + const spansBeforeBarrier: SerializedStreamedSpan[] = []; + const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { + spansBeforeBarrier.push(...spans); + return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); }); + const pageloadTunnelResponsePromise = page.waitForResponse( + response => + response.request().method() === 'POST' && + new URL(response.url()).origin === baseURL && + (response.request().postData() ?? '').includes('pageload'), + ); - test('does not create server spans for tunnel requests', async ({ page, baseURL }) => { - // One listener, so it gets every span that the proxy received before the barrier span. - const spansBeforeBarrier: SerializedStreamedSpan[] = []; - const barrierSpanPromise = waitForStreamedSpans('nextjs-16-tunnel', spans => { - spansBeforeBarrier.push(...spans); - return spans.some(span => span.is_segment && span.name === 'GET /api/flush'); - }); - const pageloadTunnelResponsePromise = page.waitForResponse( - response => - response.request().method() === 'POST' && - new URL(response.url()).origin === baseURL && - (response.request().postData() ?? '').includes('pageload'), - ); - - await page.goto('/'); - await pageloadTunnelResponsePromise; - // Flush the edge client of the edge tunnel route (a 405 on the Node.js one), then the Node.js client twice: - // the first flush sends the tunnel spans, the second one the barrier span of the first flush. - await page.request.get('/sentry-tunnel'); - await page.request.get('/api/flush'); - await page.request.get('/api/flush'); - await barrierSpanPromise; - - const postSpans = spansBeforeBarrier.filter( - span => span.name.startsWith('POST') || getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST', - ); - const ingestSpans = spansBeforeBarrier.filter( - span => - getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), - ); - expect(postSpans).toEqual([]); - expect(ingestSpans).toEqual([]); - }); + await page.goto('/'); + await pageloadTunnelResponsePromise; + // The first flush sends the tunnel spans, the second one the barrier span of the first flush. + await page.request.get('/api/flush'); + await page.request.get('/api/flush'); + await barrierSpanPromise; + + // The middleware span of the page request arrived, so the ones of the tunnel requests would have too. + expect(spansBeforeBarrier.some(span => span.name === 'middleware GET')).toBe(true); + + const postSpans = spansBeforeBarrier.filter( + span => + span.name.includes('POST') || + getStringAttribute(span, HTTP_REQUEST_METHOD) === 'POST' || + getStringAttribute(span, 'http.method') === 'POST', + ); + const ingestSpans = spansBeforeBarrier.filter( + span => + getSpanOp(span) === 'http.client' && !!getStringAttribute(span, URL_FULL)?.startsWith('http://localhost:3031/'), + ); + expect(postSpans).toEqual([]); + expect(ingestSpans).toEqual([]); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts deleted file mode 100644 index 4eeb13efd97f..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/edge/sentry-tunnel/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as Sentry from '@sentry/nextjs'; -import { createSentryTunnelRoute } from '@sentry/nextjs/tunnel'; -import { SENTRY_DSN } from '@/sentry-dsn'; - -export const { POST } = createSentryTunnelRoute({ allowedDsns: [SENTRY_DSN] }); - -// Each edge route has its own SDK client, so the tests flush this one here. -export async function GET(): Promise { - await Sentry.flush(); - return new Response(null, { status: 204 }); -} - -export const runtime = 'edge'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts deleted file mode 100644 index 23c975fb750d..000000000000 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tunnel-handler/node/sentry-tunnel/route.ts +++ /dev/null @@ -1 +0,0 @@ -export { POST } from '@sentry/nextjs/tunnel'; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts index 522d8c460dfd..43ae875d27cb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -5,9 +5,9 @@ import handler from './.open-next/worker.js'; export default Sentry.withSentry( () => ({ environment: 'qa', // dynamic sampling bias to keep transactions - dsn: 'https://public@o12345.ingest.us.sentry.io/67890', - // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. - tunnel: 'http://localhost:3031/', + // Sends the server spans and the tunnel requests to the proxy server, so the tests see whether a tunnel request was + // sampled. + dsn: 'http://public@localhost:3031/1337', tracesSampleRate: 1.0, }), handler, diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index 72f2855e32ae..d0c6cef6eae6 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -1,50 +1,18 @@ -import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core'; import type { BrowserOptions } from '@sentry/react'; -import { DEBUG_BUILD } from '../common/debug-build'; -import type { TunnelRouteMode } from '../common/utils/tunnelPathnameMatch'; - -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: TunnelRouteMode; -}; +import { getInjectedTunnelRoute } from '../common/utils/tunnelPathnameMatch'; /** - * Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option. + * Sends events through the managed tunnel when `withSentryConfig` has a `tunnelRoute`, unless `Sentry.init` sets its + * own `tunnel`. A fixed path gets the trailing slash Next.js would otherwise redirect a POST to. */ export function applyTunnelRouteOption(options: BrowserOptions): void { - const tunnelRouteOption = process.env._sentryRewritesTunnelPath || globalWithInjectedValues._sentryRewritesTunnelPath; - if (tunnelRouteOption && options.dsn) { - const dsnComponents = dsnFromString(options.dsn); - if (!dsnComponents) { - return; - } - - // The handler reads the DSN from the envelope, so it needs no query and no SaaS DSN. - const tunnelRouteMode = process.env._sentryTunnelRouteMode || globalWithInjectedValues._sentryTunnelRouteMode; - if (tunnelRouteMode === 'handler') { - options.tunnel = tunnelRouteOption; - DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelRouteOption}"`); - return; - } - - const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest(?:\.([a-z]{2}))?\.sentry\.io$/); - if (sentrySaasDsnMatch) { - const orgId = sentrySaasDsnMatch[1]; - const regionCode = sentrySaasDsnMatch[2]; - let tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`; - if (regionCode) { - tunnelPath += `&r=${regionCode}`; - } - options.tunnel = tunnelPath; - // The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the - // tunnel does not need. - options.transportOptions = { - ...options.transportOptions, - fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions }, - }; - DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); - } else { - DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); - } + const tunnelRoute = getInjectedTunnelRoute(); + if (!tunnelRoute || options.tunnel) { + return; } + + options.tunnel = + typeof tunnelRoute === 'string' && process.env._sentryTrailingSlash && !tunnelRoute.endsWith('/') + ? `${tunnelRoute}/` + : tunnelRoute; } diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index bf7abfc07bec..73711f3936c3 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -6,10 +6,14 @@ import { withSentry as withSentryCloudflare } from '@sentry/cloudflare'; import type { Integration } from '@sentry/core'; import { applySdkMetadata, extractTraceparentData, getRootSpan, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; import { registerPrepareSpanScope, setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; -import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; import { getInjectedBasePath } from '../common/utils/basePath'; import { isTunnelRouteSpan } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd'; import { NextSentryPropagator } from '../server/nextSentryPropagator'; @@ -77,7 +81,11 @@ export const withSentry: typeof withSentryCloudflare = (optionsCallback, handler return withSentryCloudflare(env => { const options: CloudflareOptions = { enableOpenTelemetrySetup: true, ...optionsCallback(env) }; // Read per request: the global is set once `instrumentation` loads, which can be after the Worker entry. - setTunnelDefaults({ basePath: getInjectedBasePath() }); + setTunnelDefaults({ + basePath: getInjectedBasePath(), + tunnel: getTunnelRouteServerOptions(), + forward: forwardTunnelRequest, + }); options.ignoreSpans = [ ...(options.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 8baa168fa031..2ce44416eba2 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,19 +1,23 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; import type { ClientOptions, RawAttributes } from '@sentry/core'; -import { escapeStringForRegex, getClient, GLOBAL_OBJ, isSentryRequestUrl, type Span } from '@sentry/core'; +import { escapeStringForRegex, getClient, isSentryRequestUrl, type Span } from '@sentry/core'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { + getInjectedTunnelRoute, getTunnelRouteRequestPattern, isPathnameUnderSentryTunnelRoute, TUNNEL_ROUTE_HANDLER_PATH, - type TunnelRouteMode, } from './tunnelPathnameMatch'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: TunnelRouteMode; -}; +/** + * The fixed path of the `tunnelRoute`. The path derived from the DSN isn't known here. + */ +function getFixedTunnelPath(): string | undefined { + const tunnelRoute = getInjectedTunnelRoute(); + + return typeof tunnelRoute === 'string' ? tunnelRoute : undefined; +} /** * Drops spans for tunnel requests from middleware, fetch instrumentation, or BaseServer.handleRequest. @@ -71,7 +75,7 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo * Checks if a span's HTTP target matches the tunnel route. */ export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + const tunnelPath = getFixedTunnelPath(); if (!tunnelPath) { return false; } @@ -98,44 +102,27 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool * Next.js request spans, so other spans with the same path are kept. */ export function getTunnelRouteIgnoreSpans(): NonNullable { - const tunnelPath = globalWithInjectedValues._sentryRewritesTunnelPath || process.env._sentryRewritesTunnelPath; + const tunnelPath = getFixedTunnelPath(); if (!tunnelPath) { return []; } - const tunnelRouteMode = - (globalWithInjectedValues._sentryTunnelRouteMode || process.env._sentryTunnelRouteMode) === 'handler' - ? 'handler' - : 'rewrite'; - const tunnelRequestIgnoreSpan = { - attributes: { - [ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute)$/, - // Next.js sets only `http.target` at span start. - // eslint-disable-next-line typescript/no-deprecated - [HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPath, tunnelRouteMode), + return [ + { + attributes: { + // Since Next.js 16.4.3, a proxy invoked separately from the render runs under a `Next.request` root span. + [ATTR_NEXT_SPAN_TYPE]: /^(?:BaseServer\.handleRequest|Middleware\.execute|Next\.request)$/, + // Next.js sets only `http.target` at span start. + // eslint-disable-next-line typescript/no-deprecated + [HTTP_TARGET]: getTunnelRouteRequestPattern(tunnelPath), + }, }, - }; - - // Since Next.js 16.4.3, a proxy invoked separately from the render runs under a `Next.request` root span. Its - // `http.target` is the pathname only, so the query of a rewrite-mode tunnel request can't be matched. - const proxyRootSpanIgnoreSpan = { - attributes: { - [ATTR_NEXT_SPAN_TYPE]: /^Next\.request$/, - // eslint-disable-next-line typescript/no-deprecated - [HTTP_TARGET]: new RegExp(`^${escapeStringForRegex(tunnelPath)}/?$`), - }, - }; - - if (tunnelRouteMode !== 'handler') { - return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan]; - } - - // On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16. - const tunnelRouteHandlerIgnoreSpan = { - attributes: { - [ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/, - [ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`), + { + // On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16. + attributes: { + [ATTR_NEXT_SPAN_TYPE]: /^AppRouteRouteHandlers\.runHandler$/, + [ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`), + }, }, - }; - return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan, tunnelRouteHandlerIgnoreSpan]; + ]; } diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 2c5dc8bb1dd1..cf4718dfba9f 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -1,4 +1,9 @@ -import { escapeStringForRegex } from '@sentry/core'; +import type { ServerTunnelOptions } from '@sentry/core'; +import { escapeStringForRegex, GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryTunnelRoute?: string; +}; /** * The path of the tunnel route handler (`app/sentry-tunnel/route.ts`). @@ -7,10 +12,31 @@ import { escapeStringForRegex } from '@sentry/core'; export const TUNNEL_ROUTE_HANDLER_PATH = '/sentry-tunnel'; /** - * `rewrite`: the tunnel route proxies requests to Sentry with all their headers. - * `handler`: the tunnel route goes to the tunnel route handler, which forwards only the envelope. + * The `tunnelRoute` of `withSentryConfig`, which `withSentryConfig` injects: the fixed tunnel path with the `basePath`, + * or `true` for the path derived from the DSN. The client bundle reads it from `env`. On the server, `@sentry/nextjs` is + * external, so `env` isn't inlined there and it reads the injected global instead. + */ +export function getInjectedTunnelRoute(): string | true | undefined { + const tunnelRoute = process.env._sentryTunnelRoute || globalWithInjectedValues._sentryTunnelRoute; + if (!tunnelRoute) { + return undefined; + } + + return tunnelRoute === 'true' ? true : tunnelRoute; +} + +/** + * The managed tunnel the `tunnelRoute` enables on the server, like `serveTunnelIntegration` with these options. A fixed + * path already has the `basePath`. */ -export type TunnelRouteMode = 'rewrite' | 'handler'; +export function getTunnelRouteServerOptions(): Omit | undefined { + const tunnelRoute = getInjectedTunnelRoute(); + if (!tunnelRoute) { + return undefined; + } + + return tunnelRoute === true ? {} : { path: tunnelRoute }; +} /** * Returns true when `pathname` is exactly the Sentry tunnel route or a sub-path @@ -22,16 +48,9 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: s } /** - * Returns a pattern for the request targets (path and query) that the tunnel rewrite serves. - * It must never match more than the rewrite, see GHSA-mchm-gjx6-7h55. - * - * - `handler`: the path, with an optional trailing slash and any query. - * - `rewrite`: also exactly the query that `applyTunnelRouteOption` sends. A looser query match is unsafe, because - * Next.js reads the last value of a repeated param. + * Returns a pattern for the request targets (path and query) of the tunnel path: the path, with an optional trailing + * slash and any query. It must never match more than the tunnel path, see GHSA-mchm-gjx6-7h55. */ -export function getTunnelRouteRequestPattern(tunnelPath: string, tunnelRouteMode: TunnelRouteMode): RegExp { - const pathPattern = `^${escapeStringForRegex(tunnelPath)}/?`; - return tunnelRouteMode === 'handler' - ? new RegExp(`${pathPattern}(?:\\?|$)`) - : new RegExp(`${pathPattern}\\?o=\\d+&p=\\d+(?:&r=[a-z]{2})?$`); +export function getTunnelRouteRequestPattern(tunnelPath: string): RegExp { + return new RegExp(`^${escapeStringForRegex(tunnelPath)}/?(?:\\?|$)`); } diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 629d16aea705..d4a2da1c56a3 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -284,31 +284,14 @@ export type SentryBuildOptions = Omit< /** * Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events - * from being sent. This option should be a path (for example: '/error-monitoring'). + * from being sent. It turns on the managed tunnel, like `tunnel: true` in the browser `Sentry.init` together with + * `serveTunnelIntegration()` in the server `Sentry.init`. * - * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build + * - Pass `true` to use the path derived from the DSN * - Pass a string path (e.g., '/monitoring') to use a custom route * - * By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload, - * create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route - * group) with: - * - * ```ts - * export { POST } from '@sentry/nextjs/tunnel'; - * ``` - * - * This handler accepts the DSN of the server SDK, also a self-hosted one, and the DSNs `serveTunnelIntegration` - * allows. For other DSNs, use `createSentryTunnelRoute` from the same import. - * - * Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your - * middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events - * can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`. - * - * Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP - * authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set - * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. - * - * NOTE: This feature only works with Next.js 11+ + * The server forwards only the event payload, for the DSN of the server SDK. A `tunnel` or `serveTunnelIntegration` + * you set in `Sentry.init` wins over this option. * * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server * `Sentry.init` instead. The server answers the tunnel requests, also on Vercel. diff --git a/packages/nextjs/src/config/withSentryConfig/buildTime.ts b/packages/nextjs/src/config/withSentryConfig/buildTime.ts index c0d595df6469..3af554d730e4 100644 --- a/packages/nextjs/src/config/withSentryConfig/buildTime.ts +++ b/packages/nextjs/src/config/withSentryConfig/buildTime.ts @@ -33,7 +33,7 @@ export function setUpBuildTimeVariables( _sentryRewriteFramesAssetPrefixPath: assetPrefix ? new URL(assetPrefix, 'http://dogs.are.great').pathname.replace(/\/$/, '') : '', - ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions, projectDir), + ...getTunnelRouteInjectedValues(userNextConfig, userSentryOptions), }; if (userNextConfig.assetPrefix) { diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index bbc7310a4545..333198e19a2d 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -10,18 +10,10 @@ import type { RouteManifest } from '../manifest/types'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { DEFAULT_PAGE_EXTENSIONS, requiresInstrumentationHook } from '../util'; import { getGitRevision, getInstrumentationClientFileContents } from './buildTime'; -import { - findTunnelRouteHandlerFile, - resolveTunnelRoute, - setUpManagedTunnelVercelRoute, - setUpTunnelHandlerRewrite, - setUpTunnelRewriteRules, -} from './tunnel'; +import { setUpManagedTunnelVercelRoute } from './tunnel'; let showedExportModeTunnelWarning = false; let showedDeprecatedTunnelRouteWarning = false; -let showedUnusedTunnelRouteHandlerWarning = false; -let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; const MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN = '__SENTRY_MISSING_INSTRUMENTATION_FILE_WARNING_SHOWN__'; @@ -39,9 +31,10 @@ export function resolveReleaseName(userSentryOptions: SentryBuildOptions): strin } /** - * Applies tunnel-route rewrites, if configured. Uses handler mode if the project has a tunnel route handler file. + * Sets up the route of the managed tunnel on Vercel. A `tunnelRoute` turns on the managed tunnel at its path, or at + * the path derived from the DSN for `true`, which the client, server and edge SDKs read from the injected values. * - * Note: this mutates `userSentryOptions` (to store the resolved tunnel route) and `incomingUserNextConfigObject`. + * Note: this mutates `incomingUserNextConfigObject`. */ export function maybeSetUpTunnelRouteRewriteRules( incomingUserNextConfigObject: NextConfigObject, @@ -49,72 +42,27 @@ export function maybeSetUpTunnelRouteRewriteRules( ): void { // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working const tunnelRoute = userSentryOptions.tunnelRoute; - if (tunnelRoute === undefined) { - if (!setUpManagedTunnelVercelRoute(incomingUserNextConfigObject)) { - maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); - } - - return; - } - - if (!showedDeprecatedTunnelRouteWarning) { + if (tunnelRoute !== undefined && !showedDeprecatedTunnelRouteWarning) { showedDeprecatedTunnelRouteWarning = true; getBuildLogger(userSentryOptions.silent).warn( '[@sentry/nextjs] The `tunnelRoute` option is deprecated. Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server `Sentry.init` instead.', ); } - if (!tunnelRoute) { - return; - } - - if (incomingUserNextConfigObject.output === 'export') { - if (!showedExportModeTunnelWarning) { - showedExportModeTunnelWarning = true; - getBuildLogger(userSentryOptions.silent).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(tunnelRoute); - // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working - userSentryOptions.tunnelRoute = resolvedTunnelRoute || undefined; - - const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); - if (!tunnelRouteHandlerFile) { - setUpTunnelRewriteRules(incomingUserNextConfigObject, resolvedTunnelRoute); + if (tunnelRoute === false || tunnelRoute === '') { return; } - setUpTunnelHandlerRewrite(incomingUserNextConfigObject, resolvedTunnelRoute); - - if (!showedTunnelRouteHandlerModeLog) { - showedTunnelRouteHandlerModeLog = true; - getBuildLogger(userSentryOptions.silent).log( - `[@sentry/nextjs] The tunnel route sends events through ${tunnelRouteHandlerFile}. Only the event payload goes to Sentry.`, + if (tunnelRoute && incomingUserNextConfigObject.output === 'export' && !showedExportModeTunnelWarning) { + showedExportModeTunnelWarning = true; + getBuildLogger(userSentryOptions.silent).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', ); } -} - -function maybeWarnAboutUnusedTunnelRouteHandler( - incomingUserNextConfigObject: NextConfigObject, - silent: boolean | undefined, -): void { - if (showedUnusedTunnelRouteHandlerWarning || incomingUserNextConfigObject.output === 'export') { - return; - } - - const tunnelRouteHandlerFile = findTunnelRouteHandlerFile(incomingUserNextConfigObject.pageExtensions); - if (!tunnelRouteHandlerFile) { - return; - } - showedUnusedTunnelRouteHandlerWarning = true; - getBuildLogger(silent).warn( - `[@sentry/nextjs] Found ${tunnelRouteHandlerFile}, but the \`tunnelRoute\` option is not set. Set \`tunnelRoute\` in \`withSentryConfig\` to send events through this route.`, + setUpManagedTunnelVercelRoute( + incomingUserNextConfigObject, + typeof tunnelRoute === 'string' ? tunnelRoute : undefined, ); } diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index 63f7166f5773..c6a687bf7d61 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -1,152 +1,11 @@ -import { _INTERNAL_safeMathRandom } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; -import { TUNNEL_ROUTE_HANDLER_PATH, type TunnelRouteMode } from '../../common/utils/tunnelPathnameMatch'; +import { TUNNEL_ROUTE_HANDLER_PATH } from '../../common/utils/tunnelPathnameMatch'; import type { NextConfigObject, SentryBuildOptions } from '../types'; import { DEFAULT_PAGE_EXTENSIONS } from '../util'; -/** The values that the client, server and edge bundles read to find the tunnel route. */ -export type TunnelRouteInjectedValues = - | { _sentryRewritesTunnelPath: string; _sentryTunnelRouteMode: TunnelRouteMode } - | Record; - -/** - * 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 || [])], - }; - } - }; -} - -/** - * Injects a rewrite rule that sends requests from the `tunnelPath` to the tunnel route handler. - */ -export function setUpTunnelHandlerRewrite(userNextConfig: NextConfigObject, tunnelPath: string): void { - const originalRewrites = userNextConfig.rewrites; - // Next.js adds the `basePath` and an optional trailing slash. - const handlerRewrite = { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH }; - - // `beforeFiles`: a `public/` file or a page at the same path wins over an `afterFiles` rewrite. - userNextConfig.rewrites = async () => { - const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; - - if (Array.isArray(originalRewritesResult)) { - return { beforeFiles: [handlerRewrite], afterFiles: originalRewritesResult }; - } - - return { - ...originalRewritesResult, - beforeFiles: [handlerRewrite, ...(originalRewritesResult.beforeFiles || [])], - }; - }; -} +/** The values that the client, server and edge bundles read to enable the managed tunnel for the `tunnelRoute`. */ +export type TunnelRouteInjectedValues = { _sentryTunnelRoute: string } | Record; /** * Returns the tunnel route handler file relative to `projectDir` (for example `app/sentry-tunnel/route.ts`), or @@ -193,54 +52,49 @@ function getStats(filePath: string): fs.Stats | undefined { } /** - * Returns the tunnel path that the client sends envelopes to, and the tunnel route mode. - * Returns no values if there is no tunnel route, or if it cannot work (static export). - * - * Call it after `maybeSetUpTunnelRouteRewriteRules`, which resolves `tunnelRoute: true` to a path. + * Returns the `tunnelRoute` for the client, server and edge bundles: the fixed path with the `basePath`, or `'true'` + * for the path derived from the DSN. Returns no values if there is no tunnel route, or if it cannot work (static + * export). */ export function getTunnelRouteInjectedValues( userNextConfig: NextConfigObject, userSentryOptions: SentryBuildOptions | undefined, - projectDir: string = process.cwd(), ): TunnelRouteInjectedValues { // oxlint-disable-next-line typescript/no-deprecated -- reads the deprecated option to keep it working const tunnelRoute = userSentryOptions?.tunnelRoute; - if (typeof tunnelRoute !== 'string' || !tunnelRoute || userNextConfig.output === 'export') { + if (!tunnelRoute || userNextConfig.output === 'export') { return {}; } return { - _sentryRewritesTunnelPath: `${userNextConfig.basePath ?? ''}${tunnelRoute}`, - _sentryTunnelRouteMode: findTunnelRouteHandlerFile(userNextConfig.pageExtensions, projectDir) - ? 'handler' - : 'rewrite', + _sentryTunnelRoute: tunnelRoute === true ? 'true' : `${userNextConfig.basePath ?? ''}${tunnelRoute}`, }; } /** - * Makes the managed tunnel (`tunnel: true`) reachable on Vercel, which serves unknown paths without running the app. - * Writes the tunnel route handler if the app has none, and rewrites envelope requests to its DSN-derived paths there. + * Makes the managed tunnel reachable on Vercel, which serves unknown paths without running the app. Writes the tunnel + * route handler if the app has none, and rewrites envelope requests to it: from the `tunnelRoute` path, or else from + * the paths derived from a DSN. * * Only runs in `next build` on Vercel, whose build checkout is thrown away afterwards. `vercel dev` sets `VERCEL` * locally too, so `next dev` and development builds skip it to keep the file out of the repo. - * - * Returns true if it set up the route. */ export function setUpManagedTunnelVercelRoute( userNextConfig: NextConfigObject, + tunnelPath?: string, projectDir: string = process.cwd(), -): boolean { +): void { // Next.js sets `NEXT_PHASE` only after it loads the config. const isVercelBuild = !!process.env.VERCEL && process.env.VERCEL_ENV !== 'development' && process.argv.includes('build'); if (!isVercelBuild || userNextConfig.output === 'export') { - return false; + return; } const pageExtensions = userNextConfig.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS; // A route handler next to a page at the same path fails the build. if (hasTunnelRoutePage(pageExtensions, projectDir)) { - return false; + return; } if (!findTunnelRouteHandlerFile(pageExtensions, projectDir)) { @@ -250,7 +104,7 @@ export function setUpManagedTunnelVercelRoute( (getStats(path.join(projectDir, 'src/pages'))?.isDirectory() ? 'src/app' : 'app'); const extension = ['js', 'ts'].find(ext => pageExtensions.includes(ext)); if (!extension) { - return false; + return; } const routeDir = path.join(projectDir, appDir, TUNNEL_ROUTE_HANDLER_PATH); @@ -262,26 +116,26 @@ export function setUpManagedTunnelVercelRoute( } const originalRewrites = userNextConfig.rewrites; - const managedTunnelRewrite = { - // The paths `getTunnelPath` derives from a DSN. Next.js adds the `basePath`. - source: '/:sentryTunnel([a-z0-9]{1,7})', - has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], - destination: TUNNEL_ROUTE_HANDLER_PATH, - }; + // Next.js adds the `basePath` and an optional trailing slash to the source. + const tunnelRewrite = tunnelPath + ? { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH } + : { + // The paths `getTunnelPath` derives from a DSN. + source: '/:sentryTunnel([a-z0-9]{1,7})', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: TUNNEL_ROUTE_HANDLER_PATH, + }; - // `afterFiles`: the app's own routes win. userNextConfig.rewrites = async () => { const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; - - if (Array.isArray(originalRewritesResult)) { - return { afterFiles: [...originalRewritesResult, managedTunnelRewrite] }; - } - - return { - ...originalRewritesResult, - afterFiles: [...(originalRewritesResult.afterFiles || []), managedTunnelRewrite], - }; + const rewrites = Array.isArray(originalRewritesResult) + ? { afterFiles: originalRewritesResult } + : originalRewritesResult; + + // The path the user picked wins over a `public/` file or a page at it. The derived paths come after the app's own + // routes, since one of them could take such a short path. + return tunnelPath + ? { ...rewrites, beforeFiles: [tunnelRewrite, ...(rewrites.beforeFiles || [])] } + : { ...rewrites, afterFiles: [...(rewrites.afterFiles || []), tunnelRewrite] }; }; - - return true; } diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index c3c4527047fa..95855b366591 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -15,7 +15,10 @@ import { } from '@sentry/core'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; -import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; import { getInjectedBasePath } from '../common/utils/basePath'; import { ATTR_NEXT_SPAN_NAME, ATTR_NEXT_SPAN_TYPE } from '../common/nextSpanAttributes'; @@ -28,6 +31,7 @@ import { backfillHttpResponseStatusCode } from '../common/utils/backfillHttpResp import { backfillHttpServerStatus } from '../common/utils/backfillHttpServerStatus'; import { createLiveRootSpanAdapter } from '../common/utils/liveRootSpanAdapter'; import { dropMiddlewareTunnelRequests, getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { maybeForkIsolationScopeForRootSpan } from '../common/utils/forkIsolationScopeForRootSpan'; import { getNormalizedRequestFromAttributes } from '../common/utils/getNormalizedRequestFromAttributes'; import { isBuild } from '../common/utils/isBuild'; @@ -68,7 +72,6 @@ export type EdgeOptions = VercelEdgeOptions; const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewriteFramesDistDir?: string; _sentryRelease?: string; - _sentryRewritesTunnelPath?: string; }; /** Inits the Sentry NextJS SDK on the Edge Runtime. */ @@ -116,8 +119,12 @@ export function init(options: VercelEdgeOptions = {}): void { ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; - // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. - setTunnelDefaults({ basePath: getInjectedBasePath() }); + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration` or `tunnelRoute`. + setTunnelDefaults({ + basePath: getInjectedBasePath(), + tunnel: getTunnelRouteServerOptions(), + forward: forwardTunnelRequest, + }); // Before the Cloudflare check, so that the events of the client of `withSentry` get the tag too. try { diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 02e07a914611..00cf3cb5332e 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -11,13 +11,22 @@ import { GLOBAL_OBJ, } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrations, httpIntegration, init as nodeInit } from '@sentry/node'; -import { _INTERNAL_setTunnelDefaults as setTunnelDefaults } from '@sentry/core/server'; +import { + _INTERNAL_installTunnelHooks as installTunnelHooks, + getDefaultIntegrations, + httpIntegration, + init as nodeInit, +} from '@sentry/node'; +import { + _INTERNAL_forwardTunnelRequest as forwardTunnelRequest, + _INTERNAL_setTunnelDefaults as setTunnelDefaults, +} from '@sentry/core/server'; import { DEBUG_BUILD } from '../common/debug-build'; import { getInjectedBasePath } from '../common/utils/basePath'; import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor'; import { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors'; import { getTunnelRouteIgnoreSpans } from '../common/utils/dropMiddlewareTunnelRequests'; +import { getTunnelRouteServerOptions } from '../common/utils/tunnelPathnameMatch'; import { isBuild } from '../common/utils/isBuild'; import { isAsyncContextOwnedByCloudflare, @@ -161,8 +170,12 @@ export function init(options: NodeOptions): NodeClient | undefined { opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; - // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration`. - setTunnelDefaults({ basePath: getInjectedBasePath() }); + // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration` or `tunnelRoute`. + const tunnelRoute = getTunnelRouteServerOptions(); + setTunnelDefaults({ basePath: getInjectedBasePath(), tunnel: tunnelRoute, forward: forwardTunnelRequest }); + if (tunnelRoute) { + installTunnelHooks(); + } if (DEBUG_BUILD && opts.debug) { debug.enable(); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index a0a2dadc3e01..76875a0a563f 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -266,6 +266,24 @@ describe('Client init()', () => { expect(client?.getOptions().tunnel).toBe('/monitoring'); }); + + it('sends events to the fixed path of the `tunnelRoute`', () => { + vi.stubEnv('_sentryBasePath', '/docs'); + vi.stubEnv('_sentryTunnelRoute', '/docs/monitoring'); + + const client = init({ dsn: TEST_DSN }); + + expect(client?.getOptions().tunnel).toBe('/docs/monitoring'); + }); + + it("sends events to the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + vi.stubEnv('_sentryBasePath', '/docs'); + vi.stubEnv('_sentryTunnelRoute', 'true'); + + const client = init({ dsn: TEST_DSN }); + + expect(client?.getOptions().tunnel).toBe(`/docs${getTunnelPath(TEST_DSN)}`); + }); }); it('returns client from init', () => { diff --git a/packages/nextjs/test/cloudflare/withSentry.test.ts b/packages/nextjs/test/cloudflare/withSentry.test.ts index 46bc97e520be..d0a1904663f6 100644 --- a/packages/nextjs/test/cloudflare/withSentry.test.ts +++ b/packages/nextjs/test/cloudflare/withSentry.test.ts @@ -28,7 +28,7 @@ describe('withSentry', () => { getMainCarrier().__SENTRY__ = undefined; (GLOBAL_OBJ as typeof GLOBAL_OBJ & { __SENTRY_CLOUDFLARE_CLIENT__?: unknown }).__SENTRY_CLOUDFLARE_CLIENT__ = undefined; - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath; + delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryTunnelRoute?: string })._sentryTunnelRoute; delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRelease?: string })._sentryRelease; vi.restoreAllMocks(); trace.disable(); @@ -186,8 +186,7 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryTunnelRoute?: string })._sentryTunnelRoute = '/monitoring'; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampleRate: 1 }), { fetch: (request: Request) => { @@ -212,8 +211,7 @@ describe('withSentry', () => { }); it('does not sample the request span of a request to the tunnel route when a `tracesSampler` samples every span', async () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRewritesTunnelPath?: string })._sentryRewritesTunnelPath = - '/monitoring'; + (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryTunnelRoute?: string })._sentryTunnelRoute = '/monitoring'; const sampledByPath: Record = {}; const handler = withSentry(() => ({ dsn: DSN, tracesSampler: () => 1 }), { fetch: (request: Request) => { diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts index f1394690a417..7885f953ac51 100644 --- a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -306,9 +306,9 @@ describe('generateValueInjectionRules', () => { }); describe('with tunnel route values', () => { - it('injects the tunnel path and mode into the client and server bundles', () => { + it('injects the tunnel route into the client and server bundles', () => { const result = generateValueInjectionRules({ - tunnelRouteValues: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + tunnelRouteValues: { _sentryTunnelRoute: '/monitoring' }, }); const injectedValues = result.map(({ matcher, rule }) => [ @@ -316,15 +316,11 @@ describe('generateValueInjectionRules', () => { (rule as { loaders: Array<{ options: { values: Record } }> }).loaders[0]?.options.values, ]); expect(injectedValues).toEqual([ - [ - '**/instrumentation-client.*', - { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, - ], + ['**/instrumentation-client.*', { _sentryTunnelRoute: '/monitoring' }], [ '**/instrumentation.*', { - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'handler', + _sentryTunnelRoute: '/monitoring', __SENTRY_SERVER_MODULES__: expect.any(Object), }, ], diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 5c860d18cb31..60d06c115a29 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -859,7 +859,7 @@ describe('constructWebpackConfigFunction()', () => { .flatMap(rule => rule.use ?? []) .find(loader => String(loader.loader).endsWith('valueInjectionLoader.js')); expect(valueInjectionLoader?.options).toMatchObject({ - values: { _sentryRewritesTunnelPath: '/monitoring', _sentryTunnelRouteMode: 'handler' }, + values: { _sentryTunnelRoute: '/monitoring' }, }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index 9cec2cc71410..8960683f59cb 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -917,7 +917,7 @@ describe('withSentryConfig', () => { expect(finalConfig.productionBrowserSourceMaps).toBe(true); expect(sentryOptions.sourcemaps).toHaveProperty('deleteSourcemapsAfterUpload', true); - expect(finalConfig.rewrites).toBeInstanceOf(Function); + expect(finalConfig.env).toHaveProperty('_sentryTunnelRoute', '/custom-tunnel'); }); it('works correctly with custom release configuration', () => { @@ -1786,8 +1786,8 @@ describe('withSentryConfig', () => { '[@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.', ); - // Ensure other functionality still works (tunnel route creates rewrites function) - expect(finalConfig.rewrites).toBeInstanceOf(Function); + // Ensure other functionality still works + expect(finalConfig.env).toHaveProperty('_sentryTunnelRoute', '/tunnel'); // Release name should be set (from git or environment) expect(finalConfig.env).toHaveProperty('_sentryRelease'); diff --git a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts index b30f6f708855..a0f66a40e2a0 100644 --- a/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/buildTime.test.ts @@ -59,21 +59,15 @@ describe('setUpBuildTimeVariables', () => { }); describe('tunnel route', () => { - it('adds the tunnel path and the handler mode for a project with a tunnel route handler', () => { + it('adds the tunnel path with the base path', () => { const nextConfig = { basePath: '/docs' }; - setUpBuildTimeVariables( - nextConfig, - { tunnelRoute: '/monitoring' }, - undefined, - makeProject(['app/sentry-tunnel/route.ts']), - ); + setUpBuildTimeVariables(nextConfig, { tunnelRoute: '/monitoring' }, undefined, makeProject(['app/page.tsx'])); expect(nextConfig).toEqual({ basePath: '/docs', env: expect.objectContaining({ - _sentryRewritesTunnelPath: '/docs/monitoring', - _sentryTunnelRouteMode: 'handler', + _sentryTunnelRoute: '/docs/monitoring', }), }); }); diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index d3d5471fb62d..971c0d31d0cf 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -3,7 +3,7 @@ import type { NextConfigObject } from '../../../src/config/types'; import { warnIfMissingInstrumentationFile } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; import { makeProject, removeProjects } from './projectFixture'; -const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const FIXED_PATH_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; afterEach(() => { removeProjects(); @@ -18,11 +18,17 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { await import('../../../src/config/withSentryConfig/getFinalConfigObjectUtils'); }); + const originalArgv = process.argv; + beforeEach(() => { vi.restoreAllMocks(); // A fresh module per test, because the function logs each message only once. vi.resetModules(); - delete process.env.__SENTRY_TUNNEL_ROUTE__; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + process.argv = originalArgv; }); async function setUp(files: string[]) { @@ -34,102 +40,81 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { return { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy }; } - it('sets up the handler rewrite when the project has a tunnel route handler', async () => { - const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/sentry-tunnel/route.ts']); + function stubVercelBuild(): void { + vi.stubEnv('VERCEL', '1'); + vi.stubEnv('VERCEL_ENV', 'production'); + process.argv = [...originalArgv.slice(0, 2), 'build']; + } + + it('sets up no rewrite outside of Vercel and warns once about the deprecated option', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); const nextConfig: NextConfigObject = {}; maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); - await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); + expect(nextConfig.rewrites).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); }); - it('logs the tunnel route handler file once', async () => { - const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); + it('rewrites the `tunnelRoute` path to the tunnel route handler on Vercel', async () => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; - maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); - maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); - expect(logSpy).toHaveBeenCalledTimes(1); - expect(logSpy).toHaveBeenCalledWith( - '[@sentry/nextjs] The tunnel route sends events through app/sentry-tunnel/route.ts. Only the event payload goes to Sentry.', - ); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [FIXED_PATH_REWRITE], afterFiles: [] }); }); - it('sets up the external rewrite when the project has no tunnel route handler', async () => { - const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/page.tsx']); + it.each([ + ['`tunnelRoute` is not set', {}], + ['`tunnelRoute` is `true`', { tunnelRoute: true }], + ])('rewrites the paths derived from a DSN on Vercel when %s', async (_description, sentryOptions) => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); - await expect(nextConfig.rewrites?.()).resolves.toEqual([ - { - source: '/monitoring(/?)', - has: [ - { type: 'query', key: 'o', value: '(?\\d*)' }, - { type: 'query', key: 'p', value: '(?\\d*)' }, - { type: 'query', key: 'r', value: '(?[a-z]{2})' }, - ], - destination: 'https://o:orgid.ingest.:region.sentry.io/api/:projectid/envelope/?hsts=0', - }, - { - source: '/monitoring(/?)', - has: [ - { type: 'query', key: 'o', value: '(?\\d*)' }, - { type: 'query', key: 'p', value: '(?\\d*)' }, - ], - destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/?hsts=0', - }, - ]); - expect(logSpy).not.toHaveBeenCalled(); - }); - - it('does not log with `silent`', async () => { - const { maybeSetUpTunnelRouteRewriteRules, logSpy } = await setUp(['app/sentry-tunnel/route.ts']); - - maybeSetUpTunnelRouteRewriteRules({}, { tunnelRoute: '/monitoring', silent: true }); - - expect(logSpy).not.toHaveBeenCalled(); - }); - - it('warns once when the project has a tunnel route handler but `tunnelRoute` is not set', async () => { - const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['src/app/sentry-tunnel/route.tsx']); - const nextConfig: NextConfigObject = {}; + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + afterFiles: [expect.objectContaining({ source: '/:sentryTunnel([a-z0-9]{1,7})', destination: '/sentry-tunnel' })], + }); + }); + + it('does not warn about the deprecated option when `tunnelRoute` is not set', async () => { + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); - maybeSetUpTunnelRouteRewriteRules(nextConfig, {}); maybeSetUpTunnelRouteRewriteRules({}, {}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('sets up no rewrite on Vercel when `tunnelRoute` is `false`', async () => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: false }); + expect(nextConfig.rewrites).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - '[@sentry/nextjs] Found src/app/sentry-tunnel/route.tsx, but the `tunnelRoute` option is not set. Set `tunnelRoute` in `withSentryConfig` to send events through this route.', - ); }); - it('sets up no rewrite for a static export with a tunnel route handler', async () => { - const { maybeSetUpTunnelRouteRewriteRules, logSpy, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); + it('sets up no rewrite for a static export and warns that `tunnelRoute` does not work there', async () => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); const nextConfig: NextConfigObject = { output: 'export' }; maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); expect(nextConfig.rewrites).toBeUndefined(); - expect(logSpy).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledTimes(2); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('will not work in combination with Next.js static exports'), ); }); - - it.each([ - ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], - ['`tunnelRoute` is not set in a static export', { output: 'export' }, {}], - ])('does not warn about the tunnel route handler when %s', async (_description, nextConfig, sentryOptions) => { - const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/sentry-tunnel/route.ts']); - - maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); - - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('but the `tunnelRoute` option is not set')); - }); }); describe('warnIfMissingInstrumentationFile', () => { diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts index eb60d48c39df..dcf866a0025f 100644 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -6,11 +6,10 @@ import { findTunnelRouteHandlerFile, getTunnelRouteInjectedValues, setUpManagedTunnelVercelRoute, - setUpTunnelHandlerRewrite, } from '../../../src/config/withSentryConfig/tunnel'; import { makeProject, removeProjects } from './projectFixture'; -const HANDLER_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const FIXED_PATH_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; afterEach(() => { removeProjects(); @@ -61,79 +60,25 @@ describe('findTunnelRouteHandlerFile', () => { }); describe('getTunnelRouteInjectedValues', () => { - it('returns the handler mode when the project has a tunnel route handler', () => { - const projectDir = makeProject(['app/sentry-tunnel/route.ts']); - - expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'handler', - }); - }); - - it('returns the rewrite mode when the project has no tunnel route handler', () => { - const projectDir = makeProject(['app/page.tsx']); - - expect(getTunnelRouteInjectedValues({}, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/monitoring', - _sentryTunnelRouteMode: 'rewrite', + it('returns the fixed path with the basePath', () => { + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' })).toEqual({ + _sentryTunnelRoute: '/docs/monitoring', }); }); - it('adds the basePath to the tunnel path', () => { - const projectDir = makeProject(['app/sentry-tunnel/route.ts']); - - expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: '/monitoring' }, projectDir)).toEqual({ - _sentryRewritesTunnelPath: '/docs/monitoring', - _sentryTunnelRouteMode: 'handler', + it('returns `true` for the path derived from the DSN', () => { + expect(getTunnelRouteInjectedValues({ basePath: '/docs' }, { tunnelRoute: true })).toEqual({ + _sentryTunnelRoute: 'true', }); }); it.each([ ['`tunnelRoute` is not set', {}, {}], + ['`tunnelRoute` is `false`', {}, { tunnelRoute: false }], ['`tunnelRoute` is an empty string', { basePath: '/docs' }, { tunnelRoute: '' }], - ['`tunnelRoute` is still `true`', {}, { tunnelRoute: true }], ['the app is a static export', { output: 'export' }, { tunnelRoute: '/monitoring' }], - ])('returns no values when %s', (_description, nextConfig, sentryOptions) => { - const projectDir = makeProject(['app/sentry-tunnel/route.ts']); - - expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions, projectDir)).toEqual({}); - }); -}); - -describe('setUpTunnelHandlerRewrite', () => { - it('adds the handler rewrite to `beforeFiles` when there are no user rewrites', async () => { - const nextConfig: NextConfigObject = {}; - - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); - - await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [HANDLER_REWRITE], afterFiles: [] }); - }); - - it('keeps user rewrites in array form as `afterFiles`', async () => { - const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; - const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; - - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); - - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [HANDLER_REWRITE], - afterFiles: [userRewrite], - }); - }); - - it('puts the handler rewrite before the user `beforeFiles` rewrites', async () => { - const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; - const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; - const nextConfig: NextConfigObject = { - rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), - }; - - setUpTunnelHandlerRewrite(nextConfig, '/monitoring'); - - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [HANDLER_REWRITE, userBeforeFilesRewrite], - fallback: [userFallbackRewrite], - }); + ] as const)('returns no values when %s', (_description, nextConfig, sentryOptions) => { + expect(getTunnelRouteInjectedValues(nextConfig, sentryOptions)).toEqual({}); }); }); @@ -159,7 +104,7 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(true); + setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.js'), 'utf8')).toContain( "export { POST } from '@sentry/nextjs/tunnel';", @@ -178,7 +123,8 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject([file]); const nextConfig: NextConfigObject = {}; - expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(false); + setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); }); @@ -192,15 +138,65 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - expect(setUpManagedTunnelVercelRoute(nextConfig, projectDir)).toBe(false); + setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); }); it('does nothing for a static export', () => { const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = { output: 'export' }; + + setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); - expect(setUpManagedTunnelVercelRoute({ output: 'export' }, projectDir)).toBe(false); expect(hasRouteFile(projectDir)).toBe(false); + expect(nextConfig.rewrites).toBeUndefined(); + }); + + it('rewrites the `tunnelRoute` path to the tunnel route handler before files', async () => { + const projectDir = makeProject(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', projectDir); + + expect(fs.existsSync(path.join(projectDir, 'app/sentry-tunnel/route.js'))).toBe(true); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [FIXED_PATH_REWRITE], afterFiles: [] }); + }); + + it('keeps the user rewrites in array form as `afterFiles`', async () => { + const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; + const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; + + setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', makeProject(['app/page.tsx'])); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [FIXED_PATH_REWRITE], + afterFiles: [userRewrite], + }); + }); + + it('puts the `tunnelRoute` rewrite before the user `beforeFiles` rewrites', async () => { + const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; + const nextConfig: NextConfigObject = { + rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + }; + + setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', makeProject(['app/page.tsx'])); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ + beforeFiles: [FIXED_PATH_REWRITE, userBeforeFilesRewrite], + fallback: [userFallbackRewrite], + }); + }); + + it('keeps the tunnel route handler of the app', () => { + const projectDir = makeProject(['app/page.tsx', 'app/sentry-tunnel/route.ts']); + + setUpManagedTunnelVercelRoute({}, '/monitoring', projectDir); + + expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), 'utf8')).toBe(''); + expect(fs.existsSync(path.join(projectDir, 'app/sentry-tunnel/route.js'))).toBe(false); }); }); diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 9d121172eea9..973e097953a2 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -99,8 +99,8 @@ describe('data-fetching function wrappers should not create manual spans', () => describe('wrapMiddlewareWithSentry', () => { afterEach(() => { vi.clearAllMocks(); - if ('_sentryRewritesTunnelPath' in globalThis) { - delete (globalThis as any)._sentryRewritesTunnelPath; + if ('_sentryTunnelRoute' in globalThis) { + delete (globalThis as any)._sentryTunnelRoute; } }); @@ -144,7 +144,7 @@ describe('wrapMiddlewareWithSentry', () => { }); test('should run the middleware for requests to the tunnel route', async () => { - (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; + (globalThis as any)._sentryTunnelRoute = '/monitoring'; const mockReturnValue = { status: 200 }; const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index 7ea7c5b822fb..68345ed992ab 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -97,20 +97,49 @@ describe('Edge init()', () => { }); describe('managed tunnel', () => { + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; + _sentryTunnelRoute?: string; + }; + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + afterEach(() => { - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + delete globalWithInjectedValues._sentryBasePath; + delete globalWithInjectedValues._sentryTunnelRoute; _INTERNAL_resetTunnelDefaults(); }); + it('serves the fixed path of the `tunnelRoute` without `serveTunnelIntegration`', () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post('/docs/monitoring'))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + + it("serves the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeInstanceOf(Promise); + fetchSpy.mockRestore(); + }); + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryBasePath = '/docs'; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); const tunnelPath = getTunnelPath(TEST_DSN); - const post = (path: string): Request => - new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); fetchSpy.mockRestore(); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index fc6434f01b61..5f03569ce97a 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -105,20 +105,49 @@ describe('Server init()', () => { }); describe('managed tunnel', () => { + const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath?: string; + _sentryTunnelRoute?: string; + }; + const post = (path: string): Request => + new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); + afterEach(() => { - delete (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath; + delete globalWithInjectedValues._sentryBasePath; + delete globalWithInjectedValues._sentryTunnelRoute; _INTERNAL_resetTunnelDefaults(); }); + it('serves the fixed path of the `tunnelRoute` without `serveTunnelIntegration`', () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post('/docs/monitoring'))).toBeInstanceOf(Promise); + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeUndefined(); + fetchSpy.mockRestore(); + }); + + it("serves the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); + + init({ dsn: TEST_DSN }); + + expect(handleTunnelRequestIfMatched(post(`/docs${getTunnelPath(TEST_DSN)}`))).toBeInstanceOf(Promise); + fetchSpy.mockRestore(); + }); + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { - (GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string })._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryBasePath = '/docs'; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); init({ dsn: TEST_DSN, integrations: [serveTunnelIntegration()] }); const tunnelPath = getTunnelPath(TEST_DSN); - const post = (path: string): Request => - new Request(`https://example.com${path}`, { method: 'POST', body: '{}', headers: { 'content-length': '2' } }); expect(handleTunnelRequestIfMatched(post(`/docs${tunnelPath}`))).toBeInstanceOf(Promise); expect(handleTunnelRequestIfMatched(post(`${tunnelPath}`))).toBeUndefined(); fetchSpy.mockRestore(); diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index e5b506d02c81..09dcb9384300 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -9,8 +9,7 @@ import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanA import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: string; + _sentryTunnelRoute?: string; }; vi.mock('@sentry/core', async requireActual => { @@ -33,14 +32,13 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - globalWithInjectedValues._sentryTunnelRouteMode = undefined; + globalWithInjectedValues._sentryTunnelRoute = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -52,7 +50,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -64,7 +62,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -76,7 +74,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -99,7 +97,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/basepath/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/basepath/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -113,7 +111,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -127,7 +125,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Next.request spans', () => { it('marks the proxy root span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -141,7 +139,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -160,7 +158,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -190,70 +188,37 @@ describe('getTunnelRouteIgnoreSpans', () => { expect(getTunnelRouteIgnoreSpans()).toEqual([]); }); - it.each([ - ['BaseServer.handleRequest', '/monitoring?o=123&p=456'], - ['BaseServer.handleRequest', '/monitoring/?o=123&p=456&r=us'], - ['Middleware.execute', '/monitoring?o=123&p=456'], - ])('ignores a %s span for %s in rewrite mode', (spanType, target) => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + it('returns no entries for the path derived from the DSN', () => { + globalWithInjectedValues._sentryTunnelRoute = 'true'; - expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); + expect(getTunnelRouteIgnoreSpans()).toEqual([]); }); it.each([ - '/monitoring', - '/monitoring?o=123', - '/monitoring?xo=123&p=456', - '/monitoring?o=123&p=456&o=abc', - '/monitoring?p=456&o=123', - '/monitoring/dashboard?o=123&p=456', - '/monitoring-dashboard?o=123&p=456', - '/api/monitoring?o=123&p=456', - ])('does not ignore a request span for %s in rewrite mode, which the rewrite does not serve', target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); - }); - - it.each(['/monitoring', '/monitoring/', '/monitoring?cache=0'])( - 'ignores a request span for %s in handler mode', - target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(true); - }, - ); + ['BaseServer.handleRequest', '/monitoring'], + ['BaseServer.handleRequest', '/monitoring/'], + ['BaseServer.handleRequest', '/monitoring?cache=0'], + ['Middleware.execute', '/monitoring'], + ['Next.request', '/monitoring'], + ['Next.request', '/monitoring/'], + ])('ignores a %s span for %s', (spanType, target) => { + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; - it.each([ - ['rewrite', '/monitoring'], - ['rewrite', '/monitoring/'], - ['handler', '/monitoring'], - ] as const)('ignores a Next.request proxy root span in %s mode for %s', (mode, target) => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = mode; - - expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true); + expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); }); it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])( - 'does not ignore a Next.request proxy root span for %s', + 'does not ignore a request span for %s', target => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false); }, ); - it('does not ignore a request span for a sub-path in handler mode', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/monitoring/dashboard'))).toBe(false); - }); - it('does not ignore an outgoing request with the tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; const outgoingRequestAttributes = { [URL_PATH]: '/monitoring', @@ -266,10 +231,9 @@ describe('getTunnelRouteIgnoreSpans', () => { }); it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( - 'ignores the route handler span with next.route %s in handler mode', + 'ignores the route handler span with next.route %s', nextRoute => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; expect( isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), @@ -277,18 +241,9 @@ describe('getTunnelRouteIgnoreSpans', () => { }, ); - it('does not ignore the route handler span in rewrite mode', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring'; - globalWithInjectedValues._sentryTunnelRouteMode = 'rewrite'; - - expect( - isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: '/sentry-tunnel' }), - ).toBe(false); - }); - it('escapes regex characters in the tunnel path', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/a.b'; + globalWithInjectedValues._sentryTunnelRoute = '/a.b'; - expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb?o=123&p=456'))).toBe(false); + expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb'))).toBe(false); }); }); diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 30dd3f840ba3..1cd58995ddc0 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -1,193 +1,61 @@ import type { BrowserOptions } from '@sentry/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { applyTunnelRouteOption } from '../../src/client/tunnelRoute'; +const DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; + const globalWithInjectedValues = global as typeof global & { - _sentryRewritesTunnelPath?: string; - _sentryTunnelRouteMode?: string; + _sentryTunnelRoute?: string; }; beforeEach(() => { - vi.restoreAllMocks(); - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - globalWithInjectedValues._sentryTunnelRouteMode = undefined; -}); - -describe('applyTunnelRouteOption()', () => { - it('Correctly applies `tunnelRoute` option when conditions are met', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333'); - }); - - it("Doesn't apply `tunnelRoute` when DSN is missing", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - // no dsn - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - // Avoid polluting the test output with error messages - const mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'invalidDsn', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - - mockConsoleError.mockRestore(); - }); - - it("Doesn't apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => { - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it("Doesn't `tunnelRoute` option when DSN is not a SaaS DSN", () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@example.com/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); - - it('Correctly applies `tunnelRoute` option to region DSNs', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333&r=us'); - }); + globalWithInjectedValues._sentryTunnelRoute = undefined; }); -describe('Random tunnel route generation', () => { - it('Works when tunnelRoute is true and generates random-looking paths', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/abc123def'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/abc123def?o=2222222&p=3333333'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333$/); - }); - - it('Works with region DSNs when tunnelRoute is true', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/x7h9k2m'; // Simulated random path - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.eu.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBe('/x7h9k2m?o=2222222&p=3333333&r=eu'); - expect(options.tunnel).toMatch(/^\/[a-z0-9]+\?o=2222222&p=3333333&r=eu$/); - }); - - it('Does not apply tunnel when tunnelRoute is false', () => { - globalWithInjectedValues._sentryRewritesTunnelPath = undefined; - const options: any = { - dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333', - } as BrowserOptions; - - applyTunnelRouteOption(options); - - expect(options.tunnel).toBeUndefined(); - }); +afterEach(() => { + vi.unstubAllEnvs(); }); -describe('applyTunnelRouteOption() credentials', () => { - const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; - - beforeEach(() => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - }); - - it("sends tunnel requests with `credentials: 'omit'`", () => { - const options: BrowserOptions = { dsn: SAAS_DSN }; - - applyTunnelRouteOption(options); - - expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } }); - }); - - it('keeps the fetch options of the user', () => { - const options: BrowserOptions = { - dsn: SAAS_DSN, - transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } }, - }; +describe('applyTunnelRouteOption()', () => { + it('sends events to the fixed path of the `tunnelRoute`', () => { + globalWithInjectedValues._sentryTunnelRoute = '/docs/monitoring'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toEqual({ - bufferSize: 10, - fetchOptions: { credentials: 'same-origin', priority: 'low' }, - }); + expect(options.tunnel).toBe('/docs/monitoring'); }); - it('does not change the transport options when the events are not tunneled', () => { - const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' }; + it('sends events to the path derived from the DSN for `tunnelRoute: true`', () => { + globalWithInjectedValues._sentryTunnelRoute = 'true'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toBeUndefined(); + expect(options.tunnel).toBe(true); }); - it('does not change the transport options in handler mode', () => { - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - const options: BrowserOptions = { dsn: SAAS_DSN }; + it('adds the trailing slash Next.js redirects to', () => { + vi.stubEnv('_sentryTrailingSlash', 'true'); + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); - expect(options.transportOptions).toBeUndefined(); + expect(options.tunnel).toBe('/monitoring/'); }); -}); -describe('applyTunnelRouteOption() in handler mode', () => { - it.each([ - ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'], - ['a self-hosted DSN', 'https://11111111111111111111111111111111@sentry.example.com/3333333'], - ])('tunnels %s to the tunnel path with no query', (_description, dsn) => { - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - const options: BrowserOptions = { dsn }; + it('keeps the `tunnel` the user set', () => { + globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + const options: BrowserOptions = { dsn: DSN, tunnel: 'https://tunnel.example.com' }; applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/my-error-monitoring-route'); + expect(options.tunnel).toBe('https://tunnel.example.com'); }); - it("Doesn't apply `tunnelRoute` when DSN is invalid", () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; - globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; - const options: BrowserOptions = { dsn: 'invalidDsn' }; + it('does not set a `tunnel` without a `tunnelRoute`', () => { + const options: BrowserOptions = { dsn: DSN }; applyTunnelRouteOption(options); From 8e364d0d27c06b9d034de27d5cc945be7885fda6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 11:53:54 +0100 Subject: [PATCH 14/18] ref(nextjs): Un-deprecate the tunnel route handler `POST` The route `withSentryConfig` generates on Vercel re-exports it, so it stays. --- packages/nextjs/src/tunnel/index.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/nextjs/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index db7115051b32..14bba2e884aa 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -38,20 +38,16 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions } /** - * Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the - * request headers (such as cookies). + * Route handler of the managed tunnel on Vercel, which `withSentryConfig` writes to `app/sentry-tunnel/route.js` in + * Vercel builds. It forwards Sentry envelopes to Sentry, but not the request headers (such as cookies). * - * It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. For other DSNs, - * use `createSentryTunnelRoute`. + * It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. Your own + * `app/sentry-tunnel/route.ts` with it is used instead of the generated one. * * @example * ```ts * // app/sentry-tunnel/route.ts * export { POST } from '@sentry/nextjs/tunnel'; * ``` - * - * @deprecated Set `tunnel: true` in the browser `Sentry.init` and add `serveTunnelIntegration()` to the server - * `Sentry.init` instead. */ -// oxlint-disable-next-line typescript/no-deprecated -- the deprecated default of the deprecated factory -export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST; +export const POST: TunnelRouteHandler = request => handleTunnelRequest(request); From e1db9b1fd94f471e98fca126ce9bf22d54d47f5c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 12:04:14 +0100 Subject: [PATCH 15/18] feat(nextjs): Rewrite envelope requests on any unrouted path to the Vercel tunnel route A fixed tunnel `path` set in `Sentry.init` now reaches the route on Vercel too, without `tunnelRoute`. The envelope content type keeps other requests out, and the handler forwards only envelopes for allowed DSNs. --- .../getFinalConfigObjectUtils.ts | 5 +- .../src/config/withSentryConfig/tunnel.ts | 40 +++++++-------- .../getFinalConfigObjectUtils.test.ts | 40 +++++++-------- .../config/withSentryConfig/tunnel.test.ts | 49 +++++++------------ 4 files changed, 57 insertions(+), 77 deletions(-) diff --git a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index 333198e19a2d..813f24b58df7 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -60,10 +60,7 @@ export function maybeSetUpTunnelRouteRewriteRules( ); } - setUpManagedTunnelVercelRoute( - incomingUserNextConfigObject, - typeof tunnelRoute === 'string' ? tunnelRoute : undefined, - ); + setUpManagedTunnelVercelRoute(incomingUserNextConfigObject); } /** diff --git a/packages/nextjs/src/config/withSentryConfig/tunnel.ts b/packages/nextjs/src/config/withSentryConfig/tunnel.ts index c6a687bf7d61..031e76604aca 100644 --- a/packages/nextjs/src/config/withSentryConfig/tunnel.ts +++ b/packages/nextjs/src/config/withSentryConfig/tunnel.ts @@ -73,15 +73,14 @@ export function getTunnelRouteInjectedValues( /** * Makes the managed tunnel reachable on Vercel, which serves unknown paths without running the app. Writes the tunnel - * route handler if the app has none, and rewrites envelope requests to it: from the `tunnelRoute` path, or else from - * the paths derived from a DSN. + * route handler if the app has none, and rewrites envelope requests on paths the app doesn't serve to it, so the + * derived path and a fixed one both reach it. * * Only runs in `next build` on Vercel, whose build checkout is thrown away afterwards. `vercel dev` sets `VERCEL` * locally too, so `next dev` and development builds skip it to keep the file out of the repo. */ export function setUpManagedTunnelVercelRoute( userNextConfig: NextConfigObject, - tunnelPath?: string, projectDir: string = process.cwd(), ): void { // Next.js sets `NEXT_PHASE` only after it loads the config. @@ -116,26 +115,25 @@ export function setUpManagedTunnelVercelRoute( } const originalRewrites = userNextConfig.rewrites; - // Next.js adds the `basePath` and an optional trailing slash to the source. - const tunnelRewrite = tunnelPath - ? { source: tunnelPath, destination: TUNNEL_ROUTE_HANDLER_PATH } - : { - // The paths `getTunnelPath` derives from a DSN. - source: '/:sentryTunnel([a-z0-9]{1,7})', - has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], - destination: TUNNEL_ROUTE_HANDLER_PATH, - }; + const tunnelRewrite = { + // Next.js adds the `basePath`. The content type keeps other requests out, and the handler forwards only envelopes + // for allowed DSNs. + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: TUNNEL_ROUTE_HANDLER_PATH, + }; + // `afterFiles`: the app's own routes win. userNextConfig.rewrites = async () => { const originalRewritesResult = typeof originalRewrites === 'function' ? await originalRewrites() : []; - const rewrites = Array.isArray(originalRewritesResult) - ? { afterFiles: originalRewritesResult } - : originalRewritesResult; - - // The path the user picked wins over a `public/` file or a page at it. The derived paths come after the app's own - // routes, since one of them could take such a short path. - return tunnelPath - ? { ...rewrites, beforeFiles: [tunnelRewrite, ...(rewrites.beforeFiles || [])] } - : { ...rewrites, afterFiles: [...(rewrites.afterFiles || []), tunnelRewrite] }; + + if (Array.isArray(originalRewritesResult)) { + return { afterFiles: [...originalRewritesResult, tunnelRewrite] }; + } + + return { + ...originalRewritesResult, + afterFiles: [...(originalRewritesResult.afterFiles || []), tunnelRewrite], + }; }; } diff --git a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts index 971c0d31d0cf..21162b901a20 100644 --- a/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/getFinalConfigObjectUtils.test.ts @@ -3,7 +3,11 @@ import type { NextConfigObject } from '../../../src/config/types'; import { warnIfMissingInstrumentationFile } from '../../../src/config/withSentryConfig/getFinalConfigObjectUtils'; import { makeProject, removeProjects } from './projectFixture'; -const FIXED_PATH_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const TUNNEL_REWRITE = { + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel', +}; afterEach(() => { removeProjects(); @@ -58,30 +62,22 @@ describe('maybeSetUpTunnelRouteRewriteRules', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('The `tunnelRoute` option is deprecated')); }); - it('rewrites the `tunnelRoute` path to the tunnel route handler on Vercel', async () => { - stubVercelBuild(); - const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); - const nextConfig: NextConfigObject = {}; - - maybeSetUpTunnelRouteRewriteRules(nextConfig, { tunnelRoute: '/monitoring' }); - - await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [FIXED_PATH_REWRITE], afterFiles: [] }); - }); - it.each([ ['`tunnelRoute` is not set', {}], ['`tunnelRoute` is `true`', { tunnelRoute: true }], - ])('rewrites the paths derived from a DSN on Vercel when %s', async (_description, sentryOptions) => { - stubVercelBuild(); - const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); - const nextConfig: NextConfigObject = {}; - - maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); - - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - afterFiles: [expect.objectContaining({ source: '/:sentryTunnel([a-z0-9]{1,7})', destination: '/sentry-tunnel' })], - }); - }); + ['`tunnelRoute` is a path', { tunnelRoute: '/monitoring' }], + ])( + 'rewrites envelope requests to the tunnel route handler on Vercel when %s', + async (_description, sentryOptions) => { + stubVercelBuild(); + const { maybeSetUpTunnelRouteRewriteRules } = await setUp(['app/page.tsx']); + const nextConfig: NextConfigObject = {}; + + maybeSetUpTunnelRouteRewriteRules(nextConfig, sentryOptions); + + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [TUNNEL_REWRITE] }); + }, + ); it('does not warn about the deprecated option when `tunnelRoute` is not set', async () => { const { maybeSetUpTunnelRouteRewriteRules, warnSpy } = await setUp(['app/page.tsx']); diff --git a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts index dcf866a0025f..e3bd97ed61fd 100644 --- a/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts +++ b/packages/nextjs/test/config/withSentryConfig/tunnel.test.ts @@ -9,7 +9,11 @@ import { } from '../../../src/config/withSentryConfig/tunnel'; import { makeProject, removeProjects } from './projectFixture'; -const FIXED_PATH_REWRITE = { source: '/monitoring', destination: '/sentry-tunnel' }; +const TUNNEL_REWRITE = { + source: '/:path*', + has: [{ type: 'header', key: 'content-type', value: 'application/x-sentry-envelope' }], + destination: '/sentry-tunnel', +}; afterEach(() => { removeProjects(); @@ -104,14 +108,12 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.js'), 'utf8')).toContain( "export { POST } from '@sentry/nextjs/tunnel';", ); - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - afterFiles: [expect.objectContaining({ destination: '/sentry-tunnel' })], - }); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [TUNNEL_REWRITE] }); }); it.each([ @@ -123,7 +125,7 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject([file]); const nextConfig: NextConfigObject = {}; - setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); @@ -138,7 +140,7 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = {}; - setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); @@ -148,45 +150,32 @@ describe('setUpManagedTunnelVercelRoute', () => { const projectDir = makeProject(['app/page.tsx']); const nextConfig: NextConfigObject = { output: 'export' }; - setUpManagedTunnelVercelRoute(nextConfig, undefined, projectDir); + setUpManagedTunnelVercelRoute(nextConfig, projectDir); expect(hasRouteFile(projectDir)).toBe(false); expect(nextConfig.rewrites).toBeUndefined(); }); - it('rewrites the `tunnelRoute` path to the tunnel route handler before files', async () => { - const projectDir = makeProject(['app/page.tsx']); - const nextConfig: NextConfigObject = {}; - - setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', projectDir); - - expect(fs.existsSync(path.join(projectDir, 'app/sentry-tunnel/route.js'))).toBe(true); - await expect(nextConfig.rewrites?.()).resolves.toEqual({ beforeFiles: [FIXED_PATH_REWRITE], afterFiles: [] }); - }); - - it('keeps the user rewrites in array form as `afterFiles`', async () => { + it('appends the rewrite to the user rewrites in array form', async () => { const userRewrite = { source: '/blog/:slug', destination: '/news/:slug' }; const nextConfig: NextConfigObject = { rewrites: async () => [userRewrite] }; - setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', makeProject(['app/page.tsx'])); + setUpManagedTunnelVercelRoute(nextConfig, makeProject(['app/page.tsx'])); - await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [FIXED_PATH_REWRITE], - afterFiles: [userRewrite], - }); + await expect(nextConfig.rewrites?.()).resolves.toEqual({ afterFiles: [userRewrite, TUNNEL_REWRITE] }); }); - it('puts the `tunnelRoute` rewrite before the user `beforeFiles` rewrites', async () => { - const userBeforeFilesRewrite = { source: '/old', destination: '/new' }; + it('appends the rewrite to the user `afterFiles` rewrites and keeps the other phases', async () => { + const userAfterFilesRewrite = { source: '/old', destination: '/new' }; const userFallbackRewrite = { source: '/:path*', destination: 'https://legacy.example.com/:path*' }; const nextConfig: NextConfigObject = { - rewrites: async () => ({ beforeFiles: [userBeforeFilesRewrite], fallback: [userFallbackRewrite] }), + rewrites: async () => ({ afterFiles: [userAfterFilesRewrite], fallback: [userFallbackRewrite] }), }; - setUpManagedTunnelVercelRoute(nextConfig, '/monitoring', makeProject(['app/page.tsx'])); + setUpManagedTunnelVercelRoute(nextConfig, makeProject(['app/page.tsx'])); await expect(nextConfig.rewrites?.()).resolves.toEqual({ - beforeFiles: [FIXED_PATH_REWRITE, userBeforeFilesRewrite], + afterFiles: [userAfterFilesRewrite, TUNNEL_REWRITE], fallback: [userFallbackRewrite], }); }); @@ -194,7 +183,7 @@ describe('setUpManagedTunnelVercelRoute', () => { it('keeps the tunnel route handler of the app', () => { const projectDir = makeProject(['app/page.tsx', 'app/sentry-tunnel/route.ts']); - setUpManagedTunnelVercelRoute({}, '/monitoring', projectDir); + setUpManagedTunnelVercelRoute({}, projectDir); expect(fs.readFileSync(path.join(projectDir, 'app/sentry-tunnel/route.ts'), 'utf8')).toBe(''); expect(fs.existsSync(path.join(projectDir, 'app/sentry-tunnel/route.js'))).toBe(false); From a888c609206f94bc84d4d1e49f52c6a1ac158827 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 12:16:49 +0100 Subject: [PATCH 16/18] fix(nextjs): Send a fixed `tunnelRoute` path with the envelope content type The browser sends `application/x-sentry-envelope` only for a managed tunnel given as `true` or options. A fixed path injected as a string went out as `text/plain`, which the Vercel rewrite doesn't match. --- .../nextjs-16-tunnel/tests/tunnel-route.test.ts | 2 ++ packages/nextjs/src/client/tunnelRoute.ts | 16 +++++++++++----- packages/nextjs/test/clientSdk.test.ts | 4 ++++ packages/nextjs/test/utils/tunnelRoute.test.ts | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index 4103fe7997bd..f75d167f9deb 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -86,6 +86,8 @@ test('forwards events without the cookies of the browser', async ({ page, contex const proxyRequest = await proxyRequestPromise; expect(isTunnelPath(new URL(tunnelRequest.url()).pathname)).toBe(true); + // The Vercel rewrite only matches envelope requests. + expect((await tunnelRequest.allHeaders())['content-type']).toBe('application/x-sentry-envelope'); expect((await tunnelRequest.allHeaders()).cookie).toBe('session=8f2c1d'); expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); expect(proxyRequest.rawProxyRequestHeaders['content-type']).toBe('application/x-sentry-envelope'); diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index d0c6cef6eae6..d09fa8e53e40 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -3,7 +3,8 @@ import { getInjectedTunnelRoute } from '../common/utils/tunnelPathnameMatch'; /** * Sends events through the managed tunnel when `withSentryConfig` has a `tunnelRoute`, unless `Sentry.init` sets its - * own `tunnel`. A fixed path gets the trailing slash Next.js would otherwise redirect a POST to. + * own `tunnel`. A fixed path goes in as `path`, so the browser sends the envelope content type the Vercel rewrite + * matches, and gets the trailing slash Next.js would otherwise redirect a POST to. */ export function applyTunnelRouteOption(options: BrowserOptions): void { const tunnelRoute = getInjectedTunnelRoute(); @@ -11,8 +12,13 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { return; } - options.tunnel = - typeof tunnelRoute === 'string' && process.env._sentryTrailingSlash && !tunnelRoute.endsWith('/') - ? `${tunnelRoute}/` - : tunnelRoute; + if (tunnelRoute === true) { + options.tunnel = true; + return; + } + + options.tunnel = { + path: tunnelRoute, + trailingSlash: !!process.env._sentryTrailingSlash && !tunnelRoute.endsWith('/'), + }; } diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index 76875a0a563f..cf0b8eca9568 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -274,6 +274,10 @@ describe('Client init()', () => { const client = init({ dsn: TEST_DSN }); expect(client?.getOptions().tunnel).toBe('/docs/monitoring'); + // The Vercel rewrite only matches envelope requests. + expect(client?.getOptions().transportOptions?.headers).toEqual({ + 'Content-Type': 'application/x-sentry-envelope', + }); }); it("sends events to the path derived from the DSN under the app's `basePath` for `tunnelRoute: true`", () => { diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index 1cd58995ddc0..05ed6fdd4c22 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -23,7 +23,7 @@ describe('applyTunnelRouteOption()', () => { applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/docs/monitoring'); + expect(options.tunnel).toEqual({ path: '/docs/monitoring', trailingSlash: false }); }); it('sends events to the path derived from the DSN for `tunnelRoute: true`', () => { @@ -42,7 +42,7 @@ describe('applyTunnelRouteOption()', () => { applyTunnelRouteOption(options); - expect(options.tunnel).toBe('/monitoring/'); + expect(options.tunnel).toEqual({ path: '/monitoring', trailingSlash: true }); }); it('keeps the `tunnel` the user set', () => { From a1287212cf014ef98700a1968dbf310aae2d9c7d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 13:33:34 +0100 Subject: [PATCH 17/18] fix(nextjs): Drop the Next.js spans of tunnel requests on the paths derived from the DSN The Next.js span filters only knew a fixed `tunnelRoute` path, and the `url.path` filters of the managed tunnel don't match Next.js request spans, which carry only `http.target` at start. A proxy that runs outside of the server, like on Vercel, sent its spans of tunnel requests. The filters now come from the paths the client serves. --- .../nextjs-16-adapter/next.config.ts | 2 +- .../nextjs-16-adapter/proxy.ts | 3 +- .../tests/proxy-tunnel.test.ts | 8 +-- .../utils/dropMiddlewareTunnelRequests.ts | 48 ++++++++----- .../src/common/utils/tunnelPathnameMatch.ts | 8 +-- packages/nextjs/src/edge/index.ts | 3 +- packages/nextjs/src/server/index.ts | 4 +- packages/nextjs/test/edgeSdk.test.ts | 38 ++++++++++- packages/nextjs/test/serverSdk.test.ts | 39 ++++++++++- .../dropMiddlewareTunnelRequests.test.ts | 68 ++++++++++++------- 10 files changed, 167 insertions(+), 54 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts index 660eddbbfe1f..a71f436dd215 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/next.config.ts @@ -7,5 +7,5 @@ const nextConfig: NextConfig = { export default withSentryConfig(nextConfig, { silent: true, - tunnelRoute: '/monitoring', + tunnelRoute: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts index b5cda0cfaa92..23b61c572e9a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/proxy.ts @@ -20,5 +20,6 @@ export async function proxy(request: NextRequest) { } export const config = { - matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'], + // Every path, so the proxy also runs for the tunnel path, which is derived from the DSN. + matcher: '/:path*', }; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts index 04b8c3ee3d41..2e0ddf911ba3 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-adapter/tests/proxy-tunnel.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; import { HTTP_TARGET } from '@sentry/conventions/attributes'; import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/nextjs'; test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => { // The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array. @@ -20,13 +21,12 @@ test('drops the `Next.request` root span of tunnel requests through the proxy', // The SDK answers only a POST before the proxy. A GET reaches the proxy, like a tunnel request on Vercel, where the // proxy runs outside of the server the SDK hooks into. - const tunnelResponse = await request.get('/monitoring'); + const tunnelPath = getTunnelPath(process.env.E2E_TEST_DSN); + const tunnelResponse = await request.get(tunnelPath!); expect(tunnelResponse.headers()['x-middleware-next']).toBe('1'); await request.get('/api/proxy-ok'); await proxyOkSpanPromise; - expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual( - [], - ); + expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith(tunnelPath!))).toEqual([]); }); diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 2ce44416eba2..787a274ca9a9 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -1,9 +1,15 @@ import { HTTP_TARGET, URL_FULL, URL_PATH, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; -import type { ClientOptions, RawAttributes } from '@sentry/core'; -import { escapeStringForRegex, getClient, isSentryRequestUrl, type Span } from '@sentry/core'; +import type { Client, ClientOptions, RawAttributes } from '@sentry/core'; +import { + _INTERNAL_normalizeTunnelBasePath as normalizeTunnelBasePath, + escapeStringForRegex, + getClient, + isSentryRequestUrl, + type Span, +} from '@sentry/core'; +import { _INTERNAL_getServedTunnel as getServedTunnel } from '@sentry/core/server'; import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../nextSpanAttributes'; import { - getInjectedTunnelRoute, getTunnelRouteRequestPattern, isPathnameUnderSentryTunnelRoute, TUNNEL_ROUTE_HANDLER_PATH, @@ -11,12 +17,18 @@ import { import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-with-logic-attached'; /** - * The fixed path of the `tunnelRoute`. The path derived from the DSN isn't known here. + * The paths of the managed tunnel the client serves, with the `basePath`: the ones derived from the DSNs, or the fixed + * one. */ -function getFixedTunnelPath(): string | undefined { - const tunnelRoute = getInjectedTunnelRoute(); +function getServedTunnelPaths(client: Client | undefined = getClient()): string[] { + const servedTunnel = getServedTunnel(client); + if (!servedTunnel) { + return []; + } + + const basePath = normalizeTunnelBasePath(servedTunnel.basePath); - return typeof tunnelRoute === 'string' ? tunnelRoute : undefined; + return servedTunnel.paths.map(path => `${basePath}${path}`); } /** @@ -75,8 +87,8 @@ function isSentryRequestSpan(attrs: RawAttributes>): boo * Checks if a span's HTTP target matches the tunnel route. */ export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPath = getFixedTunnelPath(); - if (!tunnelPath) { + const tunnelPaths = getServedTunnelPaths(); + if (!tunnelPaths.length) { return false; } @@ -89,21 +101,23 @@ export function isTunnelRouteSpan(spanAttributes: Record): bool // Extract pathname from the target (e.g., "/tunnel?o=123&p=456" -> "/tunnel") const pathname = target.split('?')[0] || ''; - return isPathnameUnderSentryTunnelRoute(pathname, tunnelPath); + return tunnelPaths.some(tunnelPath => isPathnameUnderSentryTunnelRoute(pathname, tunnelPath)); } return false; } /** - * Returns `ignoreSpans` entries for the spans of incoming tunnel requests. + * Returns `ignoreSpans` entries for the spans of incoming requests to the tunnel the client serves. Call it after + * `init`, which sets up the served tunnel. * - * Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. The entries match only - * Next.js request spans, so other spans with the same path are kept. + * Span streaming checks `ignoreSpans` at span start, before `dropMiddlewareTunnelRequests` runs. Next.js sets only + * `http.target` then, which the `url.path` filters of the managed tunnel don't read. The entries match only Next.js + * request spans, so other spans with the same path are kept. */ -export function getTunnelRouteIgnoreSpans(): NonNullable { - const tunnelPath = getFixedTunnelPath(); - if (!tunnelPath) { +export function getTunnelRouteIgnoreSpans(client: Client | undefined): NonNullable { + const tunnelPaths = getServedTunnelPaths(client); + if (!tunnelPaths.length) { return []; } @@ -114,7 +128,7 @@ export function getTunnelRouteIgnoreSpans(): NonNullable = [ // (set in `dropMiddlewareTunnelRequests` during `spanStart`) { attributes: { [TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION]: true } }, - ...getTunnelRouteIgnoreSpans(), ]; opts.ignoreSpans = [...(opts.ignoreSpans || []), ...nextjsIgnoreSpans]; @@ -155,6 +154,8 @@ export function init(options: VercelEdgeOptions = {}): void { } const client = vercelEdgeInit(opts); + const clientOptions = client.getOptions(); + clientOptions.ignoreSpans = [...(clientOptions.ignoreSpans || []), ...getTunnelRouteIgnoreSpans(client)]; // Next.js's OTel instrumentation samples root spans before the Sentry middleware wrapper can set // `normalizedRequest` on the isolation scope. Seed it from span attributes so `tracesSampler` has access. diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts index 00cf3cb5332e..1d21332676c3 100644 --- a/packages/nextjs/src/server/index.ts +++ b/packages/nextjs/src/server/index.ts @@ -168,7 +168,7 @@ export function init(options: NodeOptions): NodeClient | undefined { ...cloudflareConfig, }; - opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS, ...getTunnelRouteIgnoreSpans()]; + opts.ignoreSpans = [...(opts.ignoreSpans || []), ...NEXTJS_SERVER_IGNORE_SPANS]; // Before `init`, which applies it to the managed tunnel of `serveTunnelIntegration` or `tunnelRoute`. const tunnelRoute = getTunnelRouteServerOptions(); @@ -199,6 +199,8 @@ export function init(options: NodeOptions): NodeClient | undefined { if (client) { addNextjsServerSpanHooks(client); + const clientOptions = client.getOptions(); + clientOptions.ignoreSpans = [...(clientOptions.ignoreSpans || []), ...getTunnelRouteIgnoreSpans(client)]; } else if (isOwnedByCloudflare) { setCloudflareWorkerRelease(opts.release); } diff --git a/packages/nextjs/test/edgeSdk.test.ts b/packages/nextjs/test/edgeSdk.test.ts index 68345ed992ab..dde1de3cdff9 100644 --- a/packages/nextjs/test/edgeSdk.test.ts +++ b/packages/nextjs/test/edgeSdk.test.ts @@ -1,5 +1,13 @@ import type { Integration } from '@sentry/core'; -import { getGlobalScope, getMainCarrier, getTunnelPath, GLOBAL_OBJ, withIsolationScope } from '@sentry/core'; +import { + getClient, + getGlobalScope, + getMainCarrier, + getTunnelPath, + GLOBAL_OBJ, + shouldIgnoreSpan, + withIsolationScope, +} from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import * as SentryVercelEdge from '@sentry/vercel-edge'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -133,6 +141,34 @@ describe('Edge init()', () => { fetchSpy.mockRestore(); }); + it.each([ + ['`tunnelRoute: true`', () => ({ tunnelRoute: 'true', integrations: [] })], + ['`serveTunnelIntegration`', () => ({ tunnelRoute: undefined, integrations: [serveTunnelIntegration()] })], + ])('ignores the Next.js request spans of the tunnel path derived from the DSN with %s', (_description, setUp) => { + const { tunnelRoute, integrations } = setUp(); + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = tunnelRoute; + + init({ dsn: TEST_DSN, integrations }); + + const ignoreSpans = getClient()?.getOptions().ignoreSpans; + // Next.js sets only `http.target` at span start, e.g. for a proxy that runs outside of the server on Vercel. + const isIgnored = (spanType: string, target: string): boolean => + shouldIgnoreSpan( + { + description: 'middleware POST', + op: 'http.server', + attributes: { 'next.span_type': spanType, 'http.target': target }, + }, + ignoreSpans, + ); + const tunnelPath = `/docs${getTunnelPath(TEST_DSN)}`; + expect(isIgnored('Middleware.execute', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', `${tunnelPath}/`)).toBe(true); + expect(isIgnored('BaseServer.handleRequest', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', '/docs/pricing')).toBe(false); + }); + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { globalWithInjectedValues._sentryBasePath = '/docs'; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); diff --git a/packages/nextjs/test/serverSdk.test.ts b/packages/nextjs/test/serverSdk.test.ts index 5f03569ce97a..559cc23e3ca6 100644 --- a/packages/nextjs/test/serverSdk.test.ts +++ b/packages/nextjs/test/serverSdk.test.ts @@ -1,5 +1,12 @@ import type { Integration } from '@sentry/core'; -import { GLOBAL_OBJ, getMainCarrier, getTunnelPath, withIsolationScope } from '@sentry/core'; +import { + GLOBAL_OBJ, + getClient, + getMainCarrier, + getTunnelPath, + shouldIgnoreSpan, + withIsolationScope, +} from '@sentry/core'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import { close, getCurrentScope, getGlobalScope } from '@sentry/node'; import * as SentryNode from '@sentry/node'; @@ -83,6 +90,7 @@ describe('Server init()', () => { nodeInit.mockReturnValueOnce({ on: vi.fn(), addEventProcessor: vi.fn(), + getOptions: () => ({}), traceProvider, } as unknown as ReturnType); @@ -95,6 +103,7 @@ describe('Server init()', () => { nodeInit.mockReturnValueOnce({ on: vi.fn(), addEventProcessor: vi.fn(), + getOptions: () => ({}), traceProvider: undefined, } as unknown as ReturnType); @@ -141,6 +150,34 @@ describe('Server init()', () => { fetchSpy.mockRestore(); }); + it.each([ + ['`tunnelRoute: true`', () => ({ tunnelRoute: 'true', integrations: [] })], + ['`serveTunnelIntegration`', () => ({ tunnelRoute: undefined, integrations: [serveTunnelIntegration()] })], + ])('ignores the Next.js request spans of the tunnel path derived from the DSN with %s', (_description, setUp) => { + const { tunnelRoute, integrations } = setUp(); + globalWithInjectedValues._sentryBasePath = '/docs'; + globalWithInjectedValues._sentryTunnelRoute = tunnelRoute; + + init({ dsn: TEST_DSN, integrations }); + + const ignoreSpans = getClient()?.getOptions().ignoreSpans; + // Next.js sets only `http.target` at span start, e.g. for a proxy that runs outside of the server on Vercel. + const isIgnored = (spanType: string, target: string): boolean => + shouldIgnoreSpan( + { + description: 'middleware POST', + op: 'http.server', + attributes: { 'next.span_type': spanType, 'http.target': target }, + }, + ignoreSpans, + ); + const tunnelPath = `/docs${getTunnelPath(TEST_DSN)}`; + expect(isIgnored('Middleware.execute', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', `${tunnelPath}/`)).toBe(true); + expect(isIgnored('BaseServer.handleRequest', tunnelPath)).toBe(true); + expect(isIgnored('Next.request', '/docs/pricing')).toBe(false); + }); + it("serves the tunnel of `serveTunnelIntegration` under the app's `basePath`", () => { globalWithInjectedValues._sentryBasePath = '/docs'; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}')); diff --git a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts index 09dcb9384300..d13bdfb0a84b 100644 --- a/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts +++ b/packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts @@ -8,9 +8,18 @@ import { import { ATTR_NEXT_ROUTE, ATTR_NEXT_SPAN_TYPE } from '../../src/common/nextSpanAttributes'; import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../../src/common/span-attributes-with-logic-attached'; -const globalWithInjectedValues = global as typeof global & { - _sentryTunnelRoute?: string; -}; +const served = vi.hoisted(() => ({ tunnel: undefined as { paths: string[]; basePath?: string } | undefined })); + +vi.mock('@sentry/core/server', async requireActual => { + return { + ...(await requireActual()), + _INTERNAL_getServedTunnel: () => served.tunnel, + }; +}); + +function serveTunnel(paths: string[], basePath?: string): void { + served.tunnel = { paths, basePath }; +} vi.mock('@sentry/core', async requireActual => { return { @@ -32,13 +41,13 @@ function createMockSpan(): { setAttribute: ReturnType; attributes: } beforeEach(() => { - globalWithInjectedValues._sentryTunnelRoute = undefined; + served.tunnel = undefined; }); describe('dropMiddlewareTunnelRequests', () => { describe('BaseServer.handleRequest spans', () => { it('marks BaseServer.handleRequest span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -50,7 +59,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('marks BaseServer.handleRequest span for dropping when http.target exactly matches tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -62,7 +71,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target does not match tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -74,7 +83,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('does not mark BaseServer.handleRequest span for dropping when http.target shares tunnel path prefix', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -97,7 +106,7 @@ describe('dropMiddlewareTunnelRequests', () => { }); it('handles BaseServer.handleRequest span with basePath prefix in http.target', () => { - globalWithInjectedValues._sentryTunnelRoute = '/basepath/monitoring'; + serveTunnel(['/basepath/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -111,7 +120,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Middleware.execute spans', () => { it('marks middleware span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -125,7 +134,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('Next.request spans', () => { it('marks the proxy root span for dropping when http.target matches tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -139,7 +148,7 @@ describe('dropMiddlewareTunnelRequests', () => { describe('unrelated spans', () => { it('does not process spans without matching span type or origin', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -158,7 +167,7 @@ describe('dropMiddlewareTunnelRequests', () => { getOptions: () => ({ enableOpenTelemetrySetup: false }), } as any); - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const span = createMockSpan(); dropMiddlewareTunnelRequests(span as any, { @@ -175,7 +184,10 @@ describe('dropMiddlewareTunnelRequests', () => { describe('getTunnelRouteIgnoreSpans', () => { function isIgnored(attributes: Record): boolean { - return shouldIgnoreSpan({ description: 'POST', op: 'http.server', attributes }, getTunnelRouteIgnoreSpans()); + return shouldIgnoreSpan( + { description: 'POST', op: 'http.server', attributes }, + getTunnelRouteIgnoreSpans(undefined), + ); } // Next.js sets the deprecated `http.target` on its request spans. @@ -185,13 +197,23 @@ describe('getTunnelRouteIgnoreSpans', () => { } it('returns no entries when there is no tunnel route', () => { - expect(getTunnelRouteIgnoreSpans()).toEqual([]); + expect(getTunnelRouteIgnoreSpans(undefined)).toEqual([]); }); - it('returns no entries for the path derived from the DSN', () => { - globalWithInjectedValues._sentryTunnelRoute = 'true'; + it.each(['/docs/k3x9q2a', '/docs/k3x9q2a/', '/docs/f00ba7'])( + 'ignores a request span for the derived path %s', + target => { + serveTunnel(['/k3x9q2a', '/f00ba7'], '/docs'); + + expect(isIgnored(nextRequestSpanAttributes('Middleware.execute', target))).toBe(true); + expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true); + }, + ); + + it('does not ignore a request span for a derived path outside of the base path', () => { + serveTunnel(['/k3x9q2a'], '/docs'); - expect(getTunnelRouteIgnoreSpans()).toEqual([]); + expect(isIgnored(nextRequestSpanAttributes('Next.request', '/k3x9q2a'))).toBe(false); }); it.each([ @@ -202,7 +224,7 @@ describe('getTunnelRouteIgnoreSpans', () => { ['Next.request', '/monitoring'], ['Next.request', '/monitoring/'], ])('ignores a %s span for %s', (spanType, target) => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); expect(isIgnored(nextRequestSpanAttributes(spanType, target))).toBe(true); }); @@ -210,7 +232,7 @@ describe('getTunnelRouteIgnoreSpans', () => { it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])( 'does not ignore a request span for %s', target => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', target))).toBe(false); expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false); @@ -218,7 +240,7 @@ describe('getTunnelRouteIgnoreSpans', () => { ); it('does not ignore an outgoing request with the tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); const outgoingRequestAttributes = { [URL_PATH]: '/monitoring', @@ -233,7 +255,7 @@ describe('getTunnelRouteIgnoreSpans', () => { it.each(['/sentry-tunnel', '/sentry-tunnel/route'])( 'ignores the route handler span with next.route %s', nextRoute => { - globalWithInjectedValues._sentryTunnelRoute = '/monitoring'; + serveTunnel(['/monitoring']); expect( isIgnored({ [ATTR_NEXT_SPAN_TYPE]: 'AppRouteRouteHandlers.runHandler', [ATTR_NEXT_ROUTE]: nextRoute }), @@ -242,7 +264,7 @@ describe('getTunnelRouteIgnoreSpans', () => { ); it('escapes regex characters in the tunnel path', () => { - globalWithInjectedValues._sentryTunnelRoute = '/a.b'; + serveTunnel(['/a.b']); expect(isIgnored(nextRequestSpanAttributes('BaseServer.handleRequest', '/axb'))).toBe(false); }); From cb3d51728f9d441b3e0a356e18244f0c2d18033d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 11 Nov 2026 14:17:02 +0100 Subject: [PATCH 18/18] ref(nextjs): Match tunnel request spans against the client that started them --- packages/nextjs/src/cloudflare/index.ts | 2 +- .../utils/dropMiddlewareTunnelRequests.ts | 21 +++++++++++-------- packages/nextjs/src/edge/index.ts | 2 +- packages/nextjs/src/server/serverSpanHooks.ts | 2 +- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/nextjs/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 73711f3936c3..182c62276730 100644 --- a/packages/nextjs/src/cloudflare/index.ts +++ b/packages/nextjs/src/cloudflare/index.ts @@ -63,7 +63,7 @@ const nextjsIntegration = (): Integration => ({ registerPrepareSpanScope(client); // `dropMiddlewareTunnelRequests` only marks spans of Next.js, which are children of the root span of `withSentry`. client.on('beforeSampling', samplingData => { - if (isTunnelRouteSpan(samplingData.spanAttributes)) { + if (isTunnelRouteSpan(samplingData.spanAttributes, client)) { samplingData.parentSampled = false; } }); diff --git a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts index 787a274ca9a9..596ff04d7a8d 100644 --- a/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts +++ b/packages/nextjs/src/common/utils/dropMiddlewareTunnelRequests.ts @@ -20,7 +20,7 @@ import { TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION } from '../span-attributes-wit * The paths of the managed tunnel the client serves, with the `basePath`: the ones derived from the DSNs, or the fixed * one. */ -function getServedTunnelPaths(client: Client | undefined = getClient()): string[] { +function getServedTunnelPaths(client: Client | undefined): string[] { const servedTunnel = getServedTunnel(client); if (!servedTunnel) { return []; @@ -40,12 +40,12 @@ function getServedTunnelPaths(client: Client | undefined = getClient()): string[ export function dropMiddlewareTunnelRequests( span: Span, attrs: RawAttributes> | undefined, + client: Client | undefined = getClient(), ): void { // When the user brings their own OTel setup (enableOpenTelemetrySetup: false), we should not // mutate their spans with Sentry-internal attributes as it pollutes their tracing backends. if ( - (getClient()?.getOptions() as { enableOpenTelemetrySetup?: boolean } | undefined)?.enableOpenTelemetrySetup === - false + (client?.getOptions() as { enableOpenTelemetrySetup?: boolean } | undefined)?.enableOpenTelemetrySetup === false ) { return; } @@ -65,8 +65,8 @@ export function dropMiddlewareTunnelRequests( } // Check if this is either a tunnel route request or a Sentry ingest request - const isTunnel = isTunnelRouteSpan(attrs || {}); - const isSentry = isSentryRequestSpan(attrs || {}); + const isTunnel = isTunnelRouteSpan(attrs || {}, client); + const isSentry = isSentryRequestSpan(attrs || {}, client); if (isTunnel || isSentry) { // Mark the span to be dropped @@ -74,20 +74,23 @@ export function dropMiddlewareTunnelRequests( } } -function isSentryRequestSpan(attrs: RawAttributes>): boolean { +function isSentryRequestSpan(attrs: RawAttributes>, client: Client | undefined): boolean { const httpUrl = attrs[URL_FULL]; if (!httpUrl) { return false; } - return isSentryRequestUrl(httpUrl.toString(), getClient()); + return isSentryRequestUrl(httpUrl.toString(), client); } /** * Checks if a span's HTTP target matches the tunnel route. */ -export function isTunnelRouteSpan(spanAttributes: Record): boolean { - const tunnelPaths = getServedTunnelPaths(); +export function isTunnelRouteSpan( + spanAttributes: Record, + client: Client | undefined = getClient(), +): boolean { + const tunnelPaths = getServedTunnelPaths(client); if (!tunnelPaths.length) { return false; } diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index b0647d796cf7..cbbba7242fe3 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -182,7 +182,7 @@ export function init(options: VercelEdgeOptions = {}): void { const rootSpan = getRootSpan(span); const isRootSpan = span === rootSpan; - dropMiddlewareTunnelRequests(span, spanAttributes); + dropMiddlewareTunnelRequests(span, spanAttributes, client); // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it diff --git a/packages/nextjs/src/server/serverSpanHooks.ts b/packages/nextjs/src/server/serverSpanHooks.ts index f243813d7a45..b1ad6f9991c5 100644 --- a/packages/nextjs/src/server/serverSpanHooks.ts +++ b/packages/nextjs/src/server/serverSpanHooks.ts @@ -168,7 +168,7 @@ export function handleOnSpanStart(span: Span, client: Client): void { const rootSpanAttributes = spanToJSON(rootSpan).attributes; const isRootSpan = span === rootSpan; - dropMiddlewareTunnelRequests(span, spanAttributes); + dropMiddlewareTunnelRequests(span, spanAttributes, client); // Next.js names the incoming-request span after the raw URL. With span streaming, span names have to // be low cardinality, so we replace it here at span start; the `next.route` hoisting below renames it