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 4e887a8425ec..938e3ddbef8f 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", @@ -72,6 +73,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*', +}; diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 9f1ed4e4f768..c3d9361201d7 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -2,13 +2,20 @@ // 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'; 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'; @@ -24,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; }; @@ -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 = [ @@ -104,7 +112,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/src/cloudflare/index.ts b/packages/nextjs/src/cloudflare/index.ts index 9c43cff4b5a3..3eb6c9f4296e 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'; @@ -69,6 +71,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/types.ts b/packages/nextjs/src/config/types.ts index 7c990a480e51..608509216716 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 @@ -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/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/config/withSentryConfig/getFinalConfigObjectUtils.ts b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts index a90529b8d24d..af045ed1d30b 100644 --- a/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts +++ b/packages/nextjs/src/config/withSentryConfig/getFinalConfigObjectUtils.ts @@ -13,11 +13,13 @@ import { getGitRevision, getInstrumentationClientFileContents } from './buildTim import { findTunnelRouteHandlerFile, resolveTunnelRoute, + setUpManagedTunnelVercelRoute, setUpTunnelHandlerRewrite, setUpTunnelRewriteRules, } from './tunnel'; let showedExportModeTunnelWarning = false; +let showedDeprecatedTunnelRouteWarning = false; let showedUnusedTunnelRouteHandlerWarning = false; let showedTunnelRouteHandlerModeLog = false; let showedExperimentalBuildModeWarning = false; @@ -45,12 +47,26 @@ 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; + } + maybeWarnAboutUnusedTunnelRouteHandler(incomingUserNextConfigObject, userSentryOptions.silent); 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; } @@ -65,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 d0b45bd6ec8c..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 {}; @@ -200,3 +201,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/edge/index.ts b/packages/nextjs/src/edge/index.ts index e479fa45bd47..2eba3077389a 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/src/tunnel/index.ts b/packages/nextjs/src/tunnel/index.ts index d86dd2d0a99d..3c6b6278f3fe 100644 --- a/packages/nextjs/src/tunnel/index.ts +++ b/packages/nextjs/src/tunnel/index.ts @@ -1,9 +1,10 @@ -import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core'; +import { handleTunnelRequest } from '@sentry/core'; +import { _INTERNAL_getTunnelPaths as getTunnelPaths } from '@sentry/core/server'; 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[]; } @@ -25,26 +26,15 @@ 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 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,25 +42,27 @@ 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 * // 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; -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)] : []; - }); -} +/** + * 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 }); 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(); }); 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/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')); }); }); 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({}); 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(); }); });