Skip to content

fix(nextjs): parses the nextjs router correctly #12321

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

Closed
wants to merge 11 commits into from
10 changes: 7 additions & 3 deletions packages/browser/test/unit/tracekit/chromium.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { defaultStackParser as parser } from '../../../src/stack-parsers';
import { defaultStackLineParsers } from '../../../src/stack-parsers';

const parser = createStackParser(...[nextStackParser, ...defaultStackLineParsers]);

describe('Tracekit - Chrome Tests', () => {
it('should parse Chrome error with no location', () => {
Expand Down Expand Up @@ -590,14 +594,14 @@ describe('Tracekit - Chrome Tests', () => {
stacktrace: {
frames: [
{
filename: 'http://localhost:5000/(some)/(thing)/index.html',
filename: 'http://localhost:5000/%28some%29/%28thing%29/index.html',
function: 'more',
lineno: 25,
colno: 7,
in_app: true,
},
{
filename: 'http://localhost:5000/(some)/(thing)/index.html',
filename: 'http://localhost:5000/%28some%29/%28thing%29/index.html',
function: 'something',
lineno: 20,
colno: 16,
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/test/unit/tracekit/firefox.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { defaultStackParser as parser } from '../../../src/stack-parsers';
import { defaultStackLineParsers } from '../../../src/stack-parsers';

const parser = createStackParser(...[nextStackParser, ...defaultStackLineParsers]);

describe('Tracekit - Firefox Tests', () => {
it('should parse Firefox 3 error', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/browser/test/unit/tracekit/ie.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { chromeStackLineParser, geckoStackLineParser, winjsStackLineParser } from '../../../src/stack-parsers';

const parser = createStackParser(chromeStackLineParser, geckoStackLineParser, winjsStackLineParser);
const parser = createStackParser(nextStackParser, chromeStackLineParser, geckoStackLineParser, winjsStackLineParser);

describe('Tracekit - IE Tests', () => {
it('should parse IE 10 error', () => {
Expand Down
157 changes: 157 additions & 0 deletions packages/browser/test/unit/tracekit/next.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { chromeStackLineParser, geckoStackLineParser, winjsStackLineParser } from '../../../src/stack-parsers';

const parser = createStackParser(nextStackParser, chromeStackLineParser, geckoStackLineParser, winjsStackLineParser);

const urlPatterns = [
[
'http://localhost:3001/_next/static/chunks/app/[locale]/sentery-example-page/(default)/sub/page-3d428c1ba734e10f.js:1:126',
'http://localhost:3001/_next/static/chunks/app/%5Blocale%5D/sentery-example-page/%28default%29/sub/page-3d428c1ba734e10f.js',
1,
126,
],
['http://example.com:1:126', 'http://example.com', 1, 126],
['http://example.com/path/to/resource:1:126', 'http://example.com/path/to/resource', 1, 126],
[
'http://example.com/path/to/[[locale]]/resource:1:126',
'http://example.com/path/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
[
'http://example.com/path/to/[[...locale]]/resource:1:126',
'http://example.com/path/to/%5B%5B...locale%5D%5D/resource',
1,
126,
],
[
'http://example.com/path/[[locale]]/to/[[locale]]/resource:1:126',
'http://example.com/path/%5B%5Blocale%5D%5D/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
['http://example.com/path/to/resource?query=1:1:126', 'http://example.com/path/to/resource?query=1', 1, 126],
['https://example.com:1:126', 'https://example.com', 1, 126],
['https://example.com/path/to/resource:1:126', 'https://example.com/path/to/resource', 1, 126],
[
'https://example.com/path/to/[[locale]]/resource:1:126',
'https://example.com/path/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
[
'https://example.com/path/to/[[...locale]]/resource:1:126',
'https://example.com/path/to/%5B%5B...locale%5D%5D/resource',
1,
126,
],
[
'https://example.com/path/[[locale]]/to/[[locale]]/resource:1:126',
'https://example.com/path/%5B%5Blocale%5D%5D/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
['https://example.com/path/to/resource?query=1:1:126', 'https://example.com/path/to/resource?query=1', 1, 126],
['http://example.com/path/[[locale]]:1:126', 'http://example.com/path/%5B%5Blocale%5D%5D', 1, 126],
['https://example.com/path/[[locale]]:1:126', 'https://example.com/path/%5B%5Blocale%5D%5D', 1, 126],
['http://example.com/path/to/[[locale]]/:1:126', 'http://example.com/path/to/%5B%5Blocale%5D%5D/', 1, 126],
['https://example.com/path/to/[[locale]]/:1:126', 'https://example.com/path/to/%5B%5Blocale%5D%5D/', 1, 126],
['http://example.com/(path/to/resource:1:126)', 'http://example.com/%28path/to/resource', 1, 126],
['https://example.com/(path/to/resource:1:126)', 'https://example.com/%28path/to/resource', 1, 126],
['http://example.com/path/to/resource:1:126', 'http://example.com/path/to/resource', 1, 126],
['(https://example.com/path/to/resource:1:126)', 'https://example.com/path/to/resource', 1, 126],
['http://192.168.1.1/:1:126', 'http://192.168.1.1/', 1, 126],
['http://192.168.1.1/path/to/resource:1:126', 'http://192.168.1.1/path/to/resource', 1, 126],
['http://sub.example.com:1:126', 'http://sub.example.com', 1, 126],
['http://sub.example.com/path/to/resource:1:126', 'http://sub.example.com/path/to/resource', 1, 126],
['http://example.com:8080:1:126', 'http://example.com:8080', 1, 126],
['http://example.com:8080/path/to/resource:1:126', 'http://example.com:8080/path/to/resource', 1, 126],
['http://example.com/path/to/resource#anchor:1:126', 'http://example.com/path/to/resource#anchor', 1, 126],
['http://example.com/path/to/re-source:1:126', 'http://example.com/path/to/re-source', 1, 126],
['http://example.com/path/to/re_source:1:126', 'http://example.com/path/to/re_source', 1, 126],
['http://example.com/path/to/re.source:1:126', 'http://example.com/path/to/re.source', 1, 126],
['http://example.com/path/(to)/resource:1:126', 'http://example.com/path/%28to%29/resource', 1, 126],
['http://example.com/(path)/to/resource:1:126', 'http://example.com/%28path%29/to/resource', 1, 126],
[
'http://example.com/path/to/[[locale]]/resource:1:126)',
'http://example.com/path/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
[
'http://example.com/path/to/resource[[locale]]:1:126',
'http://example.com/path/to/resource%5B%5Blocale%5D%5D',
1,
126,
],
[
'http://example.com/path/to/resource[[...locale]]:1:126',
'http://example.com/path/to/resource%5B%5B...locale%5D%5D',
1,
126,
],
[
'https://example.com/path/to/resource[[locale]]:1:126',
'https://example.com/path/to/resource%5B%5Blocale%5D%5D',
1,
126,
],
[
'http://example.com/path/to/(resource)/[[locale]]:1:126',
'http://example.com/path/to/%28resource%29/%5B%5Blocale%5D%5D',
1,
126,
],
[
'https://example.com/(path)/to/resource[[locale]]:1:126',
'https://example.com/%28path%29/to/resource%5B%5Blocale%5D%5D',
1,
126,
],
[
'http://192.168.1.1/path/to/[[locale]]/resource:1:126',
'http://192.168.1.1/path/to/%5B%5Blocale%5D%5D/resource',
1,
126,
],
[
'http://example.com/path/to/[[locale]]/resource#anchor:1:126',
'http://example.com/path/to/%5B%5Blocale%5D%5D/resource#anchor',
1,
126,
],
];

describe('Tracekit - Next.js Tests', () => {
it('should parse Next.js routes error', () => {
urlPatterns.forEach(([url, transformedUrl, lineno, colno]) => {
const NextError = {
name: 'foo',
message: "Unable to get property 'undef' of undefined or null reference",
stack: "TypeError: Unable to get property 'undef' of undefined or null reference\n" + ` at ${url}`,
description: "Unable to get property 'undef' of undefined or null reference",
number: -2146823281,
};

const ex = exceptionFromError(parser, NextError);

expect(ex).toEqual({
value: "Unable to get property 'undef' of undefined or null reference",
type: 'foo',
stacktrace: {
frames: [
{
filename: transformedUrl,
function: '?',
lineno: lineno,
colno: colno,
in_app: true,
},
],
},
});
});
});
});
4 changes: 2 additions & 2 deletions packages/browser/test/unit/tracekit/opera.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';

import { exceptionFromError } from '../../../src/eventbuilder';
import { defaultStackParser, opera10StackLineParser, opera11StackLineParser } from '../../../src/stack-parsers';

const operaParser = createStackParser(opera10StackLineParser, opera11StackLineParser);
const operaParser = createStackParser(nextStackParser, opera10StackLineParser, opera11StackLineParser);
const chromiumParser = defaultStackParser;

describe('Tracekit - Opera Tests', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/test/unit/tracekit/react.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { defaultStackParser as parser } from '../../../src/stack-parsers';
import { defaultStackLineParsers } from '../../../src/stack-parsers';

const parser = createStackParser(...[nextStackParser, ...defaultStackLineParsers]);

describe('Tracekit - React Tests', () => {
it('should correctly parse Invariant Violation errors and use framesToPop to drop the invariant frame', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/browser/test/unit/tracekit/safari.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { nextStackParser } from '@sentry/nextjs';
import { createStackParser } from '@sentry/utils';
import { exceptionFromError } from '../../../src/eventbuilder';
import { defaultStackParser as parser } from '../../../src/stack-parsers';
import { defaultStackLineParsers } from '../../../src/stack-parsers';

const parser = createStackParser(...[nextStackParser, ...defaultStackLineParsers]);

describe('Tracekit - Safari Tests', () => {
it('should parse Safari 6 error', () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/nextjs/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { addEventProcessor, applySdkMetadata, hasTracingEnabled, setTag } from '@sentry/core';
import type { BrowserOptions } from '@sentry/react';
import { getDefaultIntegrations as getReactDefaultIntegrations, init as reactInit } from '@sentry/react';
import {
defaultStackLineParsers as reactDefaultStackLineParsers,
getDefaultIntegrations as getReactDefaultIntegrations,
init as reactInit,
} from '@sentry/react';
import type { EventProcessor, Integration } from '@sentry/types';
import { GLOBAL_OBJ } from '@sentry/utils';
import { GLOBAL_OBJ, createStackParser } from '@sentry/utils';

import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolicationEventProcessor';
import { getVercelEnv } from '../common/getVercelEnv';
import { nextStackParser } from '../common/nextStackParser';
import { browserTracingIntegration } from './browserTracingIntegration';
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
import { applyTunnelRouteOption } from './tunnelRoute';
Expand All @@ -14,6 +19,10 @@ export * from '@sentry/react';

export { captureUnderscoreErrorException } from '../common/_error';

export const defaultStackLineParsers = [...reactDefaultStackLineParsers, nextStackParser];

export const defaultStackParser = createStackParser(...defaultStackLineParsers);

const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
__rewriteFramesAssetPrefixPath__: string;
};
Expand All @@ -27,6 +36,7 @@ export function init(options: BrowserOptions): void {
environment: getVercelEnv(true) || process.env.NODE_ENV,
defaultIntegrations: getDefaultIntegrations(options),
...options,
stackParser: options.stackParser || defaultStackParser,
} satisfies BrowserOptions;

applyTunnelRouteOption(opts);
Expand Down
2 changes: 2 additions & 0 deletions packages/nextjs/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export { wrapPageComponentWithSentry } from './wrapPageComponentWithSentry';
export { wrapGenerationFunctionWithSentry } from './wrapGenerationFunctionWithSentry';

export { withServerActionInstrumentation } from './withServerActionInstrumentation';

export { nextStackParser } from './nextStackParser';
52 changes: 52 additions & 0 deletions packages/nextjs/src/common/nextStackParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { defaultStackLineParsers, defaultStackParser } from '@sentry/react';
import type { StackLineParser } from '@sentry/types';

// We should put it in the highest priority to make sure it will be executed first to handle the Next.js routes.
const highestPriority = defaultStackLineParsers.sort((a, b) => a[0] - b[0])[0][0] - 1;

/**
* This stack parser is used to handle the special case of Next.js routes.
* This case didn't be place in the default stack parser because the current regex logic is too complex to be maintained.
* Therefore, add this parser here as a `polyfill` is better.
*/
export const nextStackParser: StackLineParser = [
highestPriority,
line => {
/**
* Pick the last segment as the filename.
* e.g.
* " at http://localhost:3001/_next/static/chunks/a…page/(default)/sub/page-3d428c1ba734e10f.js:1:126" -> "http://localhost:3001/_next/static/chunks/a…page/(default)/sub/page-3d428c1ba734e10f.js:1:126"
* */
const waitForReplaceSegments = line.split(' ');

// Keep the last segment as the filename and use it to replace the original filename at the end
let waitForReplaceFilename = waitForReplaceSegments[waitForReplaceSegments.length - 1];

// In this case, the filename is a URL. So we need to check if it is a valid URL.
const fileUrlRegex = /^(\(?)(https?:\/\/[^/]+(?:\/(?:(?!.[./]|[[?locale]?])[^./[]]+)*)*)(\)?)$/;

if (!fileUrlRegex.test(waitForReplaceFilename) || !waitForReplaceFilename) {
return defaultStackParser(line)[0];
}

/**
* Remove parentheses.
* e.g.
* " (http://localhost:3001/_next/static/chunks/a…page/(default)/sub/page-3d428c1ba734e10f.js:1:126)" -> "http://localhost:3001/_next/static/chunks/a…page/(default)/sub/page-3d428c1ba734e10f.js:1:126"
*/
waitForReplaceFilename = waitForReplaceFilename.replace(/^\s*\(|\)\s*$/g, '');

const processedLine = line.replace(
waitForReplaceFilename,
/**
* KEY PARTS: Replace special characters with their ASCII code
* " at http://localhost:3001/_next/static/chunks/app/[locale]/sentry-example-page/(default)/sub/page-3d428c1ba734e10f.js:1:126" ->
* " at http://localhost:3001/_next/static/chunks/app/%5Blocale%5D/sentry-example-page/(default)/sub/page-3d428c1ba734e10f.js:1:126"
* This is used to avoid the URL being split from the `(default)` part.
*/
waitForReplaceFilename.replace(/[()@[\]]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`),
);

return defaultStackParser(processedLine)[0];
},
];