Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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