Skip to content

Commit 2e652f3

Browse files
authored
feat(nextjs): Support Next.js proxy files (#17926)
- Supports providing a `proxy.ts` file for global middleware as `middleware.ts` will be deprecated with Next.js 16 - Forks Isolation Scope on span start in the edge SDK as we don't wrap middleware/proxy files anymore when using turbopack - Adds middleware e2e tests for next-16 closes #17894
1 parent 55f03e0 commit 2e652f3

File tree

13 files changed

+206
-8
lines changed

13 files changed

+206
-8
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
3+
public-hoist-pattern[]=*import-in-the-middle*
4+
public-hoist-pattern[]=*require-in-the-middle*
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function GET() {
2+
return Response.json({ name: 'John Doe' });
3+
}

dev-packages/e2e-tests/test-applications/nextjs-16/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
},
2323
"dependencies": {
2424
"@sentry/nextjs": "latest || *",
25+
"@sentry/core": "latest || *",
2526
"ai": "^3.0.0",
2627
"import-in-the-middle": "^1",
2728
"next": "16.0.0-beta.0",
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getDefaultIsolationScope } from '@sentry/core';
2+
import * as Sentry from '@sentry/nextjs';
3+
import { NextResponse } from 'next/server';
4+
import type { NextRequest } from 'next/server';
5+
6+
export async function proxy(request: NextRequest) {
7+
Sentry.setTag('my-isolated-tag', true);
8+
Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope
9+
10+
if (request.headers.has('x-should-throw')) {
11+
throw new Error('Middleware Error');
12+
}
13+
14+
if (request.headers.has('x-should-make-request')) {
15+
await fetch('http://localhost:3030/');
16+
}
17+
18+
return NextResponse.next();
19+
}
20+
21+
// See "Matching Paths" below to learn more
22+
export const config = {
23+
matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'],
24+
};

dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ Sentry.init({
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
9+
// debug: true,
910
});

dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ Sentry.init({
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
88
sendDefaultPii: true,
9+
// debug: true,
910
integrations: [Sentry.vercelAIIntegration()],
1011
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test('Should create a transaction for middleware', async ({ request }) => {
5+
const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
6+
return transactionEvent?.transaction === 'middleware GET';
7+
});
8+
9+
const response = await request.get('/api/endpoint-behind-middleware');
10+
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
11+
12+
const middlewareTransaction = await middlewareTransactionPromise;
13+
14+
expect(middlewareTransaction.contexts?.trace?.status).toBe('ok');
15+
expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware');
16+
expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge');
17+
expect(middlewareTransaction.transaction_info?.source).toBe('url');
18+
19+
// Assert that isolation scope works properly
20+
expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true);
21+
expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
22+
});
23+
24+
test('Faulty middlewares', async ({ request }) => {
25+
const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
26+
return transactionEvent?.transaction === 'middleware GET';
27+
});
28+
29+
const errorEventPromise = waitForError('nextjs-16', errorEvent => {
30+
return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error';
31+
});
32+
33+
request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => {
34+
// Noop
35+
});
36+
37+
await test.step('should record transactions', async () => {
38+
const middlewareTransaction = await middlewareTransactionPromise;
39+
expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error');
40+
expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware');
41+
expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge');
42+
expect(middlewareTransaction.transaction_info?.source).toBe('url');
43+
});
44+
45+
await test.step('should record exceptions', async () => {
46+
const errorEvent = await errorEventPromise;
47+
48+
// Assert that isolation scope works properly
49+
expect(errorEvent.tags?.['my-isolated-tag']).toBe(true);
50+
expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
51+
// this differs between webpack and turbopack
52+
expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction);
53+
});
54+
});
55+
56+
test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => {
57+
const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => {
58+
return (
59+
transactionEvent?.transaction === 'middleware GET' &&
60+
!!transactionEvent.spans?.find(span => span.op === 'http.client')
61+
);
62+
});
63+
64+
request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => {
65+
// Noop
66+
});
67+
68+
const middlewareTransaction = await middlewareTransactionPromise;
69+
70+
expect(middlewareTransaction.spans).toEqual(
71+
expect.arrayContaining([
72+
{
73+
data: {
74+
'http.method': 'GET',
75+
'http.response.status_code': 200,
76+
type: 'fetch',
77+
url: 'http://localhost:3030/',
78+
'http.url': 'http://localhost:3030/',
79+
'server.address': 'localhost:3030',
80+
'sentry.op': 'http.client',
81+
'sentry.origin': 'auto.http.wintercg_fetch',
82+
},
83+
description: 'GET http://localhost:3030/',
84+
op: 'http.client',
85+
origin: 'auto.http.wintercg_fetch',
86+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
87+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
88+
start_timestamp: expect.any(Number),
89+
status: 'ok',
90+
timestamp: expect.any(Number),
91+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
92+
},
93+
]),
94+
);
95+
expect(middlewareTransaction.breadcrumbs).toEqual(
96+
expect.arrayContaining([
97+
{
98+
category: 'fetch',
99+
data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' },
100+
timestamp: expect.any(Number),
101+
type: 'http',
102+
},
103+
]),
104+
);
105+
});

dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
33

44
test('Should create a transaction for middleware', async ({ request }) => {
55
const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => {
6-
return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware';
6+
return transactionEvent?.transaction === 'middleware GET';
77
});
88

99
const response = await request.get('/api/endpoint-behind-middleware');
@@ -23,7 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => {
2323

2424
test('Faulty middlewares', async ({ request }) => {
2525
const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => {
26-
return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware';
26+
return transactionEvent?.transaction === 'middleware GET';
2727
});
2828

2929
const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => {
@@ -48,14 +48,14 @@ test('Faulty middlewares', async ({ request }) => {
4848
// Assert that isolation scope works properly
4949
expect(errorEvent.tags?.['my-isolated-tag']).toBe(true);
5050
expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined();
51-
expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware');
51+
expect(errorEvent.transaction).toBe('middleware GET');
5252
});
5353
});
5454

5555
test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => {
5656
const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => {
5757
return (
58-
transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' &&
58+
transactionEvent?.transaction === 'middleware GET' &&
5959
!!transactionEvent.spans?.find(span => span.op === 'http.client')
6060
);
6161
});

packages/nextjs/src/common/wrapMiddlewareWithSentry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function wrapMiddlewareWithSentry<H extends EdgeRouteHandler>(
6464
isolationScope.setSDKProcessingMetadata({
6565
normalizedRequest: winterCGRequestToRequestData(req),
6666
});
67-
spanName = `middleware ${req.method} ${new URL(req.url).pathname}`;
67+
spanName = `middleware ${req.method}`;
6868
spanSource = 'url';
6969
} else {
7070
spanName = 'middleware';

packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type NextApiModule =
1515
// ESM export
1616
default?: EdgeRouteHandler;
1717
middleware?: EdgeRouteHandler;
18+
proxy?: EdgeRouteHandler;
1819
}
1920
// CJS export
2021
| EdgeRouteHandler;
@@ -29,6 +30,9 @@ let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined;
2930
if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') {
3031
// Handle when user defines via named ESM export: `export { middleware };`
3132
userProvidedNamedHandler = userApiModule.middleware;
33+
} else if ('proxy' in userApiModule && typeof userApiModule.proxy === 'function') {
34+
// Handle when user defines via named ESM export (Next.js 16): `export { proxy };`
35+
userProvidedNamedHandler = userApiModule.proxy;
3236
} else if ('default' in userApiModule && typeof userApiModule.default === 'function') {
3337
// Handle when user defines via ESM export: `export default myFunction;`
3438
userProvidedDefaultHandler = userApiModule.default;
@@ -40,6 +44,7 @@ if ('middleware' in userApiModule && typeof userApiModule.middleware === 'functi
4044
export const middleware = userProvidedNamedHandler
4145
? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler)
4246
: undefined;
47+
export const proxy = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined;
4348
export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined;
4449

4550
// Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to

0 commit comments

Comments
 (0)