Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as Sentry from '@sentry/node';
import { longWork } from './long-work.js';

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

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

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

setTimeout(() => {
for (let id = 0; id < 10; id++) {
Sentry.withIsolationScope(async () => {
// eslint-disable-next-line no-console
console.log(`Starting task ${id}`);
Sentry.setUser({ id });

await fns[id]();
});
}
}, 1000);
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from 'node:path';
import type { Event } from '@sentry/core';
import { afterAll, describe, expect, test } from 'vitest';
import { NODE_VERSION } from '../../utils/index';
import { cleanupChildProcesses, createRunner } from '../../utils/runner';

function EXCEPTION(thread_id = '0', fn = 'longWork') {
Expand Down Expand Up @@ -34,9 +35,17 @@ function EXCEPTION(thread_id = '0', fn = 'longWork') {
};
}

const ANR_EVENT = {
const ANR_EVENT = (trace: boolean = false) => ({
// Ensure we have context
contexts: {
...(trace
? {
trace: {
span_id: expect.stringMatching(/[a-f0-9]{16}/),
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
},
}
: {}),
device: {
arch: expect.any(String),
},
Expand All @@ -63,11 +72,11 @@ const ANR_EVENT = {
},
// and an exception that is our ANR
exception: EXCEPTION(),
};
});

function ANR_EVENT_WITH_DEBUG_META(file: string): Event {
return {
...ANR_EVENT,
...ANR_EVENT(),
debug_meta: {
images: [
{
Expand Down Expand Up @@ -103,7 +112,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {

test('Custom appRootPath', async () => {
const ANR_EVENT_WITH_SPECIFIC_DEBUG_META: Event = {
...ANR_EVENT,
...ANR_EVENT(),
debug_meta: {
images: [
{
Expand Down Expand Up @@ -134,7 +143,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
test('blocked indefinitely', async () => {
await createRunner(__dirname, 'indefinite.mjs')
.withMockSentryServer()
.expect({ event: ANR_EVENT })
.expect({ event: ANR_EVENT() })
.start()
.completed();
});
Expand All @@ -160,7 +169,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
.withMockSentryServer()
.expect({
event: {
...ANR_EVENT,
...ANR_EVENT(),
exception: EXCEPTION('0', 'longWorkOther'),
},
})
Expand All @@ -179,7 +188,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
expect(crashedThread).toBeDefined();

expect(event).toMatchObject({
...ANR_EVENT,
...ANR_EVENT(),
exception: {
...EXCEPTION(crashedThread),
},
Expand Down Expand Up @@ -210,4 +219,52 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
.start()
.completed();
});

test('Capture scope via AsyncLocalStorage', async ctx => {
if (NODE_VERSION < 24) {
ctx.skip();
return;
}

const instrument = join(__dirname, 'instrument.mjs');
await createRunner(__dirname, 'isolated.mjs')
.withMockSentryServer()
.withInstrument(instrument)
.expect({
event: event => {
const crashedThread = event.threads?.values?.find(thread => thread.crashed)?.id as string;
expect(crashedThread).toBeDefined();

expect(event).toMatchObject({
...ANR_EVENT(true),
exception: {
...EXCEPTION(crashedThread),
},
breadcrumbs: [
{
timestamp: expect.any(Number),
category: 'console',
data: { arguments: ['Starting task 5'], logger: 'console' },
level: 'log',
message: 'Starting task 5',
},
],
user: { id: 5 },
threads: {
values: [
{
id: '0',
name: 'main',
crashed: true,
current: true,
main: true,
},
],
},
});
},
})
.start()
.completed();
});
});
2 changes: 1 addition & 1 deletion dev-packages/node-integration-tests/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parseSemver } from '@sentry/core';
import type * as http from 'http';
import { describe } from 'vitest';

const NODE_VERSION = parseSemver(process.versions.node).major;
export const NODE_VERSION = parseSemver(process.versions.node).major || 0;

export type TestServerConfig = {
url: string;
Expand Down
4 changes: 3 additions & 1 deletion packages/node-core/src/sdk/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { registerInstrumentations } from '@opentelemetry/instrumentation';
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core';
import { _INTERNAL_flushLogsBuffer, applySdkMetadata, debug, SDK_VERSION, ServerRuntimeClient } from '@sentry/core';
import { getTraceContextForScope } from '@sentry/opentelemetry';
import { type AsyncLocalStorageLookup, getTraceContextForScope } from '@sentry/opentelemetry';
import { isMainThread, threadId } from 'worker_threads';
import { DEBUG_BUILD } from '../debug-build';
import type { NodeClientOptions } from '../types';
Expand All @@ -15,6 +15,8 @@ const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitr
/** A client for using Sentry with Node & OpenTelemetry. */
export class NodeClient extends ServerRuntimeClient<NodeClientOptions> {
public traceProvider: BasicTracerProvider | undefined;
public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined;

private _tracer: Tracer | undefined;
private _clientReportInterval: NodeJS.Timeout | undefined;
private _clientReportOnExitFlushListener: (() => void) | undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/node-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"build:tarball": "npm pack"
},
"dependencies": {
"@sentry-internal/node-native-stacktrace": "^0.2.2",
"@sentry-internal/node-native-stacktrace": "^0.3.0",
"@sentry/core": "10.22.0",
"@sentry/node": "10.22.0"
},
Expand Down
17 changes: 12 additions & 5 deletions packages/node-native/src/event-loop-block-integration.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { isPromise } from 'node:util/types';
import { isMainThread, Worker } from 'node:worker_threads';
import type {
Client,
ClientOptions,
Contexts,
DsnComponents,
Expand Down Expand Up @@ -47,7 +46,7 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void {
// serialized without making it a SerializedSession
const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined;
// message the worker to tell it the main event loop is still running
threadPoll({ session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }, !enabled);
threadPoll(enabled, { session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) });
} catch {
// we ignore all errors
}
Expand All @@ -57,10 +56,15 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void {
* Starts polling
*/
function startPolling(
client: Client,
client: NodeClient,
integrationOptions: Partial<ThreadBlockedIntegrationOptions>,
): IntegrationInternal | undefined {
registerThread();
if (client.asyncLocalStorageLookup) {
const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup;
registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', contextSymbol] });
} else {
registerThread();
}

let enabled = true;

Expand Down Expand Up @@ -161,7 +165,10 @@ const _eventLoopBlockIntegration = ((options: Partial<ThreadBlockedIntegrationOp
}

try {
polling = await startPolling(client, options);
// Otel is not setup until after afterAllSetup returns.
setImmediate(() => {
polling = startPolling(client, options);
});

if (isMainThread) {
await startWorker(dsn, client, options);
Expand Down
44 changes: 39 additions & 5 deletions packages/node-native/src/event-loop-block-watchdog.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { workerData } from 'node:worker_threads';
import type { DebugImage, Event, Session, StackFrame, Thread } from '@sentry/core';
import type { DebugImage, Event, ScopeData, Session, StackFrame, Thread } from '@sentry/core';
import {
applyScopeDataToEvent,
createEventEnvelope,
createSessionEnvelope,
filenameIsInApp,
generateSpanId,
getEnvelopeEndpointWithUrlEncodedAuth,
makeSession,
mergeScopeData,
normalizeUrlToBase,
Scope,
stripSentryFramesAndReverse,
updateSession,
uuid4,
Expand All @@ -16,6 +20,11 @@ import { captureStackTrace, getThreadsLastSeen } from '@sentry-internal/node-nat
import type { ThreadState, WorkerStartData } from './common';
import { POLL_RATIO } from './common';

type CurrentScopes = {
scope: Scope;
isolationScope: Scope;
};

const {
threshold,
appRootPath,
Expand Down Expand Up @@ -178,7 +187,7 @@ function applyDebugMeta(event: Event, debugImages: Record<string, string>): void

function getExceptionAndThreads(
crashedThreadId: string,
threads: ReturnType<typeof captureStackTrace<ThreadState>>,
threads: ReturnType<typeof captureStackTrace<CurrentScopes, ThreadState>>,
): Event {
const crashedThread = threads[crashedThreadId];

Expand Down Expand Up @@ -217,12 +226,28 @@ function getExceptionAndThreads(
};
}

function applyScopeToEvent(event: Event, scope: ScopeData): void {
applyScopeDataToEvent(event, scope);

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

async function sendBlockEvent(crashedThreadId: string): Promise<void> {
if (isRateLimited()) {
return;
}

const threads = captureStackTrace<ThreadState>();
const threads = captureStackTrace<CurrentScopes, ThreadState>();
const crashedThread = threads[crashedThreadId];

if (!crashedThread) {
Expand All @@ -231,7 +256,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise<void> {
}

try {
await sendAbnormalSession(crashedThread.state?.session);
await sendAbnormalSession(crashedThread.pollState?.session);
} catch (error) {
log(`Failed to send abnormal session for thread '${crashedThreadId}':`, error);
}
Expand All @@ -250,8 +275,17 @@ async function sendBlockEvent(crashedThreadId: string): Promise<void> {
...getExceptionAndThreads(crashedThreadId, threads),
};

const asyncState = threads[crashedThreadId]?.asyncState;
if (asyncState) {
// We need to rehydrate the scopes from the serialized objects so we can call getScopeData()
const scope = Object.assign(new Scope(), asyncState.scope).getScopeData();
const isolationScope = Object.assign(new Scope(), asyncState.isolationScope).getScopeData();
mergeScopeData(scope, isolationScope);
applyScopeToEvent(event, scope);
}

const allDebugImages: Record<string, string> = Object.values(threads).reduce((acc, threadState) => {
return { ...acc, ...threadState.state?.debugImages };
return { ...acc, ...threadState.pollState?.debugImages };
}, {});

applyDebugMeta(event, allDebugImages);
Expand Down
21 changes: 16 additions & 5 deletions packages/node/src/sdk/initOtel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
SentryContextManager,
setupOpenTelemetryLogger,
} from '@sentry/node-core';
import { SentryPropagator, SentrySampler, SentrySpanProcessor } from '@sentry/opentelemetry';
import {
type AsyncLocalStorageLookup,
SentryPropagator,
SentrySampler,
SentrySpanProcessor,
} from '@sentry/opentelemetry';
import { DEBUG_BUILD } from '../debug-build';
import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracing';

Expand All @@ -34,8 +39,9 @@ export function initOpenTelemetry(client: NodeClient, options: AdditionalOpenTel
setupOpenTelemetryLogger();
}

const provider = setupOtel(client, options);
const [provider, asyncLocalStorageLookup] = setupOtel(client, options);
client.traceProvider = provider;
client.asyncLocalStorageLookup = asyncLocalStorageLookup;
}

interface NodePreloadOptions {
Expand Down Expand Up @@ -82,7 +88,10 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s
}

/** Just exported for tests. */
export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOptions = {}): BasicTracerProvider {
export function setupOtel(
client: NodeClient,
options: AdditionalOpenTelemetryOptions = {},
): [BasicTracerProvider, AsyncLocalStorageLookup] {
// Create and configure NodeTracerProvider
const provider = new BasicTracerProvider({
sampler: new SentrySampler(client),
Expand All @@ -106,9 +115,11 @@ export function setupOtel(client: NodeClient, options: AdditionalOpenTelemetryOp
// Register as globals
trace.setGlobalTracerProvider(provider);
propagation.setGlobalPropagator(new SentryPropagator());
context.setGlobalContextManager(new SentryContextManager());

return provider;
const ctxManager = new SentryContextManager();
context.setGlobalContextManager(ctxManager);

return [provider, ctxManager.getAsyncLocalStorageLookup()];
}

/** Just exported for tests. */
Expand Down
Loading