Skip to content

Commit 44516f5

Browse files
committed
feat: added utility for extending the middleware matchers
1 parent e8689a7 commit 44516f5

File tree

3 files changed

+205
-0
lines changed

3 files changed

+205
-0
lines changed

packages/nextjs/src/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ export { wrapPageComponentWithSentry } from './pages-router-instrumentation/wrap
1212
export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry';
1313
export { withServerActionInstrumentation } from './withServerActionInstrumentation';
1414
export { captureRequestError } from './captureRequestError';
15+
export { withSentryTunnelExclusion } from './withSentryTunnelExclusion';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
3+
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
4+
_sentryRewritesTunnelPath?: string;
5+
};
6+
7+
/**
8+
* Wraps a middleware matcher to automatically exclude the Sentry tunnel route.
9+
*
10+
* This is useful when you have a middleware matcher that would otherwise match
11+
* the Sentry tunnel route and potentially interfere with event delivery.
12+
*
13+
* @example
14+
* ```ts
15+
* // middleware.ts
16+
* import { withSentryTunnelExclusion } from '@sentry/nextjs';
17+
*
18+
* export const config = {
19+
* matcher: withSentryTunnelExclusion([
20+
* '/api/:path*',
21+
* '/admin/:path*',
22+
* ]),
23+
* };
24+
* ```
25+
*
26+
* @param matcher - Your middleware matcher (string or array of strings)
27+
* @returns A matcher that excludes the Sentry tunnel route
28+
*/
29+
export function withSentryTunnelExclusion(matcher: string | string[]): string | string[] {
30+
const tunnelPath = process.env._sentryRewritesTunnelPath || globalWithInjectedValues._sentryRewritesTunnelPath;
31+
if (!tunnelPath) {
32+
return matcher;
33+
}
34+
35+
// Convert to array for easier handling
36+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
37+
38+
// Add negated matcher for the tunnel route
39+
// This tells Next.js to NOT run middleware on the tunnel path
40+
const tunnelExclusion = `/((?!${tunnelPath.replace(/^\//, '')}).*)`;
41+
42+
// Combine with existing matchers
43+
return [...matchers, tunnelExclusion];
44+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { GLOBAL_OBJ } from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
import { withSentryTunnelExclusion } from '../../src/common/withSentryTunnelExclusion';
4+
5+
const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
6+
_sentryRewritesTunnelPath?: string | null;
7+
};
8+
9+
describe('withSentryTunnelExclusion', () => {
10+
let originalEnv: string | undefined;
11+
let originalGlobal: unknown;
12+
13+
beforeEach(() => {
14+
// Save original values
15+
originalEnv = process.env._sentryRewritesTunnelPath;
16+
originalGlobal = globalWithInjectedValues._sentryRewritesTunnelPath;
17+
});
18+
19+
afterEach(() => {
20+
// Restore original values
21+
if (originalEnv === undefined) {
22+
delete process.env._sentryRewritesTunnelPath;
23+
} else {
24+
process.env._sentryRewritesTunnelPath = originalEnv;
25+
}
26+
27+
if (originalGlobal === undefined) {
28+
delete globalWithInjectedValues._sentryRewritesTunnelPath;
29+
} else {
30+
// @ts-expect-error - we're resetting the value to the original value
31+
globalWithInjectedValues._sentryRewritesTunnelPath = originalGlobal;
32+
}
33+
});
34+
35+
describe('when no tunnel path is configured', () => {
36+
beforeEach(() => {
37+
delete process.env._sentryRewritesTunnelPath;
38+
delete globalWithInjectedValues._sentryRewritesTunnelPath;
39+
});
40+
41+
it('should return string matcher unchanged', () => {
42+
const result = withSentryTunnelExclusion('/api/:path*');
43+
expect(result).toBe('/api/:path*');
44+
});
45+
46+
it('should return array matcher unchanged', () => {
47+
const matcher = ['/api/:path*', '/admin/:path*'];
48+
const result = withSentryTunnelExclusion(matcher);
49+
expect(result).toBe(matcher);
50+
expect(result).toEqual(['/api/:path*', '/admin/:path*']);
51+
});
52+
});
53+
54+
describe('when tunnel path is configured via process.env', () => {
55+
beforeEach(() => {
56+
process.env._sentryRewritesTunnelPath = '/sentry-tunnel';
57+
});
58+
59+
it('should add exclusion pattern to string matcher', () => {
60+
const result = withSentryTunnelExclusion('/api/:path*');
61+
expect(result).toEqual(['/api/:path*', '/((?!sentry-tunnel).*)']);
62+
});
63+
64+
it('should add exclusion pattern to array matcher', () => {
65+
const result = withSentryTunnelExclusion(['/api/:path*', '/admin/:path*']);
66+
expect(result).toEqual(['/api/:path*', '/admin/:path*', '/((?!sentry-tunnel).*)']);
67+
});
68+
69+
it('should handle tunnel path without leading slash', () => {
70+
process.env._sentryRewritesTunnelPath = 'tunnel-route';
71+
const result = withSentryTunnelExclusion('/api/:path*');
72+
expect(result).toEqual(['/api/:path*', '/((?!tunnel-route).*)']);
73+
});
74+
75+
it('should handle tunnel path with leading slash', () => {
76+
process.env._sentryRewritesTunnelPath = '/tunnel-route';
77+
const result = withSentryTunnelExclusion('/api/:path*');
78+
expect(result).toEqual(['/api/:path*', '/((?!tunnel-route).*)']);
79+
});
80+
81+
it('should work with random generated tunnel paths', () => {
82+
process.env._sentryRewritesTunnelPath = '/abc123xyz';
83+
const result = withSentryTunnelExclusion(['/api/:path*']);
84+
expect(result).toEqual(['/api/:path*', '/((?!abc123xyz).*)']);
85+
});
86+
87+
it('should work with empty array matcher', () => {
88+
const result = withSentryTunnelExclusion([]);
89+
expect(result).toEqual(['/((?!sentry-tunnel).*)']);
90+
});
91+
});
92+
93+
describe('when tunnel path is configured via GLOBAL_OBJ', () => {
94+
beforeEach(() => {
95+
delete process.env._sentryRewritesTunnelPath;
96+
globalWithInjectedValues._sentryRewritesTunnelPath = '/global-tunnel';
97+
});
98+
99+
it('should add exclusion pattern using global value', () => {
100+
const result = withSentryTunnelExclusion('/api/:path*');
101+
expect(result).toEqual(['/api/:path*', '/((?!global-tunnel).*)']);
102+
});
103+
104+
it('should prefer process.env over GLOBAL_OBJ', () => {
105+
process.env._sentryRewritesTunnelPath = '/env-tunnel';
106+
const result = withSentryTunnelExclusion('/api/:path*');
107+
expect(result).toEqual(['/api/:path*', '/((?!env-tunnel).*)']);
108+
});
109+
});
110+
111+
describe('edge cases', () => {
112+
beforeEach(() => {
113+
process.env._sentryRewritesTunnelPath = '/tunnel';
114+
});
115+
116+
it('should handle single slash matcher', () => {
117+
const result = withSentryTunnelExclusion('/');
118+
expect(result).toEqual(['/', '/((?!tunnel).*)']);
119+
});
120+
121+
it('should handle complex path patterns', () => {
122+
const result = withSentryTunnelExclusion([
123+
'/((?!api|_next/static|_next/image|favicon.ico).*)',
124+
'/api/protected/:path*',
125+
]);
126+
expect(result).toEqual([
127+
'/((?!api|_next/static|_next/image|favicon.ico).*)',
128+
'/api/protected/:path*',
129+
'/((?!tunnel).*)',
130+
]);
131+
});
132+
133+
it('should handle matcher with special regex characters in tunnel path', () => {
134+
process.env._sentryRewritesTunnelPath = '/tunnel-route-123';
135+
const result = withSentryTunnelExclusion('/api/:path*');
136+
expect(result).toEqual(['/api/:path*', '/((?!tunnel-route-123).*)']);
137+
});
138+
});
139+
140+
describe('real-world usage patterns', () => {
141+
beforeEach(() => {
142+
process.env._sentryRewritesTunnelPath = '/monitoring';
143+
});
144+
145+
it('should work with typical API route matchers', () => {
146+
const result = withSentryTunnelExclusion(['/api/:path*', '/trpc/:path*']);
147+
expect(result).toEqual(['/api/:path*', '/trpc/:path*', '/((?!monitoring).*)']);
148+
});
149+
150+
it('should work with exclusion-based matchers', () => {
151+
const result = withSentryTunnelExclusion('/((?!_next/static|_next/image|favicon.ico).*)');
152+
expect(result).toEqual(['/((?!_next/static|_next/image|favicon.ico).*)', '/((?!monitoring).*)']);
153+
});
154+
155+
it('should work with admin and protected routes', () => {
156+
const result = withSentryTunnelExclusion(['/admin/:path*', '/dashboard/:path*', '/api/auth/:path*']);
157+
expect(result).toEqual(['/admin/:path*', '/dashboard/:path*', '/api/auth/:path*', '/((?!monitoring).*)']);
158+
});
159+
});
160+
});

0 commit comments

Comments
 (0)