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 @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
// The tunnel forwards to the DSN host, which is the event proxy here
dsn: 'http://public@localhost:3031/1337',
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
"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-canary": "pnpm install && pnpm add next@canary && pnpm build",
Expand All @@ -21,7 +21,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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as Sentry from '@sentry/nextjs';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function proxy(_request: NextRequest) {
export async function proxy(request: NextRequest) {
// Stands in for auth middleware, without blocking the other tests.
if (request.headers.get('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) {
return new NextResponse(null, { status: 401 });
}

return NextResponse.next();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
// The tunnel forwards to the DSN host, which is the event proxy here
dsn: 'http://public@localhost:3031/1337',
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
// debug: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
// Use a fake but properly formatted Sentry SaaS DSN for tunnel route testing
dsn: 'https://public@o12345.ingest.us.sentry.io/67890',
// The tunnel forwards to the DSN host, which is the event proxy here
dsn: 'http://public@localhost:3031/1337',
// No tunnel option - using tunnelRoute from withSentryConfig
tracesSampleRate: 1.0,
// debug: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry/core';
import { getTunnelPath } from '@sentry/core';

const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337');

test('sends events to the DSN-derived path, served by the app', async ({ page }) => {
const tunnelRequests: string[] = [];
page.on('request', request => {
if (request.method() === 'POST') {
tunnelRequests.push(new URL(request.url()).pathname);
}
});

const errorPromise = waitForError('nextjs-16-tunnel', event => {
return event.exception?.values?.[0]?.value === 'Managed tunnel error';
});

await page.goto('/');
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Managed tunnel error');
});
});

expect(await errorPromise).toBeDefined();
expect(tunnelRequests).toContain(TUNNEL_PATH);
});

test('does not create server spans for tunnel requests', async ({ page }) => {
const serverSpans: SerializedStreamedSpan[] = [];
void waitForStreamedSpans('nextjs-16-tunnel', spans => {
serverSpans.push(...spans.filter(span => span.is_segment));
return false;
});

const errorPromise = waitForError('nextjs-16-tunnel', event => {
return event.exception?.values?.[0]?.value === 'Managed tunnel span error';
});

await page.goto('/');
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Managed tunnel span error');
});
});
await errorPromise;

// Spans flush in order, so a span for the tunnel request would arrive before the one for this request.
const anchorSpanPromise = waitForStreamedSpan(
'nextjs-16-tunnel',
span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /',
);
await page.request.get('/');
await anchorSpanPromise;

const tunnelSpans = serverSpans.filter(
span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'),
);
expect(tunnelSpans).toEqual([]);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
logaretm marked this conversation as resolved.
});

test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => {
const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' };
const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`;

expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200);
expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401);
});

test('answers 404 to an envelope POST on any other path', async ({ request }) => {
const response = await request.post('/notatunnel', {
headers: { 'content-type': 'application/x-sentry-envelope' },
data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`,
});

expect(response.status()).toBe(404);
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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',
dsn: 'http://public@localhost:3031/1337',
// Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled.
tunnel: 'http://localhost:3031/',
tracesSampleRate: 1.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import * as Sentry from '@sentry/nextjs';

Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
// The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy
dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL ? 'http://public@localhost:3031/1337' : process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' || process.env.NEXT_RUNTIME === 'edge') {
Sentry.init({
environment: 'qa', // dynamic sampling bias to keep transactions
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
// The tunnel forwards to the DSN host, so the tunnel variant points it at the event proxy
dsn: process.env.NEXT_PUBLIC_E2E_TUNNEL
? 'http://public@localhost:3031/1337'
: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: process.env.NEXT_PUBLIC_E2E_TUNNEL ? undefined : `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
transportOptions: {
// We are doing a lot of events at once in this test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ export async function middleware(request: NextRequest) {
await fetch('http://localhost:3030/');
}

// Stands in for auth middleware, without blocking the other tests.
if (request.headers.has('x-e2e-require-auth') && !Sentry.isTunnelRequest(request)) {
return new NextResponse(null, { status: 401 });
}

return NextResponse.next();
}

// See "Matching Paths" below to learn more
export const config = {
matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'],
// The tunnel path of the test DSN, and a path next to it for the tunnel variant.
matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware', '/qpo967', '/notatunnel'],
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const nextConfig = {

module.exports = withSentryConfig(nextConfig, {
debug: true,
tunnelRoute: !!process.env.NEXT_PUBLIC_E2E_TUNNEL,
sourcemaps: {
deleteSourcemapsAfterUpload: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"test:build": "pnpm install && pnpm build",
"test:test-build": "pnpm ts-node --script-mode assert-build.ts",
"test:build-15": "pnpm install && pnpm add next@15 && pnpm add react@latest && pnpm add react-dom@latest && pnpm build",
"test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev"
"test:assert": "pnpm test:test-build && pnpm test:prod && pnpm test:dev",
"test:build:tunnel": "pnpm install && NEXT_PUBLIC_E2E_TUNNEL=1 pnpm build",
"test:assert:tunnel": "NEXT_PUBLIC_E2E_TUNNEL=1 pnpm test:prod tunnel-dsn-path"
},
"dependencies": {
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
Expand All @@ -37,6 +39,11 @@
{
"build-command": "pnpm test:build-15",
"label": "nextjs-pages-dir (next@15)"
},
{
"build-command": "pnpm test:build:tunnel",
"assert-command": "pnpm test:assert:tunnel",
"label": "nextjs-pages-dir (tunnel)"
}
],
"optionalVariants": []
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { getSpanOp, waitForError, waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';
import type { SerializedStreamedSpan } from '@sentry/core';
import { getTunnelPath } from '@sentry/core';

const TUNNEL_PATH = getTunnelPath('http://public@localhost:3031/1337');

test.skip(!process.env.NEXT_PUBLIC_E2E_TUNNEL, 'Only runs with `tunnelRoute`');

test('sends events to the DSN-derived path, served by the app', async ({ page }) => {
const tunnelRequests: string[] = [];
page.on('request', request => {
if (request.method() === 'POST') {
tunnelRequests.push(new URL(request.url()).pathname);
}
});

const errorPromise = waitForError('nextjs-pages-dir', event => {
return event.exception?.values?.[0]?.value === 'Managed tunnel error';
});

await page.goto('/');
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Managed tunnel error');
});
});

expect(await errorPromise).toBeDefined();
expect(tunnelRequests).toContain(TUNNEL_PATH);
});

test('does not create server spans for tunnel requests', async ({ page }) => {
const serverSpans: SerializedStreamedSpan[] = [];
void waitForStreamedSpans('nextjs-pages-dir', spans => {
serverSpans.push(...spans.filter(span => span.is_segment));
return false;
});

const errorPromise = waitForError('nextjs-pages-dir', event => {
return event.exception?.values?.[0]?.value === 'Managed tunnel span error';
});

await page.goto('/');
await page.evaluate(() => {
setTimeout(() => {
throw new Error('Managed tunnel span error');
});
});
await errorPromise;

// Spans flush in order, so a span for the tunnel request would arrive before the one for this request.
const anchorSpanPromise = waitForStreamedSpan(
'nextjs-pages-dir',
span => span.is_segment && getSpanOp(span) === 'http.server' && span.name === 'GET /',
);
await page.request.get('/');
await anchorSpanPromise;

const tunnelSpans = serverSpans.filter(
span => span.name.includes(TUNNEL_PATH!) || span.name.includes('sentry-tunnel'),
);
expect(tunnelSpans).toEqual([]);
});
Comment thread
cursor[bot] marked this conversation as resolved.

test('lets tunnel requests through auth middleware with `isTunnelRequest`', async ({ request }) => {
const headers = { 'content-type': 'application/x-sentry-envelope', 'x-e2e-require-auth': '1' };
const data = `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`;

expect((await request.post(TUNNEL_PATH!, { headers, data })).status()).toBe(200);
expect((await request.post('/notatunnel', { headers, data })).status()).toBe(401);
});

test('answers 404 to an envelope POST on any other path', async ({ request }) => {
const response = await request.post('/notatunnel', {
headers: { 'content-type': 'application/x-sentry-envelope' },
data: `${JSON.stringify({ dsn: 'http://public@localhost:3031/1337' })}\n`,
});

expect(response.status()).toBe(404);
});
12 changes: 12 additions & 0 deletions packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
"types": "./build/types/config/index.d.ts",
"default": "./build/cjs/config/index.js"
},
"./tunnel": {
"types": "./build/types/tunnel/index.d.ts",
"import": "./build/esm/tunnel/index.js",
"require": "./build/cjs/tunnel/index.js"
},
"./async-storage-shim": {
"import": {
"default": "./build/esm/config/templates/requestAsyncStorageShim.js"
Expand All @@ -73,6 +78,13 @@
"default": "./build/orchestrion-runtime/*.js"
}
},
"typesVersions": {
"*": {
"tunnel": [
"build/types/tunnel/index.d.ts"
]
}
},
"publishConfig": {
"access": "public"
},
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default [
'src/edge/index.ts',
'src/cloudflare/index.ts',
'src/config/index.ts',
'src/tunnel/index.ts',
],

// prevent this internal nextjs code from ending up in our built package (this doesn't happen automatically because
Expand Down
29 changes: 4 additions & 25 deletions packages/nextjs/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
import { debug, dsnFromString, GLOBAL_OBJ } from '@sentry/core';
import type { BrowserOptions } from '@sentry/react';
import { DEBUG_BUILD } from '../common/debug-build';

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
_sentryRewritesTunnelPath?: string;
};
import { getInjectedManagedTunnelOptions } from '../common/managedTunnelRoute';

/**
* Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option.
*/
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;
}
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;
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 tunnelOptions = options.tunnel === undefined && getInjectedManagedTunnelOptions();
if (tunnelOptions) {
options.tunnel = tunnelOptions;
}
}
Loading
Loading