Skip to content
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

feat(node): Add scope to ANR events (v7) #11267

Merged
merged 7 commits into from
Mar 28, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Sentry.init({
integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })],
});

Sentry.setUser({ email: 'person@home.com' });
Sentry.addBreadcrumb({ message: 'important message!' });

function longWork() {
for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
Expand Down
3 changes: 3 additions & 0 deletions dev-packages/node-integration-tests/suites/anr/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Sentry.init({
integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })],
});

Sentry.setUser({ email: 'person@home.com' });
Sentry.addBreadcrumb({ message: 'important message!' });

function longWork() {
for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
Expand Down
3 changes: 3 additions & 0 deletions dev-packages/node-integration-tests/suites/anr/basic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Sentry.init({
integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })],
});

Sentry.setUser({ email: 'person@home.com' });
Sentry.addBreadcrumb({ message: 'important message!' });

function longWork() {
for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
Expand Down
3 changes: 3 additions & 0 deletions dev-packages/node-integration-tests/suites/anr/forked.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Sentry.init({
integrations: [new Sentry.Integrations.Anr({ captureStackTrace: true, anrThreshold: 100 })],
});

Sentry.setUser({ email: 'person@home.com' });
Sentry.addBreadcrumb({ message: 'important message!' });

function longWork() {
for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
Expand Down
53 changes: 53 additions & 0 deletions dev-packages/node-integration-tests/suites/anr/isolated.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as assert from 'assert';
import * as crypto from 'crypto';

import * as Sentry from '@sentry/node';

setTimeout(() => {
process.exit();
}, 10000);

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
debug: true,
autoSessionTracking: false,
integrations: [Sentry.anrIntegration({ captureStackTrace: true, anrThreshold: 100 })],
});

async function longWork() {
await new Promise(resolve => setTimeout(resolve, 1000));

for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
assert.ok(hash);
}
}

function neverResolve() {
return new Promise(() => {
//
});
}

const fns = [
neverResolve,
neverResolve,
neverResolve,
neverResolve,
neverResolve,
longWork, // [5]
neverResolve,
neverResolve,
neverResolve,
neverResolve,
];

for (let id = 0; id < 10; id++) {
Sentry.withIsolationScope(async () => {
Sentry.setUser({ id });

await fns[id]();
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Sentry.init({
integrations: [anr],
});

Sentry.setUser({ email: 'person@home.com' });
Sentry.addBreadcrumb({ message: 'important message!' });

function longWorkIgnored() {
for (let i = 0; i < 20; i++) {
const salt = crypto.randomBytes(128).toString('base64');
Expand Down
91 changes: 90 additions & 1 deletion dev-packages/node-integration-tests/suites/anr/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ const EXPECTED_ANR_EVENT = {
timezone: expect.any(String),
},
},
user: {
email: 'person@home.com',
},
breadcrumbs: [
{
timestamp: expect.any(Number),
message: 'important message!',
},
],
// and an exception that is our ANR
exception: {
values: [
Expand Down Expand Up @@ -56,9 +65,59 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () =>
cleanupChildProcesses();
});

const EXPECTED_LEGACY_ANR_EVENT = {
// Ensure we have context
contexts: {
trace: {
span_id: expect.any(String),
trace_id: expect.any(String),
},
device: {
arch: expect.any(String),
},
app: {
app_start_time: expect.any(String),
},
os: {
name: expect.any(String),
},
culture: {
timezone: expect.any(String),
},
},
// and an exception that is our ANR
exception: {
values: [
{
type: 'ApplicationNotResponding',
value: 'Application Not Responding for at least 100 ms',
mechanism: { type: 'ANR' },
stacktrace: {
frames: expect.arrayContaining([
{
colno: expect.any(Number),
lineno: expect.any(Number),
filename: expect.any(String),
function: '?',
in_app: true,
},
{
colno: expect.any(Number),
lineno: expect.any(Number),
filename: expect.any(String),
function: 'longWork',
in_app: true,
},
]),
},
},
],
},
};

// TODO (v8): Remove this old API and this test
test('Legacy API', done => {
createRunner(__dirname, 'legacy.js').expect({ event: EXPECTED_ANR_EVENT }).start(done);
createRunner(__dirname, 'legacy.js').expect({ event: EXPECTED_LEGACY_ANR_EVENT }).start(done);
});

test('CJS', done => {
Expand Down Expand Up @@ -110,4 +169,34 @@ conditionalTest({ min: 16 })('should report ANR when event loop blocked', () =>
test('worker can be stopped and restarted', done => {
createRunner(__dirname, 'stop-and-start.js').expect({ event: EXPECTED_ANR_EVENT }).start(done);
});

const EXPECTED_ISOLATED_EVENT = {
user: {
id: 5,
},
exception: {
values: [
{
type: 'ApplicationNotResponding',
value: 'Application Not Responding for at least 100 ms',
mechanism: { type: 'ANR' },
stacktrace: {
frames: expect.arrayContaining([
{
colno: expect.any(Number),
lineno: expect.any(Number),
filename: expect.stringMatching(/isolated.mjs$/),
function: 'longWork',
in_app: true,
},
]),
},
},
],
},
};

test('fetches correct isolated scope', done => {
createRunner(__dirname, 'isolated.mjs').expect({ event: EXPECTED_ISOLATED_EVENT }).start(done);
});
});
43 changes: 37 additions & 6 deletions packages/node/src/integrations/anr/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// TODO (v8): This import can be removed once we only support Node with global URL
import { URL } from 'url';
import { convertIntegrationFnToClass, defineIntegration, getCurrentScope } from '@sentry/core';
import {
convertIntegrationFnToClass,
defineIntegration,
getCurrentScope,
getGlobalScope,
getIsolationScope,
mergeScopeData,
} from '@sentry/core';
import type {
Client,
Contexts,
Expand All @@ -10,8 +17,9 @@ import type {
IntegrationClass,
IntegrationFn,
IntegrationFnResult,
ScopeData,
} from '@sentry/types';
import { dynamicRequire, logger } from '@sentry/utils';
import { GLOBAL_OBJ, dynamicRequire, logger } from '@sentry/utils';
import type { Worker, WorkerOptions } from 'worker_threads';
import type { NodeClient } from '../../client';
import { NODE_VERSION } from '../../nodeVersion';
Expand All @@ -31,6 +39,24 @@ function log(message: string, ...args: unknown[]): void {
logger.log(`[ANR] ${message}`, ...args);
}

function globalWithScopeFetchFn(): typeof GLOBAL_OBJ & { __SENTRY_GET_SCOPES__?: () => ScopeData } {
return GLOBAL_OBJ;
}

/** Fetches merged scope data */
function getScopeData(): ScopeData {
const scope = getGlobalScope().getScopeData();
mergeScopeData(scope, getIsolationScope().getScopeData());
mergeScopeData(scope, getCurrentScope().getScopeData());

// We remove attachments because they likely won't serialize well as json
scope.attachments = [];
// We can't serialize event processor functions
scope.eventProcessors = [];

return scope;
}

/**
* We need to use dynamicRequire because worker_threads is not available in node < v12 and webpack error will when
* targeting those versions
Expand Down Expand Up @@ -64,9 +90,18 @@ const INTEGRATION_NAME = 'Anr';
type AnrInternal = { startWorker: () => void; stopWorker: () => void };

const _anrIntegration = ((options: Partial<AnrIntegrationOptions> = {}) => {
if (NODE_VERSION.major < 16 || (NODE_VERSION.major === 16 && NODE_VERSION.minor < 17)) {
throw new Error('ANR detection requires Node 16.17.0 or later');
}

let worker: Promise<() => void> | undefined;
let client: NodeClient | undefined;

// Hookup the scope fetch function to the global object so that it can be called from the worker thread via the
// debugger when it pauses
const gbl = globalWithScopeFetchFn();
gbl.__SENTRY_GET_SCOPES__ = getScopeData;

return {
name: INTEGRATION_NAME,
// TODO v8: Remove this
Expand All @@ -90,10 +125,6 @@ const _anrIntegration = ((options: Partial<AnrIntegrationOptions> = {}) => {
}
},
setup(initClient: NodeClient) {
if (NODE_VERSION.major < 16 || (NODE_VERSION.major === 16 && NODE_VERSION.minor < 17)) {
throw new Error('ANR detection requires Node 16.17.0 or later');
}

client = initClient;

// setImmediate is used to ensure that all other integrations have had their setup called first.
Expand Down
46 changes: 35 additions & 11 deletions packages/node/src/integrations/anr/worker.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import {
applyScopeDataToEvent,
createEventEnvelope,
createSessionEnvelope,
getEnvelopeEndpointWithUrlEncodedAuth,
makeSession,
updateSession,
} from '@sentry/core';
import type { Event, Session, StackFrame, TraceContext } from '@sentry/types';
import type { Event, ScopeData, Session, StackFrame } from '@sentry/types';
import {
callFrameToStackFrame,
normalizeUrlToBase,
Expand Down Expand Up @@ -87,7 +88,23 @@ function prepareStackFrames(stackFrames: StackFrame[] | undefined): StackFrame[]
return strippedFrames;
}

async function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext): Promise<void> {
function applyScopeToEvent(event: Event, scope: ScopeData): void {
applyScopeDataToEvent(event, scope);

if (!event.contexts?.trace) {
const { traceId, spanId, parentSpanId } = scope.propagationContext;
event.contexts = {
trace: {
trace_id: traceId,
span_id: spanId,
parent_span_id: parentSpanId,
},
...event.contexts,
};
}
}

async function sendAnrEvent(frames?: StackFrame[], scope?: ScopeData): Promise<void> {
if (hasSentAnrEvent) {
return;
}
Expand All @@ -100,7 +117,7 @@ async function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext):

const event: Event = {
event_id: uuid4(),
contexts: { ...options.contexts, trace: traceContext },
contexts: options.contexts,
release: options.release,
environment: options.environment,
dist: options.dist,
Expand All @@ -120,8 +137,12 @@ async function sendAnrEvent(frames?: StackFrame[], traceContext?: TraceContext):
tags: options.staticTags,
};

if (scope) {
applyScopeToEvent(event, scope);
}

const envelope = createEventEnvelope(event, options.dsn, options.sdkMetadata);
// Log the envelope so to aid in testing
// Log the envelope to aid in testing
log(JSON.stringify(envelope));

await transport.send(envelope);
Expand Down Expand Up @@ -172,20 +193,23 @@ if (options.captureStackTrace) {
'Runtime.evaluate',
{
// Grab the trace context from the current scope
expression:
'var __sentry_ctx = __SENTRY__.hub.getScope().getPropagationContext(); __sentry_ctx.traceId + "-" + __sentry_ctx.spanId + "-" + __sentry_ctx.parentSpanId',
expression: 'global.__SENTRY_GET_SCOPES__();',
// Don't re-trigger the debugger if this causes an error
silent: true,
// Serialize the result to json otherwise only primitives are supported
returnByValue: true,
},
(_, param) => {
const traceId = param && param.result ? (param.result.value as string) : '--';
const [trace_id, span_id, parent_span_id] = traceId.split('-') as (string | undefined)[];
(err, param) => {
if (err) {
log(`Error executing script: '${err.message}'`);
}

const scopes = param && param.result ? (param.result.value as ScopeData) : undefined;

session.post('Debugger.resume');
session.post('Debugger.disable');

const context = trace_id?.length && span_id?.length ? { trace_id, span_id, parent_span_id } : undefined;
sendAnrEvent(stackFrames, context).then(null, () => {
sendAnrEvent(stackFrames, scopes).then(null, () => {
log('Sending ANR event failed.');
});
},
Expand Down