diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs index 273760a6db39..49f30db2e59e 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs @@ -14,6 +14,14 @@ Sentry.init({ integrations: [eventLoopBlockIntegration()], }); +// Sentry.addBreadcrumb() writes to the isolation scope which is only captured via +// AsyncLocalStorage, so we add to the current scope to test the poll state route +Sentry.getCurrentScope().addBreadcrumb({ + category: 'test', + message: 'blocking event loop soon', + level: 'info', +}); + setTimeout(() => { longWork(); }, 2000); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index c6729e55c209..a07cc781b3d2 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -105,7 +105,20 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { test('ESM', async () => { await createRunner(__dirname, 'basic.mjs') .withMockSentryServer() - .expect({ event: ANR_EVENT_WITH_DEBUG_META('basic') }) + .expect({ + event: { + ...ANR_EVENT_WITH_DEBUG_META('basic'), + // Ensures breadcrumbs make it through the poll state rather than via AsyncLocalStorage + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'test', + message: 'blocking event loop soon', + level: 'info', + }, + ], + }, + }) .start() .completed(); }); diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index e48c73835f6e..853bcbf35a6e 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -225,6 +225,21 @@ function getExceptionAndThreads( }; } +/** + * Rehydrates a Scope from serialized ScopeData that has been passed over a JSON serialization boundary. + * + * `Scope.update()` only handles `ScopeContext` fields, so breadcrumbs have to be carried over separately. + * Attachments are deliberately not carried over since their binary data does not survive JSON serialization. + */ +function hydrateScope(data: Partial | undefined): Scope { + const scope = new Scope(); + if (data) { + scope.update(data); + data.breadcrumbs?.forEach(breadcrumb => scope.addBreadcrumb(breadcrumb)); + } + return scope; +} + function applyScopeToEvent(event: Event, scope: ScopeData): void { applyScopeDataToEvent(event, scope); @@ -274,9 +289,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise { ...getExceptionAndThreads(crashedThreadId, threads), }; - const scope = crashedThread.pollState?.scope - ? new Scope().update(crashedThread.pollState.scope).getScopeData() - : new Scope().getScopeData(); + const scope = hydrateScope(crashedThread.pollState?.scope).getScopeData(); if (crashedThread?.asyncState?.isolationScope) { // We need to rehydrate the scope from the serialized object with properties beginning with _user, etc