Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Original file line number Diff line number Diff line change
@@ -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([]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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*',
};
14 changes: 11 additions & 3 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_applyBrowserTunnelDefaults as applyBrowserTunnelDefaults,
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';
Expand All @@ -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;
};
Expand Down Expand Up @@ -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 = applyBrowserTunnelDefaults(opts.tunnel, { basePath: getInjectedBasePath() });
applySdkMetadata(opts, 'nextjs', ['nextjs', 'react']);

opts.ignoreSpans = [
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions packages/nextjs/src/cloudflare/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_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 { dropReactControlFlowErrorsEventProcessor } from '../common/utils/dropReactControlFlowErrors';
import { markAsyncContextOwnedByNextjsCloudflare } from '../common/utils/responseEnd';
Expand Down Expand Up @@ -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.
setTunnelDefaults({ basePath: getInjectedBasePath() });
options.ignoreSpans = [
...(options.ignoreSpans || []),
...NEXTJS_SERVER_IGNORE_SPANS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -12,7 +13,6 @@ type OriginalStackFrameResponse = {
};

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryBasePath?: string;
_sentryNextJsVersion: string | undefined;
};

Expand All @@ -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(/^\//)) {
Expand Down
13 changes: 13 additions & 0 deletions packages/nextjs/src/common/utils/basePath.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function constructTurbopackConfig({
nextJsVersion,
tunnelRouteValues: getTunnelRouteInjectedValues(userNextConfig, userSentryOptions),
vercelCronsConfig,
basePath: userNextConfig.basePath,
});

for (const { matcher, rule } of valueInjectionRules) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JSONValue> = { ...tunnelRouteValues };
Expand All @@ -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);
}
Expand Down
7 changes: 5 additions & 2 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
Loading
Loading