diff --git a/CHANGELOG.md b/CHANGELOG.md index 724c0730ee83..d2612400a12f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 7.72.0 + +### Important Changes + +- **feat(node): App Not Responding with stack traces (#9079)** + +This release introduces support for Application Not Responding (ANR) errors for Node.js applications. +These errors are triggered when the Node.js main thread event loop of an application is blocked for more than five seconds. +The Node SDK reports ANR errors as Sentry events and can optionally attach a stacktrace of the blocking code to the ANR event. + +To enable ANR detection, import and use the `enableANRDetection` function from the `@sentry/node` package before you run the rest of your application code. +Any event loop blocking before calling `enableANRDetection` will not be detected by the SDK. + +Example (ESM): + +```ts +import * as Sentry from "@sentry/node"; + +Sentry.init({ + dsn: "___PUBLIC_DSN___", + tracesSampleRate: 1.0, +}); + +await Sentry.enableANRDetection({ captureStackTrace: true }); +// Function that runs your app +runApp(); +``` + +Example (CJS): + +```ts +const Sentry = require("@sentry/node"); + +Sentry.init({ + dsn: "___PUBLIC_DSN___", + tracesSampleRate: 1.0, +}); + +Sentry.enableANRDetection({ captureStackTrace: true }).then(() => { + // Function that runs your app + runApp(); +}); +``` + +### Other Changes + +- fix(nextjs): Filter `RequestAsyncStorage` locations by locations that webpack will resolve (#9114) +- fix(replay): Ensure `replay_id` is not captured when session is expired (#9109) + ## 7.71.0 - feat(bun): Instrument Bun.serve (#9080) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index dc8a77a4494c..ffe7091fa74a 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -126,7 +126,7 @@ export function constructWebpackConfigFunction( pageExtensionRegex, excludeServerRoutes: userSentryOptions.excludeServerRoutes, sentryConfigFilePath: getUserConfigFilePath(projectDir, runtime), - nextjsRequestAsyncStorageModulePath: getRequestAsyncLocalStorageModuleLocation(rawNewConfig.resolve?.modules), + nextjsRequestAsyncStorageModulePath: getRequestAsyncStorageModuleLocation(rawNewConfig.resolve?.modules), }; const normalizeLoaderResourcePath = (resourcePath: string): string => { @@ -977,30 +977,39 @@ function addValueInjectionLoader( ); } -function getRequestAsyncLocalStorageModuleLocation(modules: string[] | undefined): string | undefined { - if (modules === undefined) { +function getRequestAsyncStorageModuleLocation( + webpackResolvableModuleLocations: string[] | undefined, +): string | undefined { + if (webpackResolvableModuleLocations === undefined) { return undefined; } - try { - // Original location of that module - // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts - const location = 'next/dist/client/components/request-async-storage'; - require.resolve(location, { paths: modules }); - return location; - } catch { - // noop - } + const absoluteWebpackResolvableModuleLocations = webpackResolvableModuleLocations.map(m => path.resolve(m)); + const moduleIsWebpackResolvable = (moduleId: string): boolean => { + let requireResolveLocation: string; + try { + // This will throw if the location is not resolvable at all. + // We provide a `paths` filter in order to maximally limit the potential locations to the locations webpack would check. + requireResolveLocation = require.resolve(moduleId, { paths: webpackResolvableModuleLocations }); + } catch { + return false; + } - try { + // Since the require.resolve approach still looks in "global" node_modules locations like for example "/user/lib/node" + // we further need to filter by locations that start with the locations that webpack would check for. + return absoluteWebpackResolvableModuleLocations.some(resolvableModuleLocation => + requireResolveLocation.startsWith(resolvableModuleLocation), + ); + }; + + const potentialRequestAsyncStorageLocations = [ + // Original location of RequestAsyncStorage + // https://github.com/vercel/next.js/blob/46151dd68b417e7850146d00354f89930d10b43b/packages/next/src/client/components/request-async-storage.ts + 'next/dist/client/components/request-async-storage', // Introduced in Next.js 13.4.20 // https://github.com/vercel/next.js/blob/e1bc270830f2fc2df3542d4ef4c61b916c802df3/packages/next/src/client/components/request-async-storage.external.ts - const location = 'next/dist/client/components/request-async-storage.external'; - require.resolve(location, { paths: modules }); - return location; - } catch { - // noop - } + 'next/dist/client/components/request-async-storage.external', + ]; - return undefined; + return potentialRequestAsyncStorageLocations.find(potentialLocation => moduleIsWebpackResolvable(potentialLocation)); } diff --git a/packages/node-experimental/package.json b/packages/node-experimental/package.json index 6ba4849b58d8..e48a6d57058c 100644 --- a/packages/node-experimental/package.json +++ b/packages/node-experimental/package.json @@ -36,6 +36,7 @@ "@opentelemetry/instrumentation-mysql2": "~0.34.1", "@opentelemetry/instrumentation-nestjs-core": "~0.33.1", "@opentelemetry/instrumentation-pg": "~0.36.1", + "@opentelemetry/resources": "~1.17.0", "@opentelemetry/sdk-trace-base": "~1.17.0", "@opentelemetry/semantic-conventions": "~1.17.0", "@prisma/instrumentation": "~5.3.1", diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index b58eec81880e..3ed0e2ab2b2b 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -1,6 +1,8 @@ import { diag, DiagLogLevel } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; import { AlwaysOnSampler, BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { getCurrentHub } from '@sentry/core'; +import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; +import { getCurrentHub, SDK_VERSION } from '@sentry/core'; import { SentryPropagator, SentrySpanProcessor } from '@sentry/opentelemetry-node'; import { logger } from '@sentry/utils'; @@ -28,6 +30,11 @@ export function initOtel(): () => void { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new AlwaysOnSampler(), + resource: new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: 'node-experimental', + [SemanticResourceAttributes.SERVICE_NAMESPACE]: 'sentry', + [SemanticResourceAttributes.SERVICE_VERSION]: SDK_VERSION, + }), }); provider.addSpanProcessor(new SentrySpanProcessor()); diff --git a/packages/node-integration-tests/suites/anr/scenario.js b/packages/node-integration-tests/suites/anr/scenario.js new file mode 100644 index 000000000000..3abadc09b9c3 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/scenario.js @@ -0,0 +1,31 @@ +const crypto = require('crypto'); + +const Sentry = require('@sentry/node'); + +// close both processes after 5 seconds +setTimeout(() => { + process.exit(); +}, 5000); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + beforeSend: event => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(event)); + }, +}); + +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }).then(() => { + function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } + } + + setTimeout(() => { + longWork(); + }, 1000); +}); diff --git a/packages/node-integration-tests/suites/anr/scenario.mjs b/packages/node-integration-tests/suites/anr/scenario.mjs new file mode 100644 index 000000000000..ba9c8623da7e --- /dev/null +++ b/packages/node-integration-tests/suites/anr/scenario.mjs @@ -0,0 +1,31 @@ +import * as crypto from 'crypto'; + +import * as Sentry from '@sentry/node'; + +// close both processes after 5 seconds +setTimeout(() => { + process.exit(); +}, 5000); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + beforeSend: event => { + // eslint-disable-next-line no-console + console.log(JSON.stringify(event)); + }, +}); + +await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200, debug: true }); + +function longWork() { + for (let i = 0; i < 100; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + // eslint-disable-next-line no-unused-vars + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + } +} + +setTimeout(() => { + longWork(); +}, 1000); diff --git a/packages/node-integration-tests/suites/anr/test.ts b/packages/node-integration-tests/suites/anr/test.ts new file mode 100644 index 000000000000..ec820dca9c62 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -0,0 +1,57 @@ +import type { Event } from '@sentry/node'; +import { parseSemver } from '@sentry/utils'; +import * as childProcess from 'child_process'; +import * as path from 'path'; + +const NODE_VERSION = parseSemver(process.versions.node).major || 0; + +describe('should report ANR when event loop blocked', () => { + test('CJS', done => { + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 6 : 4); + + const testScriptPath = path.resolve(__dirname, 'scenario.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { + const event = JSON.parse(stdout) as Event; + + expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); + expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); + expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); + + if (testFramesDetails) { + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + } + + done(); + }); + }); + + test('ESM', done => { + if (NODE_VERSION < 14) { + done(); + return; + } + + expect.assertions(6); + + const testScriptPath = path.resolve(__dirname, 'scenario.mjs'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { + const event = JSON.parse(stdout) as Event; + + expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); + expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); + expect(event.exception?.values?.[0].value).toEqual('Application Not Responding for at least 200 ms'); + expect(event.exception?.values?.[0].stacktrace?.frames?.length).toBeGreaterThan(4); + expect(event.exception?.values?.[0].stacktrace?.frames?.[2].function).toEqual('?'); + expect(event.exception?.values?.[0].stacktrace?.frames?.[3].function).toEqual('longWork'); + + done(); + }); + }); +}); diff --git a/packages/node/src/anr/debugger.ts b/packages/node/src/anr/debugger.ts new file mode 100644 index 000000000000..4d4a2799fa64 --- /dev/null +++ b/packages/node/src/anr/debugger.ts @@ -0,0 +1,95 @@ +import type { StackFrame } from '@sentry/types'; +import { dropUndefinedKeys, filenameIsInApp } from '@sentry/utils'; +import type { Debugger } from 'inspector'; + +import { getModuleFromFilename } from '../module'; +import { createWebSocketClient } from './websocket'; + +/** + * Converts Debugger.CallFrame to Sentry StackFrame + */ +function callFrameToStackFrame( + frame: Debugger.CallFrame, + filenameFromScriptId: (id: string) => string | undefined, +): StackFrame { + const filename = filenameFromScriptId(frame.location.scriptId)?.replace(/^file:\/\//, ''); + + // CallFrame row/col are 0 based, whereas StackFrame are 1 based + const colno = frame.location.columnNumber ? frame.location.columnNumber + 1 : undefined; + const lineno = frame.location.lineNumber ? frame.location.lineNumber + 1 : undefined; + + return dropUndefinedKeys({ + filename, + module: getModuleFromFilename(filename), + function: frame.functionName || '?', + colno, + lineno, + in_app: filename ? filenameIsInApp(filename) : undefined, + }); +} + +// The only messages we care about +type DebugMessage = + | { + method: 'Debugger.scriptParsed'; + params: Debugger.ScriptParsedEventDataType; + } + | { method: 'Debugger.paused'; params: Debugger.PausedEventDataType }; + +/** + * Wraps a websocket connection with the basic logic of the Node debugger protocol. + * @param url The URL to connect to + * @param onMessage A callback that will be called with each return message from the debugger + * @returns A function that can be used to send commands to the debugger + */ +async function webSocketDebugger( + url: string, + onMessage: (message: DebugMessage) => void, +): Promise<(method: string, params?: unknown) => void> { + let id = 0; + const webSocket = await createWebSocketClient(url); + + webSocket.on('message', (data: Buffer) => { + const message = JSON.parse(data.toString()) as DebugMessage; + onMessage(message); + }); + + return (method: string, params?: unknown) => { + webSocket.send(JSON.stringify({ id: id++, method, params })); + }; +} + +/** + * Captures stack traces from the Node debugger. + * @param url The URL to connect to + * @param callback A callback that will be called with the stack frames + * @returns A function that triggers the debugger to pause and capture a stack trace + */ +export async function captureStackTrace(url: string, callback: (frames: StackFrame[]) => void): Promise<() => void> { + // Collect scriptId -> url map so we can look up the filenames later + const scripts = new Map(); + + const sendCommand = await webSocketDebugger(url, message => { + if (message.method === 'Debugger.scriptParsed') { + scripts.set(message.params.scriptId, message.params.url); + } else if (message.method === 'Debugger.paused') { + // copy the frames + const callFrames = [...message.params.callFrames]; + // and resume immediately! + sendCommand('Debugger.resume'); + sendCommand('Debugger.disable'); + + const frames = callFrames + .map(frame => callFrameToStackFrame(frame, id => scripts.get(id))) + // Sentry expects the frames to be in the opposite order + .reverse(); + + callback(frames); + } + }); + + return () => { + sendCommand('Debugger.enable'); + sendCommand('Debugger.pause'); + }; +} diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts new file mode 100644 index 000000000000..2d546447ddd7 --- /dev/null +++ b/packages/node/src/anr/index.ts @@ -0,0 +1,245 @@ +import type { Event, StackFrame } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import { fork } from 'child_process'; +import * as inspector from 'inspector'; + +import { addGlobalEventProcessor, captureEvent, flush } from '..'; +import { captureStackTrace } from './debugger'; + +const DEFAULT_INTERVAL = 50; +const DEFAULT_HANG_THRESHOLD = 5000; + +/** + * A node.js watchdog timer + * @param pollInterval The interval that we expect to get polled at + * @param anrThreshold The threshold for when we consider ANR + * @param callback The callback to call for ANR + * @returns A function to call to reset the timer + */ +function watchdogTimer(pollInterval: number, anrThreshold: number, callback: () => void): () => void { + let lastPoll = process.hrtime(); + let triggered = false; + + setInterval(() => { + const [seconds, nanoSeconds] = process.hrtime(lastPoll); + const diffMs = Math.floor(seconds * 1e3 + nanoSeconds / 1e6); + + if (triggered === false && diffMs > pollInterval + anrThreshold) { + triggered = true; + callback(); + } + + if (diffMs < pollInterval + anrThreshold) { + triggered = false; + } + }, 20); + + return () => { + lastPoll = process.hrtime(); + }; +} + +interface Options { + /** + * The app entry script. This is used to run the same script as the child process. + * + * Defaults to `process.argv[1]`. + */ + entryScript: string; + /** + * Interval to send heartbeat messages to the child process. + * + * Defaults to 50ms. + */ + pollInterval: number; + /** + * Threshold in milliseconds to trigger an ANR event. + * + * Defaults to 5000ms. + */ + anrThreshold: number; + /** + * Whether to capture a stack trace when the ANR event is triggered. + * + * Defaults to `false`. + * + * This uses the node debugger which enables the inspector API and opens the required ports. + */ + captureStackTrace: boolean; + /** + * Log debug information. + */ + debug: boolean; +} + +function sendEvent(blockedMs: number, frames?: StackFrame[]): void { + const event: Event = { + level: 'error', + exception: { + values: [ + { + type: 'ApplicationNotResponding', + value: `Application Not Responding for at least ${blockedMs} ms`, + stacktrace: { frames }, + mechanism: { + // This ensures the UI doesn't say 'Crashed in' for the stack trace + type: 'ANR', + }, + }, + ], + }, + }; + + captureEvent(event); + + void flush(3000).then(() => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }); +} + +function startChildProcess(options: Options): void { + function log(message: string, err?: unknown): void { + if (options.debug) { + if (err) { + logger.log(`[ANR] ${message}`, err); + } else { + logger.log(`[ANR] ${message}`); + } + } + } + + try { + const env = { ...process.env }; + + if (options.captureStackTrace) { + inspector.open(); + env.SENTRY_INSPECT_URL = inspector.url(); + } + + const child = fork(options.entryScript, { + env, + stdio: options.debug ? 'inherit' : 'ignore', + }); + // The child process should not keep the main process alive + child.unref(); + + const timer = setInterval(() => { + try { + // message the child process to tell it the main event loop is still running + child.send('ping'); + } catch (_) { + // + } + }, options.pollInterval); + + const end = (err: unknown): void => { + clearInterval(timer); + log('Child process ended', err); + }; + + child.on('error', end); + child.on('disconnect', end); + child.on('exit', end); + } catch (e) { + log('Failed to start child process', e); + } +} + +function handleChildProcess(options: Options): void { + function log(message: string): void { + if (options.debug) { + logger.log(`[ANR child process] ${message}`); + } + } + + log('Started'); + + addGlobalEventProcessor(event => { + // Strip sdkProcessingMetadata from all child process events to remove trace info + delete event.sdkProcessingMetadata; + event.tags = { + ...event.tags, + 'process.name': 'ANR', + }; + return event; + }); + + let debuggerPause: Promise<() => void> | undefined; + + // if attachStackTrace is enabled, we'll have a debugger url to connect to + if (process.env.SENTRY_INSPECT_URL) { + log('Connecting to debugger'); + + debuggerPause = captureStackTrace(process.env.SENTRY_INSPECT_URL, frames => { + log('Capturing event with stack frames'); + sendEvent(options.anrThreshold, frames); + }); + } + + async function watchdogTimeout(): Promise { + log('Watchdog timeout'); + const pauseAndCapture = await debuggerPause; + + if (pauseAndCapture) { + log('Pausing debugger to capture stack trace'); + pauseAndCapture(); + } else { + log('Capturing event'); + sendEvent(options.anrThreshold); + } + } + + const ping = watchdogTimer(options.pollInterval, options.anrThreshold, watchdogTimeout); + + process.on('message', () => { + ping(); + }); +} + +/** + * **Note** This feature is still in beta so there may be breaking changes in future releases. + * + * Starts a child process that detects Application Not Responding (ANR) errors. + * + * It's important to await on the returned promise before your app code to ensure this code does not run in the ANR + * child process. + * + * ```js + * import { init, enableAnrDetection } from '@sentry/node'; + * + * init({ dsn: "__DSN__" }); + * + * // with ESM + Node 14+ + * await enableAnrDetection({ captureStackTrace: true }); + * runApp(); + * + * // with CJS or Node 10+ + * enableAnrDetection({ captureStackTrace: true }).then(() => { + * runApp(); + * }); + * ``` + */ +export function enableAnrDetection(options: Partial): Promise { + const isChildProcess = !!process.send; + + const anrOptions: Options = { + entryScript: options.entryScript || process.argv[1], + pollInterval: options.pollInterval || DEFAULT_INTERVAL, + anrThreshold: options.anrThreshold || DEFAULT_HANG_THRESHOLD, + captureStackTrace: !!options.captureStackTrace, + debug: !!options.debug, + }; + + if (isChildProcess) { + handleChildProcess(anrOptions); + // In the child process, the promise never resolves which stops the app code from running + return new Promise(() => { + // Never resolve + }); + } else { + startChildProcess(anrOptions); + // In the main process, the promise resolves immediately + return Promise.resolve(); + } +} diff --git a/packages/node/src/anr/websocket.ts b/packages/node/src/anr/websocket.ts new file mode 100644 index 000000000000..9faa90bcfd1c --- /dev/null +++ b/packages/node/src/anr/websocket.ts @@ -0,0 +1,359 @@ +/* eslint-disable no-bitwise */ +/** + * A simple WebSocket client implementation copied from Rome before being modified for our use: + * https://github.com/jeremyBanks/rome/tree/b034dd22d5f024f87c50eef2872e22b3ad48973a/packages/%40romejs/codec-websocket + * + * Original license: + * + * MIT License + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { EventEmitter } from 'events'; +import * as http from 'http'; +import type { Socket } from 'net'; +import * as url from 'url'; + +type BuildFrameOpts = { + opcode: number; + fin: boolean; + data: Buffer; +}; + +type Frame = { + fin: boolean; + opcode: number; + mask: undefined | Buffer; + payload: Buffer; + payloadLength: number; +}; + +const OPCODES = { + CONTINUATION: 0, + TEXT: 1, + BINARY: 2, + TERMINATE: 8, + PING: 9, + PONG: 10, +}; + +const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +function isCompleteFrame(frame: Frame): boolean { + return Buffer.byteLength(frame.payload) >= frame.payloadLength; +} + +function unmaskPayload(payload: Buffer, mask: undefined | Buffer, offset: number): Buffer { + if (mask === undefined) { + return payload; + } + + for (let i = 0; i < payload.length; i++) { + payload[i] ^= mask[(offset + i) & 3]; + } + + return payload; +} + +function buildFrame(opts: BuildFrameOpts): Buffer { + const { opcode, fin, data } = opts; + + let offset = 6; + let dataLength = data.length; + + if (dataLength >= 65_536) { + offset += 8; + dataLength = 127; + } else if (dataLength > 125) { + offset += 2; + dataLength = 126; + } + + const head = Buffer.allocUnsafe(offset); + + head[0] = fin ? opcode | 128 : opcode; + head[1] = dataLength; + + if (dataLength === 126) { + head.writeUInt16BE(data.length, 2); + } else if (dataLength === 127) { + head.writeUInt32BE(0, 2); + head.writeUInt32BE(data.length, 6); + } + + const mask = crypto.randomBytes(4); + head[1] |= 128; + head[offset - 4] = mask[0]; + head[offset - 3] = mask[1]; + head[offset - 2] = mask[2]; + head[offset - 1] = mask[3]; + + const masked = Buffer.alloc(dataLength); + for (let i = 0; i < dataLength; ++i) { + masked[i] = data[i] ^ mask[i & 3]; + } + + return Buffer.concat([head, masked]); +} + +function parseFrame(buffer: Buffer): Frame { + const firstByte = buffer.readUInt8(0); + const isFinalFrame: boolean = Boolean((firstByte >>> 7) & 1); + const opcode: number = firstByte & 15; + + const secondByte: number = buffer.readUInt8(1); + const isMasked: boolean = Boolean((secondByte >>> 7) & 1); + + // Keep track of our current position as we advance through the buffer + let currentOffset = 2; + let payloadLength = secondByte & 127; + if (payloadLength > 125) { + if (payloadLength === 126) { + payloadLength = buffer.readUInt16BE(currentOffset); + currentOffset += 2; + } else if (payloadLength === 127) { + const leftPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned + + // if payload length is greater than this number. + if (leftPart >= Number.MAX_SAFE_INTEGER) { + throw new Error('Unsupported WebSocket frame: payload length > 2^53 - 1'); + } + + const rightPart = buffer.readUInt32BE(currentOffset); + currentOffset += 4; + + payloadLength = leftPart * Math.pow(2, 32) + rightPart; + } else { + throw new Error('Unknown payload length'); + } + } + + // Get the masking key if one exists + let mask; + if (isMasked) { + mask = buffer.slice(currentOffset, currentOffset + 4); + currentOffset += 4; + } + + const payload = unmaskPayload(buffer.slice(currentOffset), mask, 0); + + return { + fin: isFinalFrame, + opcode, + mask, + payload, + payloadLength, + }; +} + +function createKey(key: string): string { + return crypto.createHash('sha1').update(`${key}${GUID}`).digest('base64'); +} + +class WebSocketInterface extends EventEmitter { + private _alive: boolean; + private _incompleteFrame: undefined | Frame; + private _unfinishedFrame: undefined | Frame; + private _socket: Socket; + + public constructor(socket: Socket) { + super(); + // When a frame is set here then any additional continuation frames payloads will be appended + this._unfinishedFrame = undefined; + + // When a frame is set here, all additional chunks will be appended until we reach the correct payloadLength + this._incompleteFrame = undefined; + + this._socket = socket; + this._alive = true; + + socket.on('data', buff => { + this._addBuffer(buff); + }); + + socket.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ECONNRESET') { + this.emit('close'); + } else { + this.emit('error'); + } + }); + + socket.on('close', () => { + this.end(); + }); + } + + public end(): void { + if (!this._alive) { + return; + } + + this._alive = false; + this.emit('close'); + this._socket.end(); + } + + public send(buff: string): void { + this._sendFrame({ + opcode: OPCODES.TEXT, + fin: true, + data: Buffer.from(buff), + }); + } + + private _sendFrame(frameOpts: BuildFrameOpts): void { + this._socket.write(buildFrame(frameOpts)); + } + + private _completeFrame(frame: Frame): void { + // If we have an unfinished frame then only allow continuations + const { _unfinishedFrame: unfinishedFrame } = this; + if (unfinishedFrame !== undefined) { + if (frame.opcode === OPCODES.CONTINUATION) { + unfinishedFrame.payload = Buffer.concat([ + unfinishedFrame.payload, + unmaskPayload(frame.payload, unfinishedFrame.mask, unfinishedFrame.payload.length), + ]); + + if (frame.fin) { + this._unfinishedFrame = undefined; + this._completeFrame(unfinishedFrame); + } + return; + } else { + // Silently ignore the previous frame... + this._unfinishedFrame = undefined; + } + } + + if (frame.fin) { + if (frame.opcode === OPCODES.PING) { + this._sendFrame({ + opcode: OPCODES.PONG, + fin: true, + data: frame.payload, + }); + } else { + // Trim off any excess payload + let excess; + if (frame.payload.length > frame.payloadLength) { + excess = frame.payload.slice(frame.payloadLength); + frame.payload = frame.payload.slice(0, frame.payloadLength); + } + + this.emit('message', frame.payload); + + if (excess !== undefined) { + this._addBuffer(excess); + } + } + } else { + this._unfinishedFrame = frame; + } + } + + private _addBufferToIncompleteFrame(incompleteFrame: Frame, buff: Buffer): void { + incompleteFrame.payload = Buffer.concat([ + incompleteFrame.payload, + unmaskPayload(buff, incompleteFrame.mask, incompleteFrame.payload.length), + ]); + + if (isCompleteFrame(incompleteFrame)) { + this._incompleteFrame = undefined; + this._completeFrame(incompleteFrame); + } + } + + private _addBuffer(buff: Buffer): void { + // Check if we're still waiting for the rest of a payload + const { _incompleteFrame: incompleteFrame } = this; + if (incompleteFrame !== undefined) { + this._addBufferToIncompleteFrame(incompleteFrame, buff); + return; + } + + const frame = parseFrame(buff); + + if (isCompleteFrame(frame)) { + // Frame has been completed! + this._completeFrame(frame); + } else { + this._incompleteFrame = frame; + } + } +} + +/** + * Creates a WebSocket client + */ +export async function createWebSocketClient(rawUrl: string): Promise { + const parts = url.parse(rawUrl); + + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString('base64'); + const digest = createKey(key); + + const req = http.request({ + hostname: parts.hostname, + port: parts.port, + path: parts.path, + method: 'GET', + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Key': key, + 'Sec-WebSocket-Version': '13', + }, + }); + + req.on('response', (res: http.IncomingMessage) => { + if (res.statusCode && res.statusCode >= 400) { + process.stderr.write(`Unexpected HTTP code: ${res.statusCode}\n`); + res.pipe(process.stderr); + } else { + res.pipe(process.stderr); + } + }); + + req.on('upgrade', (res: http.IncomingMessage, socket: Socket) => { + if (res.headers['sec-websocket-accept'] !== digest) { + socket.end(); + reject(new Error(`Digest mismatch ${digest} !== ${res.headers['sec-websocket-accept']}`)); + return; + } + + const client = new WebSocketInterface(socket); + resolve(client); + }); + + req.on('error', err => { + reject(err); + }); + + req.end(); + }); +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index c7d93ef16463..503f2749ea29 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -71,6 +71,7 @@ export { defaultIntegrations, init, defaultStackParser, getSentryRelease } from export { addRequestDataToEvent, DEFAULT_USER_INCLUDES, extractRequestData } from './requestdata'; export { deepReadDirSync } from './utils'; export { getModuleFromFilename } from './module'; +export { enableAnrDetection } from './anr'; import { Integrations as CoreIntegrations } from '@sentry/core'; diff --git a/packages/node/src/integrations/undici/types.ts b/packages/node/src/integrations/undici/types.ts index f56e708f456c..d885984671bf 100644 --- a/packages/node/src/integrations/undici/types.ts +++ b/packages/node/src/integrations/undici/types.ts @@ -1,6 +1,7 @@ // Vendored from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/5a94716c6788f654aea7999a5fc28f4f1e7c48ad/types/node/diagnostics_channel.d.ts import type { Span } from '@sentry/core'; +import type { URL } from 'url'; // License: // This project is licensed under the MIT license. @@ -224,7 +225,7 @@ export interface UndiciRequest { method?: string; path: string; headers: string; - addHeader(key: string, value: string): Request; + addHeader(key: string, value: string): RequestWithSentry; } export interface UndiciResponse { diff --git a/packages/node/src/requestdata.ts b/packages/node/src/requestdata.ts index a0d5aed926a9..bc07fcf92f8b 100644 --- a/packages/node/src/requestdata.ts +++ b/packages/node/src/requestdata.ts @@ -320,11 +320,5 @@ function extractQueryParams(req: PolymorphicRequest): string | Record { if (d2done) { done(); } - }); + }, 0); }); runWithAsyncContext(() => { @@ -131,7 +131,7 @@ describe('domains', () => { if (d1done) { done(); } - }); + }, 0); }); }); }); diff --git a/packages/node/test/async/hooks.test.ts b/packages/node/test/async/hooks.test.ts index a08271230579..ad477e03d477 100644 --- a/packages/node/test/async/hooks.test.ts +++ b/packages/node/test/async/hooks.test.ts @@ -130,7 +130,7 @@ conditionalTest({ min: 12 })('async_hooks', () => { if (d2done) { done(); } - }); + }, 0); }); runWithAsyncContext(() => { @@ -142,7 +142,7 @@ conditionalTest({ min: 12 })('async_hooks', () => { if (d1done) { done(); } - }); + }, 0); }); }); }); diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index bf45a09f2d71..5fc0658105eb 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -4,6 +4,6 @@ "include": ["src/**/*"], "compilerOptions": { - // package-specific options + "lib": ["es6"] } } diff --git a/packages/replay/src/coreHandlers/handleGlobalEvent.ts b/packages/replay/src/coreHandlers/handleGlobalEvent.ts index c2e134a86acb..f69e8d975417 100644 --- a/packages/replay/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay/src/coreHandlers/handleGlobalEvent.ts @@ -35,6 +35,12 @@ export function handleGlobalEventListener( return event; } + // Ensure we do not add replay_id if the session is expired + const isSessionActive = replay.checkAndHandleExpiredSession(); + if (!isSessionActive) { + return event; + } + // Unless `captureExceptions` is enabled, we want to ignore errors coming from rrweb // As there can be a bunch of stuff going wrong in internals there, that we don't want to bubble up to users if (isRrwebError(event, hint) && !replay.getOptions()._experiments.captureExceptions) { diff --git a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts index dbe919b18079..d4357eb4a6ea 100644 --- a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -1,9 +1,10 @@ import type { Event } from '@sentry/types'; import type { Replay as ReplayIntegration } from '../../../src'; -import { REPLAY_EVENT_NAME } from '../../../src/constants'; +import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; +import { makeSession } from '../../../src/session/Session'; import { Error } from '../../fixtures/error'; import { Transaction } from '../../fixtures/transaction'; import { resetSdkMock } from '../../mocks/resetSdkMock'; @@ -102,6 +103,32 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { ); }); + it('does not add replayId if replay session is expired', async () => { + const transaction = Transaction(); + const error = Error(); + + const now = Date.now(); + + replay.session = makeSession({ + id: 'test-session-id', + segmentId: 0, + lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1, + started: now - SESSION_IDLE_EXPIRE_DURATION - 1, + sampled: 'session', + }); + + expect(handleGlobalEventListener(replay)(transaction, {})).toEqual( + expect.objectContaining({ + tags: expect.not.objectContaining({ replayId: expect.anything() }), + }), + ); + expect(handleGlobalEventListener(replay)(error, {})).toEqual( + expect.objectContaining({ + tags: expect.not.objectContaining({ replayId: expect.anything() }), + }), + ); + }); + it('tags errors and transactions with replay id for session samples', async () => { let integration: ReplayIntegration; ({ replay, integration } = await resetSdkMock({})); diff --git a/packages/types/src/mechanism.ts b/packages/types/src/mechanism.ts index 0f2adf98ed24..9d3dc86e7382 100644 --- a/packages/types/src/mechanism.ts +++ b/packages/types/src/mechanism.ts @@ -13,7 +13,7 @@ export interface Mechanism { * it hits the global error/rejection handlers, whether through explicit handling by the user or auto instrumentation. * Converted to a tag on ingest and used in various ways in the UI. */ - handled: boolean; + handled?: boolean; /** * Arbitrary data to be associated with the mechanism (for example, errors coming from event handlers include the diff --git a/packages/utils/src/node-stack-trace.ts b/packages/utils/src/node-stack-trace.ts index 00b02b0fee35..43db209a5fc5 100644 --- a/packages/utils/src/node-stack-trace.ts +++ b/packages/utils/src/node-stack-trace.ts @@ -25,6 +25,29 @@ import type { StackLineParserFn } from '@sentry/types'; export type GetModuleFn = (filename: string | undefined) => string | undefined; +/** + * Does this filename look like it's part of the app code? + */ +export function filenameIsInApp(filename: string, isNative: boolean = false): boolean { + const isInternal = + isNative || + (filename && + // It's not internal if it's an absolute linux path + !filename.startsWith('/') && + // It's not internal if it's an absolute windows path + !filename.includes(':\\') && + // It's not internal if the path is starting with a dot + !filename.startsWith('.') && + // It's not internal if the frame has a protocol. In node, this is usually the case if the file got pre-processed with a bundler like webpack + !filename.match(/^[a-zA-Z]([a-zA-Z0-9.\-+])*:\/\//)); // Schema from: https://stackoverflow.com/a/3641782 + + // in_app is all that's not an internal Node function or a module within node_modules + // note that isNative appears to return true even for node core libraries + // see https://github.com/getsentry/raven-node/issues/176 + + return !isInternal && filename !== undefined && !filename.includes('node_modules/'); +} + /** Node Stack line parser */ // eslint-disable-next-line complexity export function node(getModule?: GetModuleFn): StackLineParserFn { @@ -84,31 +107,13 @@ export function node(getModule?: GetModuleFn): StackLineParserFn { filename = lineMatch[5]; } - const isInternal = - isNative || - (filename && - // It's not internal if it's an absolute linux path - !filename.startsWith('/') && - // It's not internal if it's an absolute windows path - !filename.includes(':\\') && - // It's not internal if the path is starting with a dot - !filename.startsWith('.') && - // It's not internal if the frame has a protocol. In node, this is usually the case if the file got pre-processed with a bundler like webpack - !filename.match(/^[a-zA-Z]([a-zA-Z0-9.\-+])*:\/\//)); // Schema from: https://stackoverflow.com/a/3641782 - - // in_app is all that's not an internal Node function or a module within node_modules - // note that isNative appears to return true even for node core libraries - // see https://github.com/getsentry/raven-node/issues/176 - - const in_app = !isInternal && filename !== undefined && !filename.includes('node_modules/'); - return { filename, module: getModule ? getModule(filename) : undefined, function: functionName, lineno: parseInt(lineMatch[3], 10) || undefined, colno: parseInt(lineMatch[4], 10) || undefined, - in_app, + in_app: filenameIsInApp(filename, isNative), }; } diff --git a/packages/utils/src/stacktrace.ts b/packages/utils/src/stacktrace.ts index ac9f2159221d..917b46daa5d1 100644 --- a/packages/utils/src/stacktrace.ts +++ b/packages/utils/src/stacktrace.ts @@ -1,7 +1,9 @@ import type { StackFrame, StackLineParser, StackParser } from '@sentry/types'; import type { GetModuleFn } from './node-stack-trace'; -import { node } from './node-stack-trace'; +import { filenameIsInApp, node } from './node-stack-trace'; + +export { filenameIsInApp }; const STACKTRACE_FRAME_LIMIT = 50; // Used to sanitize webpack (error: *) wrapped stack errors diff --git a/yarn.lock b/yarn.lock index 774b6abcc4dd..25082dd4c5c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3993,7 +3993,7 @@ "@opentelemetry/core" "1.15.2" "@opentelemetry/semantic-conventions" "1.15.2" -"@opentelemetry/resources@1.17.0": +"@opentelemetry/resources@1.17.0", "@opentelemetry/resources@~1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-1.17.0.tgz#ee29144cfd7d194c69698c8153dbadec7fe6819f" integrity sha512-+u0ciVnj8lhuL/qGRBPeVYvk7fL+H/vOddfvmOeJaA1KC+5/3UED1c9KoZQlRsNT5Kw1FaK8LkY2NVLYfOVZQw==