diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2987f09addb5..4c82ef60ffd4 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -57,6 +57,10 @@ export function updateSession(session: Session, context: SessionContext = {}): v session.timestamp = context.timestamp || timestampInSeconds(); + if (context.abnormal_mechanism) { + session.abnormal_mechanism = context.abnormal_mechanism; + } + if (context.ignoreDuration) { session.ignoreDuration = context.ignoreDuration; } @@ -143,6 +147,7 @@ function sessionToJSON(session: Session): SerializedSession { errors: session.errors, did: typeof session.did === 'number' || typeof session.did === 'string' ? `${session.did}` : undefined, duration: session.duration, + abnormal_mechanism: session.abnormal_mechanism, attrs: { release: session.release, environment: session.environment, diff --git a/packages/node-integration-tests/suites/anr/basic-session.js b/packages/node-integration-tests/suites/anr/basic-session.js new file mode 100644 index 000000000000..29cdc17e76c9 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/basic-session.js @@ -0,0 +1,31 @@ +const crypto = require('crypto'); + +const Sentry = require('@sentry/node'); + +const { transport } = require('./test-transport.js'); + +// close both processes after 5 seconds +setTimeout(() => { + process.exit(); +}, 5000); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + debug: true, + transport, +}); + +Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).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/basic.js b/packages/node-integration-tests/suites/anr/basic.js index 45a324e507c5..33c4151a19f1 100644 --- a/packages/node-integration-tests/suites/anr/basic.js +++ b/packages/node-integration-tests/suites/anr/basic.js @@ -2,6 +2,8 @@ const crypto = require('crypto'); const Sentry = require('@sentry/node'); +const { transport } = require('./test-transport.js'); + // close both processes after 5 seconds setTimeout(() => { process.exit(); @@ -11,10 +13,8 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + autoSessionTracking: false, + transport, }); Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { diff --git a/packages/node-integration-tests/suites/anr/basic.mjs b/packages/node-integration-tests/suites/anr/basic.mjs index 1d89ac1b3989..3d10dc556076 100644 --- a/packages/node-integration-tests/suites/anr/basic.mjs +++ b/packages/node-integration-tests/suites/anr/basic.mjs @@ -2,6 +2,8 @@ import * as crypto from 'crypto'; import * as Sentry from '@sentry/node'; +const { transport } = await import('./test-transport.js'); + // close both processes after 5 seconds setTimeout(() => { process.exit(); @@ -11,10 +13,8 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + autoSessionTracking: false, + transport, }); await Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }); diff --git a/packages/node-integration-tests/suites/anr/forked.js b/packages/node-integration-tests/suites/anr/forked.js index 45a324e507c5..33c4151a19f1 100644 --- a/packages/node-integration-tests/suites/anr/forked.js +++ b/packages/node-integration-tests/suites/anr/forked.js @@ -2,6 +2,8 @@ const crypto = require('crypto'); const Sentry = require('@sentry/node'); +const { transport } = require('./test-transport.js'); + // close both processes after 5 seconds setTimeout(() => { process.exit(); @@ -11,10 +13,8 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', debug: true, - beforeSend: event => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(event)); - }, + autoSessionTracking: false, + transport, }); Sentry.enableAnrDetection({ captureStackTrace: true, anrThreshold: 200 }).then(() => { diff --git a/packages/node-integration-tests/suites/anr/test-transport.js b/packages/node-integration-tests/suites/anr/test-transport.js new file mode 100644 index 000000000000..86836cd6ab35 --- /dev/null +++ b/packages/node-integration-tests/suites/anr/test-transport.js @@ -0,0 +1,17 @@ +const { TextEncoder, TextDecoder } = require('util'); + +const { createTransport } = require('@sentry/core'); +const { parseEnvelope } = require('@sentry/utils'); + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// A transport that just logs the envelope payloads to console for checking in tests +exports.transport = () => { + return createTransport({ recordDroppedEvent: () => {}, textEncoder }, async request => { + const env = parseEnvelope(request.body, textEncoder, textDecoder); + // eslint-disable-next-line no-console + console.log(JSON.stringify(env[1][0][1])); + return { statusCode: 200 }; + }); +}; diff --git a/packages/node-integration-tests/suites/anr/test.ts b/packages/node-integration-tests/suites/anr/test.ts index 96d83c64a6a7..e7214ae194ec 100644 --- a/packages/node-integration-tests/suites/anr/test.ts +++ b/packages/node-integration-tests/suites/anr/test.ts @@ -1,4 +1,5 @@ import type { Event } from '@sentry/node'; +import type { SerializedSession } from '@sentry/types'; import { parseSemver } from '@sentry/utils'; import * as childProcess from 'child_process'; import * as path from 'path'; @@ -6,19 +7,21 @@ import * as path from 'path'; const NODE_VERSION = parseSemver(process.versions.node).major || 0; /** The output will contain logging so we need to find the line that parses as JSON */ -function parseJsonLine(input: string): T { - return ( - input - .split('\n') - .map(line => { - try { - return JSON.parse(line) as T; - } catch { - return undefined; - } - }) - .filter(a => a) as T[] - )[0]; +function parseJsonLines(input: string, expected: number): T { + const results = input + .split('\n') + .map(line => { + try { + return JSON.parse(line) as T; + } catch { + return undefined; + } + }) + .filter(a => a) as T; + + expect(results.length).toEqual(expected); + + return results; } describe('should report ANR when event loop blocked', () => { @@ -26,12 +29,12 @@ describe('should report ANR when event loop blocked', () => { // The stack trace is different when node < 12 const testFramesDetails = NODE_VERSION >= 12; - expect.assertions(testFramesDetails ? 6 : 4); + expect.assertions(testFramesDetails ? 7 : 5); const testScriptPath = path.resolve(__dirname, 'basic.js'); childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = parseJsonLine(stdout); + const [event] = parseJsonLines<[Event]>(stdout, 1); expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); @@ -53,12 +56,12 @@ describe('should report ANR when event loop blocked', () => { return; } - expect.assertions(6); + expect.assertions(7); const testScriptPath = path.resolve(__dirname, 'basic.mjs'); childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = parseJsonLine(stdout); + const [event] = parseJsonLines<[Event]>(stdout, 1); expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); @@ -71,16 +74,44 @@ describe('should report ANR when event loop blocked', () => { }); }); + test('With session', done => { + // The stack trace is different when node < 12 + const testFramesDetails = NODE_VERSION >= 12; + + expect.assertions(testFramesDetails ? 9 : 7); + + const testScriptPath = path.resolve(__dirname, 'basic-session.js'); + + childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { + const [session, event] = parseJsonLines<[SerializedSession, Event]>(stdout, 2); + + 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'); + } + + expect(session.status).toEqual('abnormal'); + expect(session.abnormal_mechanism).toEqual('anr_foreground'); + + done(); + }); + }); + test('from forked process', done => { // The stack trace is different when node < 12 const testFramesDetails = NODE_VERSION >= 12; - expect.assertions(testFramesDetails ? 6 : 4); + expect.assertions(testFramesDetails ? 7 : 5); const testScriptPath = path.resolve(__dirname, 'forker.js'); childProcess.exec(`node ${testScriptPath}`, { encoding: 'utf8' }, (_, stdout) => { - const event = parseJsonLine(stdout); + const [event] = parseJsonLines<[Event]>(stdout, 1); expect(event.exception?.values?.[0].mechanism).toEqual({ type: 'ANR' }); expect(event.exception?.values?.[0].type).toEqual('ApplicationNotResponding'); diff --git a/packages/node/src/anr/index.ts b/packages/node/src/anr/index.ts index 9c6827c38259..caa384b3928f 100644 --- a/packages/node/src/anr/index.ts +++ b/packages/node/src/anr/index.ts @@ -1,8 +1,9 @@ -import type { Event, StackFrame } from '@sentry/types'; +import { makeSession, updateSession } from '@sentry/core'; +import type { Event, Session, StackFrame } from '@sentry/types'; import { logger, watchdogTimer } from '@sentry/utils'; import { spawn } from 'child_process'; -import { addGlobalEventProcessor, captureEvent, flush } from '..'; +import { addGlobalEventProcessor, captureEvent, flush, getCurrentHub } from '..'; import { captureStackTrace } from './debugger'; const DEFAULT_INTERVAL = 50; @@ -41,8 +42,8 @@ interface Options { debug: boolean; } -function sendEvent(blockedMs: number, frames?: StackFrame[]): void { - const event: Event = { +function createAnrEvent(blockedMs: number, frames?: StackFrame[]): Event { + return { level: 'error', exception: { values: [ @@ -58,13 +59,6 @@ function sendEvent(blockedMs: number, frames?: StackFrame[]): void { ], }, }; - - captureEvent(event); - - void flush(3000).then(() => { - // We only capture one event to avoid spamming users with errors - process.exit(); - }); } interface InspectorApi { @@ -97,6 +91,8 @@ function startChildProcess(options: Options): void { logger.log(`[ANR] ${message}`, ...args); } + const hub = getCurrentHub(); + try { const env = { ...process.env }; env.SENTRY_ANR_CHILD_PROCESS = 'true'; @@ -105,7 +101,7 @@ function startChildProcess(options: Options): void { env.SENTRY_INSPECT_URL = startInspector(); } - log(`Spawning child process with execPath:'${process.execPath}' and entryScript'${options.entryScript}'`); + log(`Spawning child process with execPath:'${process.execPath}' and entryScript:'${options.entryScript}'`); const child = spawn(process.execPath, [options.entryScript], { env, @@ -116,13 +112,24 @@ function startChildProcess(options: Options): void { const timer = setInterval(() => { try { + const currentSession = hub.getScope()?.getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the child process + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; // message the child process to tell it the main event loop is still running - child.send('ping'); + child.send({ session }); } catch (_) { // } }, options.pollInterval); + child.on('message', (msg: string) => { + if (msg === 'session-ended') { + log('ANR event sent from child process. Clearing session in this process.'); + hub.getScope()?.setSession(undefined); + } + }); + const end = (type: string): ((...args: unknown[]) => void) => { return (...args): void => { clearInterval(timer); @@ -153,13 +160,36 @@ function createHrTimer(): { getTimeMs: () => number; reset: () => void } { } function handleChildProcess(options: Options): void { + process.title = 'sentry-anr'; + function log(message: string): void { logger.log(`[ANR child process] ${message}`); } - process.title = 'sentry-anr'; - log('Started'); + let session: Session | undefined; + + function sendAnrEvent(frames?: StackFrame[]): void { + if (session) { + log('Sending abnormal session'); + updateSession(session, { status: 'abnormal', abnormal_mechanism: 'anr_foreground' }); + getCurrentHub().getClient()?.sendSession(session); + + try { + // Notify the main process that the session has ended so the session can be cleared from the scope + process.send?.('session-ended'); + } catch (_) { + // ignore + } + } + + captureEvent(createAnrEvent(options.anrThreshold, frames)); + + void flush(3000).then(() => { + // We only capture one event to avoid spamming users with errors + process.exit(); + }); + } addGlobalEventProcessor(event => { // Strip sdkProcessingMetadata from all child process events to remove trace info @@ -179,7 +209,7 @@ function handleChildProcess(options: Options): void { debuggerPause = captureStackTrace(process.env.SENTRY_INSPECT_URL, frames => { log('Capturing event with stack frames'); - sendEvent(options.anrThreshold, frames); + sendAnrEvent(frames); }); } @@ -192,13 +222,16 @@ function handleChildProcess(options: Options): void { pauseAndCapture(); } else { log('Capturing event'); - sendEvent(options.anrThreshold); + sendAnrEvent(); } } const { poll } = watchdogTimer(createHrTimer, options.pollInterval, options.anrThreshold, watchdogTimeout); - process.on('message', () => { + process.on('message', (msg: { session: Session | undefined }) => { + if (msg.session) { + session = makeSession(msg.session); + } poll(); }); } diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index 78b7a5b39654..5bc49b9a7733 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -21,7 +21,7 @@ export interface Session { errors: number; user?: User | null; ignoreDuration: boolean; - + abnormal_mechanism?: string; /** * Overrides default JSON serialization of the Session because * the Sentry servers expect a slightly different schema of a session @@ -76,6 +76,7 @@ export interface SerializedSession { duration?: number; status: SessionStatus; errors: number; + abnormal_mechanism?: string; attrs?: { release?: string; environment?: string;