Skip to content

feat(nextjs): Add option to automatically tunnel events #6425

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Dec 13, 2022
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
19 changes: 19 additions & 0 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export type NextConfigFunctionWithSentry = (
defaults: { defaultConfig: NextConfigObject },
) => NextConfigObjectWithSentry;

// Vendored from Next.js (this type is not complete - extend if necessary)
type NextRewrite = {
source: string;
destination: string;
};

export type NextConfigObject = {
// Custom webpack options
webpack?: WebpackConfigFunction | null;
Expand All @@ -42,6 +48,15 @@ export type NextConfigObject = {
publicRuntimeConfig?: { [key: string]: unknown };
// File extensions that count as pages in the `pages/` directory
pageExtensions?: string[];
// Paths to reroute when requested
rewrites?: () => Promise<
| NextRewrite[]
| {
beforeFiles: NextRewrite[];
afterFiles: NextRewrite[];
fallback: NextRewrite[];
}
>;
};

export type UserSentryOptions = {
Expand Down Expand Up @@ -75,6 +90,10 @@ export type UserSentryOptions = {
// (`pages/animals/index.js` or `.\src\pages\api\animals\[animalType]\habitat.tsx`), and strings must be be a full,
// exact match.
excludeServerRoutes?: Array<RegExp | string>;

// Tunnel Sentry requests through this route on the Next.js server, to circumvent ad-blockers blocking Sentry events from being sent.
// This option should be a path (for example: '/error-monitoring').
tunnelRoute?: string;
};

export type NextConfigFunction = (phase: string, defaults: { defaultConfig: NextConfigObject }) => NextConfigObject;
Expand Down
6 changes: 5 additions & 1 deletion packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function constructWebpackConfigFunction(
const newConfig = setUpModuleRules(rawNewConfig);

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

if (isServer) {
if (userSentryOptions.autoInstrumentServerFunctions !== false) {
Expand Down Expand Up @@ -654,6 +654,7 @@ function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWi
function addValueInjectionLoader(
newConfig: WebpackConfigObjectWithModuleRules,
userNextConfig: NextConfigObject,
userSentryOptions: UserSentryOptions,
webpackPluginOptions: SentryWebpackPlugin.SentryCliPluginOptions,
): void {
const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || '';
Expand All @@ -679,6 +680,9 @@ function addValueInjectionLoader(
},
}
: undefined),

// `rewritesTunnel` set by the user in Next.js config
__sentryRewritesTunnelPath__: userSentryOptions.tunnelRoute,
};

const serverValues = {
Expand Down
53 changes: 53 additions & 0 deletions packages/nextjs/src/config/withSentryConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ function getFinalConfigObject(
// Remind TS that there's now no `sentry` property
const userNextConfigObject = incomingUserNextConfigObject as NextConfigObject;

if (userSentryOptions?.tunnelRoute) {
setUpTunnelRewriteRules(userNextConfigObject, userSentryOptions.tunnelRoute);
}

// In order to prevent all of our build-time code from being bundled in people's route-handling serverless functions,
// we exclude `webpack.ts` and all of its dependencies from nextjs's `@vercel/nft` filetracing. We therefore need to
// make sure that we only require it at build time or in development mode.
Expand All @@ -58,3 +62,52 @@ function getFinalConfigObject(
// At runtime, we just return the user's config untouched.
return userNextConfigObject;
}

/**
* Injects rewrite rules into the Next.js config provided by the user to tunnel
* requests from the `tunnelPath` to Sentry.
*
* See https://nextjs.org/docs/api-reference/next.config.js/rewrites.
*/
function setUpTunnelRewriteRules(userNextConfig: NextConfigObject, tunnelPath: string): void {
const originalRewrites = userNextConfig.rewrites;

// This function doesn't take any arguments at the time of writing but we future-proof
// here in case Next.js ever decides to pass some
userNextConfig.rewrites = async (...args: unknown[]) => {
const injectedRewrite = {
// Matched rewrite routes will look like the following: `[tunnelPath]?o=[orgid]&p=[projectid]`
// Nextjs will automatically convert `source` into a regex for us
source: `${tunnelPath}(/?)`,
has: [
{
type: 'query',
key: 'o', // short for orgId - we keep it short so matching is harder for ad-blockers
value: '(?<orgid>.*)',
},
{
type: 'query',
key: 'p', // short for projectId - we keep it short so matching is harder for ad-blockers
value: '(?<projectid>.*)',
},
],
destination: 'https://o:orgid.ingest.sentry.io/api/:projectid/envelope/',
};

if (typeof originalRewrites !== 'function') {
return [injectedRewrite];
}

// @ts-ignore Expected 0 arguments but got 1 - this is from the future-proofing mentioned above, so we don't care about it
const originalRewritesResult = await originalRewrites(...args);

if (Array.isArray(originalRewritesResult)) {
return [injectedRewrite, ...originalRewritesResult];
} else {
return {
...originalRewritesResult,
beforeFiles: [injectedRewrite, ...originalRewritesResult.beforeFiles],
};
}
};
}
3 changes: 2 additions & 1 deletion packages/nextjs/src/index.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { logger } from '@sentry/utils';
import { nextRouterInstrumentation } from './performance/client';
import { buildMetadata } from './utils/metadata';
import { NextjsOptions } from './utils/nextjsOptions';
import { applyTunnelRouteOption } from './utils/tunnelRoute';
import { addOrUpdateIntegration } from './utils/userIntegrations';

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

const globalWithInjectedValues = global as typeof global & {
__rewriteFramesAssetPrefixPath__: string;
__sentryRewritesTunnelPath__?: string;
};

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

applyTunnelRouteOption(options);
buildMetadata(options, ['nextjs', 'react']);
options.environment = options.environment || process.env.NODE_ENV;
addClientIntegrations(options);
Expand Down
12 changes: 12 additions & 0 deletions packages/nextjs/src/utils/instrumentServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ type WrappedPageComponentFinder = PageComponentFinder;
let liveServer: Server;
let sdkSetupComplete = false;

const globalWithInjectedValues = global as typeof global & {
__sentryRewritesTunnelPath__?: string;
};

/**
* Do the monkeypatching and wrapping necessary to catch errors in page routes and record transactions for both page and
* API routes.
Expand Down Expand Up @@ -352,6 +356,14 @@ function makeWrappedMethodForGettingParameterizedPath(
* @returns false if the URL is for an internal or static resource
*/
function shouldTraceRequest(url: string, publicDirFiles: Set<string>): boolean {
// Don't trace tunneled sentry events
const tunnelPath = globalWithInjectedValues.__sentryRewritesTunnelPath__;
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
if (tunnelPath && pathname === tunnelPath) {
__DEBUG_BUILD__ && logger.log(`Tunneling Sentry event received on "${url}"`);
return false;
}

// `static` is a deprecated but still-functional location for static resources
return !url.startsWith('/_next/') && !url.startsWith('/static/') && !publicDirFiles.has(url);
}
26 changes: 26 additions & 0 deletions packages/nextjs/src/utils/tunnelRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { dsnFromString, logger } from '@sentry/utils';

import { NextjsOptions } from './nextjsOptions';

const globalWithInjectedValues = global as typeof global & {
__sentryRewritesTunnelPath__?: string;
};

/**
* Applies the `tunnel` option to the Next.js SDK options based on `withSentryConfig`'s `tunnelRoute` option.
*/
export function applyTunnelRouteOption(options: NextjsOptions): void {
const tunnelRouteOption = globalWithInjectedValues.__sentryRewritesTunnelPath__;
if (tunnelRouteOption && options.dsn) {
const dsnComponents = dsnFromString(options.dsn);
const sentrySaasDsnMatch = dsnComponents.host.match(/^o(\d+)\.ingest\.sentry\.io$/);
if (sentrySaasDsnMatch) {
const orgId = sentrySaasDsnMatch[1];
const tunnelPath = `${tunnelRouteOption}?o=${orgId}&p=${dsnComponents.projectId}`;
options.tunnel = tunnelPath;
__DEBUG_BUILD__ && logger.info(`Tunneling events to "${tunnelPath}"`);
} else {
__DEBUG_BUILD__ && logger.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.');
}
}
}
55 changes: 55 additions & 0 deletions packages/nextjs/test/utils/tunnelRoute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextjsOptions } from '../../src/utils/nextjsOptions';
import { applyTunnelRouteOption } from '../../src/utils/tunnelRoute';

const globalWithInjectedValues = global as typeof global & {
__sentryRewritesTunnelPath__?: string;
};

beforeEach(() => {
globalWithInjectedValues.__sentryRewritesTunnelPath__ = undefined;
});

describe('applyTunnelRouteOption()', () => {
it('should correctly apply `tunnelRoute` option when conditions are met', () => {
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
const options: any = {
dsn: 'https://[email protected]/3333333',
} as NextjsOptions;

applyTunnelRouteOption(options);

expect(options.tunnel).toBe('/my-error-monitoring-route?o=2222222&p=3333333');
});

it('should not apply `tunnelRoute` when DSN is missing', () => {
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
const options: any = {
// no dsn
} as NextjsOptions;

applyTunnelRouteOption(options);

expect(options.tunnel).toBeUndefined();
});

it("should not apply `tunnelRoute` option when `tunnelRoute` option wasn't injected", () => {
const options: any = {
dsn: 'https://[email protected]/3333333',
} as NextjsOptions;

applyTunnelRouteOption(options);

expect(options.tunnel).toBeUndefined();
});

it('should not apply `tunnelRoute` option when DSN is not a SaaS DSN', () => {
globalWithInjectedValues.__sentryRewritesTunnelPath__ = '/my-error-monitoring-route';
const options: any = {
dsn: 'https://[email protected]/3333333',
} as NextjsOptions;

applyTunnelRouteOption(options);

expect(options.tunnel).toBeUndefined();
});
});