Skip to content
Merged
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 @@ -7,4 +7,5 @@ const nextConfig: NextConfig = {

export default withSentryConfig(nextConfig, {
silent: true,
tunnelRoute: '/monitoring',
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz",
"@sentry/conventions": "0.30.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function proxy(request: NextRequest) {
if (request.nextUrl.pathname !== '/api/proxy-isolation') {
return NextResponse.next();
}

const requestId = request.nextUrl.searchParams.get('id');

Sentry.addBreadcrumb({ message: `proxy breadcrumb ${requestId}` });
Expand All @@ -16,5 +20,5 @@ export async function proxy(request: NextRequest) {
}

export const config = {
matcher: ['/api/proxy-isolation'],
matcher: ['/api/proxy-isolation', '/api/proxy-ok', '/monitoring'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
import { HTTP_TARGET } from '@sentry/conventions/attributes';
import { waitForStreamedSpan, waitForStreamedSpans } from '@sentry-internal/test-utils';

test('drops the `Next.request` root span of tunnel requests through the proxy', async ({ request }) => {
// The callback never returns true, so this promise is deliberately left unsettled. The assertions read the array.
const segmentSpans: Awaited<ReturnType<typeof waitForStreamedSpan>>[] = [];
void waitForStreamedSpans('nextjs-16-adapter', spans => {
segmentSpans.push(...spans.filter(span => span.is_segment));
return false;
});

const proxyOkSpanPromise = waitForStreamedSpan('nextjs-16-adapter', span => {
return (
span.is_segment &&
span.attributes['next.span_type']?.value === 'Next.request' &&
span.attributes[HTTP_TARGET]?.value === '/api/proxy-ok'
);
});

const tunnelResponse = await request.post('/monitoring?o=123&p=456', { data: '{}' });
expect(tunnelResponse.headers()['x-middleware-next']).toBe('1');

await request.get('/api/proxy-ok');
await proxyOkSpanPromise;

expect(segmentSpans.filter(span => String(span.attributes[HTTP_TARGET]?.value).startsWith('/monitoring'))).toEqual(
[],
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ export function dropMiddlewareTunnelRequests(
return;
}

// Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans
const isMiddleware = attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute';
// Only filter middleware spans, HTTP fetch spans, or BaseServer.handleRequest spans. Since Next.js 16.4.3, a proxy
// invoked separately from the render runs under a `Next.request` root span, which has to be dropped too.
const isMiddleware =
attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Middleware.execute' || attrs?.[ATTR_NEXT_SPAN_TYPE] === 'Next.request';
// The fetch span could be originating from rewrites re-writing a tunnel request
// So we want to filter it out
const isFetchSpan = attrs?.[SENTRY_ORIGIN] === 'auto.http.node_fetch';
Expand Down Expand Up @@ -114,8 +116,18 @@ export function getTunnelRouteIgnoreSpans(): NonNullable<ClientOptions['ignoreSp
},
};

// Since Next.js 16.4.3, a proxy invoked separately from the render runs under a `Next.request` root span. Its
// `http.target` is the pathname only, so the query of a rewrite-mode tunnel request can't be matched.
const proxyRootSpanIgnoreSpan = {
attributes: {
[ATTR_NEXT_SPAN_TYPE]: /^Next\.request$/,
// eslint-disable-next-line typescript/no-deprecated
[HTTP_TARGET]: new RegExp(`^${escapeStringForRegex(tunnelPath)}/?$`),
},
};

if (tunnelRouteMode !== 'handler') {
return [tunnelRequestIgnoreSpan];
return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan];
}

// On edge, the root span of a route handler has only `next.route`, with a `/route` suffix before Next.js 16.
Expand All @@ -125,5 +137,5 @@ export function getTunnelRouteIgnoreSpans(): NonNullable<ClientOptions['ignoreSp
[ATTR_NEXT_ROUTE]: new RegExp(`^${escapeStringForRegex(TUNNEL_ROUTE_HANDLER_PATH)}(?:/route)?$`),
},
};
return [tunnelRequestIgnoreSpan, tunnelRouteHandlerIgnoreSpan];
return [tunnelRequestIgnoreSpan, proxyRootSpanIgnoreSpan, tunnelRouteHandlerIgnoreSpan];
}
34 changes: 34 additions & 0 deletions packages/nextjs/test/utils/dropMiddlewareTunnelRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ describe('dropMiddlewareTunnelRequests', () => {
});
});

describe('Next.request spans', () => {
it('marks the proxy root span for dropping when http.target matches tunnel path', () => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
const span = createMockSpan();

dropMiddlewareTunnelRequests(span as any, {
'next.span_type': 'Next.request',
'http.target': '/monitoring',
});

expect(span.setAttribute).toHaveBeenCalledWith(TRANSACTION_ATTR_SHOULD_DROP_TRANSACTION, true);
});
});

describe('unrelated spans', () => {
it('does not process spans without matching span type or origin', () => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
Expand Down Expand Up @@ -211,6 +225,26 @@ describe('getTunnelRouteIgnoreSpans', () => {
},
);

it.each([
['rewrite', '/monitoring'],
['rewrite', '/monitoring/'],
['handler', '/monitoring'],
] as const)('ignores a Next.request proxy root span in %s mode for %s', (mode, target) => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
globalWithInjectedValues._sentryTunnelRouteMode = mode;

expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(true);
});

it.each(['/monitoring/dashboard', '/monitoring-dashboard', '/api/monitoring'])(
'does not ignore a Next.request proxy root span for %s',
target => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';

expect(isIgnored(nextRequestSpanAttributes('Next.request', target))).toBe(false);
},
);

it('does not ignore a request span for a sub-path in handler mode', () => {
globalWithInjectedValues._sentryRewritesTunnelPath = '/monitoring';
globalWithInjectedValues._sentryTunnelRouteMode = 'handler';
Expand Down
Loading