Skip to content

Commit 6e6e241

Browse files
lforstlobsterkatie
andauthored
feat(nextjs): Add option to automatically tunnel events (getsentry#6425)
Co-authored-by: Katie Byers <katie.byers@sentry.io>
1 parent 241f784 commit 6e6e241

File tree

7 files changed

+172
-2
lines changed

7 files changed

+172
-2
lines changed

packages/nextjs/src/config/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ export type NextConfigFunctionWithSentry = (
2727
defaults: { defaultConfig: NextConfigObject },
2828
) => NextConfigObjectWithSentry;
2929

30+
// Vendored from Next.js (this type is not complete - extend if necessary)
31+
type NextRewrite = {
32+
source: string;
33+
destination: string;
34+
};
35+
3036
export type NextConfigObject = {
3137
// Custom webpack options
3238
webpack?: WebpackConfigFunction | null;
@@ -42,6 +48,15 @@ export type NextConfigObject = {
4248
publicRuntimeConfig?: { [key: string]: unknown };
4349
// File extensions that count as pages in the `pages/` directory
4450
pageExtensions?: string[];
51+
// Paths to reroute when requested
52+
rewrites?: () => Promise<
53+
| NextRewrite[]
54+
| {
55+
beforeFiles: NextRewrite[];
56+
afterFiles: NextRewrite[];
57+
fallback: NextRewrite[];
58+
}
59+
>;
4560
};
4661

4762
export type UserSentryOptions = {
@@ -75,6 +90,10 @@ export type UserSentryOptions = {
7590
// (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,
7691
// exact match.
7792
excludeServerRoutes?: Array<RegExp | string>;
93+
94+
// Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events from being sent.
95+
// This option should be a path (for example: '/error-monitoring').
96+
tunnelRoute?: string;
7897
};
7998

8099
export type NextConfigFunction = (phase: string, defaults: { defaultConfig: NextConfigObject }) => NextConfigObject;

packages/nextjs/src/config/webpack.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function constructWebpackConfigFunction(
8888
const newConfig = setUpModuleRules(rawNewConfig);
8989

9090
// Add a loader which will inject code that sets global values
91-
addValueInjectionLoader(newConfig, userNextConfig, webpackPluginOptions);
91+
addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, webpackPluginOptions);
9292

9393
if (isServer) {
9494
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
@@ -654,6 +654,7 @@ function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWi
654654
function addValueInjectionLoader(
655655
newConfig: WebpackConfigObjectWithModuleRules,
656656
userNextConfig: NextConfigObject,
657+
userSentryOptions: UserSentryOptions,
657658
webpackPluginOptions: SentryWebpackPlugin.SentryCliPluginOptions,
658659
): void {
659660
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';
@@ -679,6 +680,9 @@ function addValueInjectionLoader(
679680
},
680681
}
681682
: undefined),
683+
684+
// `rewritesTunnel` set by the user in Next.js config
685+
__sentryRewritesTunnelPath__: userSentryOptions.tunnelRoute,
682686
};
683687

684688
const serverValues = {

packages/nextjs/src/config/withSentryConfig.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ function getFinalConfigObject(
4343
// Remind TS that there's now no `sentry` property
4444
const userNextConfigObject = incomingUserNextConfigObject as NextConfigObject;
4545

46+
if (userSentryOptions?.tunnelRoute) {
47+
setUpTunnelRewriteRules(userNextConfigObject, userSentryOptions.tunnelRoute);
48+
}
49+
4650
// In order to prevent all of our build-time code from being bundled in people's route-handling serverless functions,
4751
// we exclude `webpack.ts` and all of its dependencies from nextjs's `@vercel/nft` filetracing. We therefore need to
4852
// make sure that we only require it at build time or in development mode.
@@ -58,3 +62,52 @@ function getFinalConfigObject(
5862
// At runtime, we just return the user's config untouched.
5963
return userNextConfigObject;
6064
}
65+
66+
/**
67+
* Injects rewrite rules into the Next.js config provided by the user to tunnel
68+
* requests from the `tunnelPath` to Sentry.
69+
*
70+
* See https://nextjs.org/docs/api-reference/next.config.js/rewrites.
71+
*/
72+
function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void {
73+
const originalRewrites = userNextConfig.rewrites;
74+
75+
// This function doesn't take any arguments at the time of writing but we future-proof
76+
// here in case Next.js ever decides to pass some
77+
userNextConfig.rewrites = async (...args: unknown[]) => {
78+
const injectedRewrite = {
79+
// Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
80+
// Nextjs will automatically convert `source` into a regex for us
81+
source: `${tunnelPath}(/?)`,
82+
has: [
83+
{
84+
type: 'query',
85+
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
86+
value: '(?<orgid>.*)',
87+
},
88+
{
89+
type: 'query',
90+
key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
91+
value: '(?<projectid>.*)',
92+
},
93+
],
94+
destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/',
95+
};
96+
97+
if (typeof originalRewrites !== 'function') {
98+
return [injectedRewrite];
99+
}
100+
101+
// @ts-ignore Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it
102+
const originalRewritesResult = await originalRewrites(...args);
103+
104+
if (Array.isArray(originalRewritesResult)) {
105+
return [injectedRewrite, ...originalRewritesResult];
106+
} else {
107+
return {
108+
...originalRewritesResult,
109+
beforeFiles: [injectedRewrite, ...originalRewritesResult.beforeFiles],
110+
};
111+
}
112+
};
113+
}

packages/nextjs/src/index.client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { logger } from '@sentry/utils';
77
import { nextRouterInstrumentation } from './performance/client';
88
import { buildMetadata } from './utils/metadata';
99
import { NextjsOptions } from './utils/nextjsOptions';
10+
import { applyTunnelRouteOption } from './utils/tunnelRoute';
1011
import { addOrUpdateIntegration } from './utils/userIntegrations';
1112

1213
export * from '@sentry/react';
@@ -37,7 +38,6 @@ declare const EdgeRuntime: string | undefined;
3738

3839
const globalWithInjectedValues = global as typeof global & {
3940
__rewriteFramesAssetPrefixPath__: string;
40-
__sentryRewritesTunnelPath__?: string;
4141
};
4242

4343
/** Inits the Sentry NextJS SDK on the browser with the React SDK. */
@@ -50,6 +50,7 @@ export function init(options: NextjsOptions): void {
5050
return;
5151
}
5252

53+
applyTunnelRouteOption(options);
5354
buildMetadata(options, ['nextjs', 'react']);
5455
options.environment = options.environment || process.env.NODE_ENV;
5556
addClientIntegrations(options);

packages/nextjs/src/utils/instrumentServer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ type WrappedPageComponentFinder = PageComponentFinder;
7676
let liveServer: Server;
7777
let sdkSetupComplete = false;
7878

79+
const globalWithInjectedValues = global as typeof global & {
80+
__sentryRewritesTunnelPath__?: string;
81+
};
82+
7983
/**
8084
* Do the monkeypatching and wrapping necessary to catch errors in page routes and record transactions for both page and
8185
* API routes.
@@ -352,6 +356,14 @@ function makeWrappedMethodForGettingParameterizedPath(
352356
* @returns false if the URL is for an internal or static resource
353357
*/
354358
function shouldTraceRequest(url: string, publicDirFiles: Set<string>): boolean {
359+
// Don't trace tunneled sentry events
360+
const tunnelPath = globalWithInjectedValues.__sentryRewritesTunnelPath__;
361+
const pathname = new URL(url, 'http://example.com/').pathname; // `url` is relative so we need to define a base to be able to parse with URL
362+
if (tunnelPath && pathname === tunnelPath) {
363+
__DEBUG_BUILD__ && logger.log(`Tunneling Sentry event received on "${url}"`);
364+
return false;
365+
}
366+
355367
// `static` is a deprecated but still-functional location for static resources
356368
return !url.startsWith('/_next/') && !url.startsWith('/static/') && !publicDirFiles.has(url);
357369
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { dsnFromString, logger } from '@sentry/utils';
2+
3+
import { NextjsOptions } from './nextjsOptions';
4+
5+
const globalWithInjectedValues = global as typeof global & {
6+
__sentryRewritesTunnelPath__?: string;
7+
};
8+
9+
/**
10+
* Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option.
11+
*/
12+
export function applyTunnelRouteOption(options: NextjsOptions): void {
13+
const tunnelRouteOption = globalWithInjectedValues.__sentryRewritesTunnelPath__;
14+
if (tunnelRouteOption && options.dsn) {
15+
const dsnComponents = dsnFromString(options.dsn);
16+
const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest\.sentry\.io$/);
17+
if (sentrySaasDsnMatch) {
18+
const orgId = sentrySaasDsnMatch[1];
19+
const tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`;
20+
options.tunnel = tunnelPath;
21+
__DEBUG_BUILD__ && logger.info(`Tunneling events to "${tunnelPath}"`);
22+
} else {
23+
__DEBUG_BUILD__ && logger.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.');
24+
}
25+
}
26+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { NextjsOptions } from '../../src/utils/nextjsOptions';
2+
import { applyTunnelRouteOption } from '../../src/utils/tunnelRoute';
3+
4+
const globalWithInjectedValues = global as typeof global & {
5+
__sentryRewritesTunnelPath__?: string;
6+
};
7+
8+
beforeEach(() => {
9+
globalWithInjectedValues.__sentryRewritesTunnelPath__ = undefined;
10+
});
11+
12+
describe('applyTunnelRouteOption()', () => {
13+
it('should correctly apply `tunnelRoute` option when conditions are met', () => {
14+
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
15+
const options: any = {
16+
dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333',
17+
} as NextjsOptions;
18+
19+
applyTunnelRouteOption(options);
20+
21+
expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333');
22+
});
23+
24+
it('should not apply `tunnelRoute` when DSN is missing', () => {
25+
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
26+
const options: any = {
27+
// no dsn
28+
} as NextjsOptions;
29+
30+
applyTunnelRouteOption(options);
31+
32+
expect(options.tunnel).toBeUndefined();
33+
});
34+
35+
it("should not apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => {
36+
const options: any = {
37+
dsn: 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333',
38+
} as NextjsOptions;
39+
40+
applyTunnelRouteOption(options);
41+
42+
expect(options.tunnel).toBeUndefined();
43+
});
44+
45+
it('should not apply `tunnelRoute` option when DSN is not a SaaS DSN', () => {
46+
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
47+
const options: any = {
48+
dsn: 'https://11111111111111111111111111111111@example.com/3333333',
49+
} as NextjsOptions;
50+
51+
applyTunnelRouteOption(options);
52+
53+
expect(options.tunnel).toBeUndefined();
54+
});
55+
});

0 commit comments

Comments
 (0)