Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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