Skip to content

Commit f736381

Browse files
author
Luca Forstner
authored
Merge pull request #9118 from getsentry/prepare-release/7.72.0
2 parents 7614bb9 + 779cd26 commit f736381

File tree

22 files changed

+977
-57
lines changed

22 files changed

+977
-57
lines changed

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,55 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
## 7.72.0
8+
9+
### Important Changes
10+
11+
- **feat(node): App Not Responding with stack traces (#9079)**
12+
13+
This release introduces support for Application Not Responding (ANR) errors for Node.js applications.
14+
These errors are triggered when the Node.js main thread event loop of an application is blocked for more than five seconds.
15+
The Node SDK reports ANR errors as Sentry events and can optionally attach a stacktrace of the blocking code to the ANR event.
16+
17+
To enable ANR detection, import and use the `enableANRDetection` function from the `@sentry/node` package before you run the rest of your application code.
18+
Any event loop blocking before calling `enableANRDetection` will not be detected by the SDK.
19+
20+
Example (ESM):
21+
22+
```ts
23+
import * as Sentry from "@sentry/node";
24+
25+
Sentry.init({
26+
dsn: "___PUBLIC_DSN___",
27+
tracesSampleRate: 1.0,
28+
});
29+
30+
await Sentry.enableANRDetection({ captureStackTrace: true });
31+
// Function that runs your app
32+
runApp();
33+
```
34+
35+
Example (CJS):
36+
37+
```ts
38+
const Sentry = require("@sentry/node");
39+
40+
Sentry.init({
41+
dsn: "___PUBLIC_DSN___",
42+
tracesSampleRate: 1.0,
43+
});
44+
45+
Sentry.enableANRDetection({ captureStackTrace: true }).then(() => {
46+
// Function that runs your app
47+
runApp();
48+
});
49+
```
50+
51+
### Other Changes
52+
53+
- fix(nextjs): Filter `RequestAsyncStorage` locations by locations that webpack will resolve (#9114)
54+
- fix(replay): Ensure `replay_id` is not captured when session is expired (#9109)
55+
756
## 7.71.0
857

958
- feat(bun): Instrument Bun.serve (#9080)

packages/nextjs/src/config/webpack.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export function constructWebpackConfigFunction(
126126
pageExtensionRegex,
127127
excludeServerRoutes: userSentryOptions.excludeServerRoutes,
128128
sentryConfigFilePath: getUserConfigFilePath(projectDir, runtime),
129-
nextjsRequestAsyncStorageModulePath: getRequestAsyncLocalStorageModuleLocation(rawNewConfig.resolve?.modules),
129+
nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation(rawNewConfig.resolve?.modules),
130130
};
131131

132132
const normalizeLoaderResourcePath = (resourcePath: string): string => {
@@ -977,30 +977,39 @@ function addValueInjectionLoader(
977977
);
978978
}
979979

980-
function getRequestAsyncLocalStorageModuleLocation(modules: string[] | undefined): string | undefined {
981-
if (modules === undefined) {
980+
function getRequestAsyncStorageModuleLocation(
981+
webpackResolvableModuleLocations: string[] | undefined,
982+
): string | undefined {
983+
if (webpackResolvableModuleLocations === undefined) {
982984
return undefined;
983985
}
984986

985-
try {
986-
// Original location of that module
987-
// https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts
988-
const location = 'next/dist/client/components/request-async-storage';
989-
require.resolve(location, { paths: modules });
990-
return location;
991-
} catch {
992-
// noop
993-
}
987+
const absoluteWebpackResolvableModuleLocations = webpackResolvableModuleLocations.map(m => path.resolve(m));
988+
const moduleIsWebpackResolvable = (moduleId: string): boolean => {
989+
let requireResolveLocation: string;
990+
try {
991+
// This will throw if the location is not resolvable at all.
992+
// We provide a `paths` filter in order to maximally limit the potential locations to the locations webpack would check.
993+
requireResolveLocation = require.resolve(moduleId, { paths: webpackResolvableModuleLocations });
994+
} catch {
995+
return false;
996+
}
994997

995-
try {
998+
// Since the require.resolve approach still looks in "global" node_modules locations like for example "/user/lib/node"
999+
// we further need to filter by locations that start with the locations that webpack would check for.
1000+
return absoluteWebpackResolvableModuleLocations.some(resolvableModuleLocation =>
1001+
requireResolveLocation.startsWith(resolvableModuleLocation),
1002+
);
1003+
};
1004+
1005+
const potentialRequestAsyncStorageLocations = [
1006+
// Original location of RequestAsyncStorage
1007+
// https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts
1008+
'next/dist/client/components/request-async-storage',
9961009
// Introduced in Next.js 13.4.20
9971010
// https://github.com/vercel/next.js/blob/e1bc270830f2fc2df3542d4ef4c61b916c802df3/packages/next/src/client/components/request-async-storage.external.ts
998-
const location = 'next/dist/client/components/request-async-storage.external';
999-
require.resolve(location, { paths: modules });
1000-
return location;
1001-
} catch {
1002-
// noop
1003-
}
1011+
'next/dist/client/components/request-async-storage.external',
1012+
];
10041013

1005-
return undefined;
1014+
return potentialRequestAsyncStorageLocations.find(potentialLocation => moduleIsWebpackResolvable(potentialLocation));
10061015
}

packages/node-experimental/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@opentelemetry/instrumentation-mysql2": "~0.34.1",
3737
"@opentelemetry/instrumentation-nestjs-core": "~0.33.1",
3838
"@opentelemetry/instrumentation-pg": "~0.36.1",
39+
"@opentelemetry/resources": "~1.17.0",
3940
"@opentelemetry/sdk-trace-base": "~1.17.0",
4041
"@opentelemetry/semantic-conventions": "~1.17.0",
4142
"@prisma/instrumentation": "~5.3.1",

packages/node-experimental/src/sdk/initOtel.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { diag, DiagLogLevel } from '@opentelemetry/api';
2+
import { Resource } from '@opentelemetry/resources';
23
import { AlwaysOnSampler, BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
3-
import { getCurrentHub } from '@sentry/core';
4+
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
5+
import { getCurrentHub, SDK_VERSION } from '@sentry/core';
46
import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node';
57
import { logger } from '@sentry/utils';
68

@@ -28,6 +30,11 @@ export function initOtel(): () => void {
2830
// Create and configure NodeTracerProvider
2931
const provider = new BasicTracerProvider({
3032
sampler: new AlwaysOnSampler(),
33+
resource: new Resource({
34+
[SemanticResourceAttributes.SERVICE_NAME]: 'node-experimental',
35+
[SemanticResourceAttributes.SERVICE_NAMESPACE]: 'sentry',
36+
[SemanticResourceAttributes.SERVICE_VERSION]: SDK_VERSION,
37+
}),
3138
});
3239
provider.addSpanProcessor(new SentrySpanProcessor());
3340

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const crypto = require('crypto');
2+
3+
const Sentry = require('@sentry/node');
4+
5+
// close both processes after 5 seconds
6+
setTimeout(() => {
7+
process.exit();
8+
}, 5000);
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
release: '1.0',
13+
beforeSend: event => {
14+
// eslint-disable-next-line no-console
15+
console.log(JSON.stringify(event));
16+
},
17+
});
18+
19+
Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }).then(() => {
20+
function longWork() {
21+
for (let i = 0; i < 100; i++) {
22+
const salt = crypto.randomBytes(128).toString('base64');
23+
// eslint-disable-next-line no-unused-vars
24+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
25+
}
26+
}
27+
28+
setTimeout(() => {
29+
longWork();
30+
}, 1000);
31+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as crypto from 'crypto';
2+
3+
import * as Sentry from '@sentry/node';
4+
5+
// close both processes after 5 seconds
6+
setTimeout(() => {
7+
process.exit();
8+
}, 5000);
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
release: '1.0',
13+
beforeSend: event => {
14+
// eslint-disable-next-line no-console
15+
console.log(JSON.stringify(event));
16+
},
17+
});
18+
19+
await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true });
20+
21+
function longWork() {
22+
for (let i = 0; i < 100; i++) {
23+
const salt = crypto.randomBytes(128).toString('base64');
24+
// eslint-disable-next-line no-unused-vars
25+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
26+
}
27+
}
28+
29+
setTimeout(() => {
30+
longWork();
31+
}, 1000);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { Event } from '@sentry/node';
2+
import { parseSemver } from '@sentry/utils';
3+
import * as childProcess from 'child_process';
4+
import * as path from 'path';
5+
6+
const NODE_VERSION = parseSemver(process.versions.node).major || 0;
7+
8+
describe('should report ANR when event loop blocked', () => {
9+
test('CJS', done => {
10+
// The stack trace is different when node < 12
11+
const testFramesDetails = NODE_VERSION >= 12;
12+
13+
expect.assertions(testFramesDetails ? 6 : 4);
14+
15+
const testScriptPath = path.resolve(__dirname, 'scenario.js');
16+
17+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
18+
const event = JSON.parse(stdout) as Event;
19+
20+
expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' });
21+
expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding');
22+
expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms');
23+
expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4);
24+
25+
if (testFramesDetails) {
26+
expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?');
27+
expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork');
28+
}
29+
30+
done();
31+
});
32+
});
33+
34+
test('ESM', done => {
35+
if (NODE_VERSION < 14) {
36+
done();
37+
return;
38+
}
39+
40+
expect.assertions(6);
41+
42+
const testScriptPath = path.resolve(__dirname, 'scenario.mjs');
43+
44+
childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => {
45+
const event = JSON.parse(stdout) as Event;
46+
47+
expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' });
48+
expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding');
49+
expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms');
50+
expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4);
51+
expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?');
52+
expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork');
53+
54+
done();
55+
});
56+
});
57+
});

packages/node/src/anr/debugger.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { StackFrame } from '@sentry/types';
2+
import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils';
3+
import type { Debugger } from 'inspector';
4+
5+
import { getModuleFromFilename } from '../module';
6+
import { createWebSocketClient } from './websocket';
7+
8+
/**
9+
* Converts Debugger.CallFrame to Sentry StackFrame
10+
*/
11+
function callFrameToStackFrame(
12+
frame: Debugger.CallFrame,
13+
filenameFromScriptId: (id: string) => string | undefined,
14+
): StackFrame {
15+
const filename = filenameFromScriptId(frame.location.scriptId)?.replace(/^file:\/\//, '');
16+
17+
// CallFrame row/col are 0 based, whereas StackFrame are 1 based
18+
const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined;
19+
const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined;
20+
21+
return dropUndefinedKeys({
22+
filename,
23+
module: getModuleFromFilename(filename),
24+
function: frame.functionName || '?',
25+
colno,
26+
lineno,
27+
in_app: filename ? filenameIsInApp(filename) : undefined,
28+
});
29+
}
30+
31+
// The only messages we care about
32+
type DebugMessage =
33+
| {
34+
method: 'Debugger.scriptParsed';
35+
params: Debugger.ScriptParsedEventDataType;
36+
}
37+
| { method: 'Debugger.paused'; params: Debugger.PausedEventDataType };
38+
39+
/**
40+
* Wraps a websocket connection with the basic logic of the Node debugger protocol.
41+
* @param url The URL to connect to
42+
* @param onMessage A callback that will be called with each return message from the debugger
43+
* @returns A function that can be used to send commands to the debugger
44+
*/
45+
async function webSocketDebugger(
46+
url: string,
47+
onMessage: (message: DebugMessage) => void,
48+
): Promise<(method: string, params?: unknown) => void> {
49+
let id = 0;
50+
const webSocket = await createWebSocketClient(url);
51+
52+
webSocket.on('message', (data: Buffer) => {
53+
const message = JSON.parse(data.toString()) as DebugMessage;
54+
onMessage(message);
55+
});
56+
57+
return (method: string, params?: unknown) => {
58+
webSocket.send(JSON.stringify({ id: id++, method, params }));
59+
};
60+
}
61+
62+
/**
63+
* Captures stack traces from the Node debugger.
64+
* @param url The URL to connect to
65+
* @param callback A callback that will be called with the stack frames
66+
* @returns A function that triggers the debugger to pause and capture a stack trace
67+
*/
68+
export async function captureStackTrace(url: string, callback: (frames: StackFrame[]) => void): Promise<() => void> {
69+
// Collect scriptId -> url map so we can look up the filenames later
70+
const scripts = new Map<string, string>();
71+
72+
const sendCommand = await webSocketDebugger(url, message => {
73+
if (message.method === 'Debugger.scriptParsed') {
74+
scripts.set(message.params.scriptId, message.params.url);
75+
} else if (message.method === 'Debugger.paused') {
76+
// copy the frames
77+
const callFrames = [...message.params.callFrames];
78+
// and resume immediately!
79+
sendCommand('Debugger.resume');
80+
sendCommand('Debugger.disable');
81+
82+
const frames = callFrames
83+
.map(frame => callFrameToStackFrame(frame, id => scripts.get(id)))
84+
// Sentry expects the frames to be in the opposite order
85+
.reverse();
86+
87+
callback(frames);
88+
}
89+
});
90+
91+
return () => {
92+
sendCommand('Debugger.enable');
93+
sendCommand('Debugger.pause');
94+
};
95+
}

0 commit comments

Comments
 (0)