): void {
/**
* Processes resolved routes by adding them to allRoutes and checking for nested async handlers.
+ * When capturedSpan is provided, updates that specific span instead of the current active span.
+ * This prevents race conditions where a lazy handler resolves after the user has navigated away.
*/
export function processResolvedRoutes(
resolvedRoutes: RouteObject[],
parentRoute?: RouteObject,
currentLocation: Location | null = null,
+ capturedSpan?: Span,
): void {
resolvedRoutes.forEach(child => {
allRoutes.add(child);
@@ -249,17 +257,27 @@ export function processResolvedRoutes(
addResolvedRoutesToParent(resolvedRoutes, parentRoute);
}
- // After processing lazy routes, check if we need to update an active transaction
- const activeRootSpan = getActiveRootSpan();
- if (activeRootSpan) {
- const spanOp = spanToJSON(activeRootSpan).op;
+ // Use captured span if provided, otherwise fall back to current active span
+ const targetSpan = capturedSpan ?? getActiveRootSpan();
+ if (targetSpan) {
+ const spanJson = spanToJSON(targetSpan);
- // Try to use the provided location first, then fall back to global window location if needed
+ // Skip update if span has already ended (timestamp is set when span.end() is called)
+ if (spanJson.timestamp) {
+ DEBUG_BUILD && debug.warn('[React Router] Lazy handler resolved after span ended - skipping update');
+ return;
+ }
+
+ const spanOp = spanJson.op;
+
+ // Use captured location for route matching (ensures we match against the correct route)
+ // Fall back to window.location only if no captured location and no captured span
+ // (i.e., this is not from an async handler)
let location = currentLocation;
- if (!location) {
+ if (!location && !capturedSpan) {
if (typeof WINDOW !== 'undefined') {
const globalLocation = WINDOW.location;
- if (globalLocation) {
+ if (globalLocation?.pathname) {
location = { pathname: globalLocation.pathname };
}
}
@@ -269,14 +287,14 @@ export function processResolvedRoutes(
if (spanOp === 'pageload') {
// Re-run the pageload transaction update with the newly loaded routes
updatePageloadTransaction({
- activeRootSpan,
+ activeRootSpan: targetSpan,
location: { pathname: location.pathname },
routes: Array.from(allRoutes),
allRoutes: Array.from(allRoutes),
});
} else if (spanOp === 'navigation') {
// For navigation spans, update the name with the newly loaded routes
- updateNavigationSpan(activeRootSpan, location, Array.from(allRoutes), false, _matchRoutes);
+ updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, _matchRoutes);
}
}
}
@@ -713,7 +731,12 @@ function wrapPatchRoutesOnNavigation(
(args as any).patch = (routeId: string, children: RouteObject[]) => {
addRoutesToAllRoutes(children);
const currentActiveRootSpan = getActiveRootSpan();
- if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
+ // Only update if we have a valid targetPath (patchRoutesOnNavigation can be called without path)
+ if (
+ targetPath &&
+ currentActiveRootSpan &&
+ (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation'
+ ) {
updateNavigationSpan(
currentActiveRootSpan,
{ pathname: targetPath, search: '', hash: '', state: null, key: 'default' },
@@ -728,7 +751,14 @@ function wrapPatchRoutesOnNavigation(
}
const lazyLoadPromise = (async () => {
- const result = await originalPatchRoutes(args);
+ // Set context so async handlers can access correct targetPath and span
+ const contextToken = setNavigationContext(targetPath, activeRootSpan);
+ let result;
+ try {
+ result = await originalPatchRoutes(args);
+ } finally {
+ clearNavigationContext(contextToken);
+ }
const currentActiveRootSpan = getActiveRootSpan();
if (currentActiveRootSpan && (spanToJSON(currentActiveRootSpan) as { op?: string }).op === 'navigation') {
@@ -1184,17 +1214,3 @@ export function createV6CompatibleWithSentryReactRouterRouting unknown,
route: RouteObject,
handlerKey: string,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
): (...args: unknown[]) => unknown {
const proxy = new Proxy(originalFunction, {
apply(target: (...args: unknown[]) => unknown, thisArg, argArray) {
+ const locationAtInvocation = captureCurrentLocation();
+ const spanAtInvocation = captureActiveSpan();
const result = target.apply(thisArg, argArray);
- handleAsyncHandlerResult(result, route, handlerKey, processResolvedRoutes);
+ handleAsyncHandlerResult(
+ result,
+ route,
+ handlerKey,
+ processResolvedRoutes,
+ locationAtInvocation,
+ spanAtInvocation,
+ );
return result;
},
});
@@ -26,25 +94,33 @@ export function createAsyncHandlerProxy(
/**
* Handles the result of an async handler function call.
+ * Passes the captured span through to ensure the correct span is updated.
*/
export function handleAsyncHandlerResult(
result: unknown,
route: RouteObject,
handlerKey: string,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
+ currentLocation: Location | null,
+ capturedSpan: Span | undefined,
): void {
if (isThenable(result)) {
(result as Promise)
.then((resolvedRoutes: unknown) => {
if (Array.isArray(resolvedRoutes)) {
- processResolvedRoutes(resolvedRoutes, route);
+ processResolvedRoutes(resolvedRoutes, route, currentLocation ?? undefined, capturedSpan);
}
})
.catch((e: unknown) => {
DEBUG_BUILD && debug.warn(`Error resolving async handler '${handlerKey}' for route`, route, e);
});
} else if (Array.isArray(result)) {
- processResolvedRoutes(result, route);
+ processResolvedRoutes(result, route, currentLocation ?? undefined, capturedSpan);
}
}
@@ -53,7 +129,12 @@ export function handleAsyncHandlerResult(
*/
export function checkRouteForAsyncHandler(
route: RouteObject,
- processResolvedRoutes: (resolvedRoutes: RouteObject[], parentRoute?: RouteObject, currentLocation?: Location) => void,
+ processResolvedRoutes: (
+ resolvedRoutes: RouteObject[],
+ parentRoute?: RouteObject,
+ currentLocation?: Location,
+ capturedSpan?: Span,
+ ) => void,
): void {
// Set up proxies for any functions in the route's handle
if (route.handle && typeof route.handle === 'object') {
diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts
index 8431e283108b..96c178b64c14 100644
--- a/packages/react/src/reactrouter-compat-utils/utils.ts
+++ b/packages/react/src/reactrouter-compat-utils/utils.ts
@@ -1,10 +1,57 @@
-import type { TransactionSource } from '@sentry/core';
+import type { Span, TransactionSource } from '@sentry/core';
+import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core';
+import { DEBUG_BUILD } from '../debug-build';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types';
// Global variables that these utilities depend on
let _matchRoutes: MatchRoutes;
let _stripBasename: boolean = false;
+// Navigation context stack for nested/concurrent patchRoutesOnNavigation calls.
+// Required because window.location hasn't updated yet when handlers are invoked.
+interface NavigationContext {
+ token: object;
+ targetPath: string | undefined;
+ span: Span | undefined;
+}
+
+const _navigationContextStack: NavigationContext[] = [];
+const MAX_CONTEXT_STACK_SIZE = 10;
+
+/**
+ * Pushes a navigation context and returns a unique token for cleanup.
+ * The token uses object identity for uniqueness (no counter needed).
+ */
+export function setNavigationContext(targetPath: string | undefined, span: Span | undefined): object {
+ const token = {};
+ // Prevent unbounded stack growth - oldest (likely stale) contexts are evicted first
+ if (_navigationContextStack.length >= MAX_CONTEXT_STACK_SIZE) {
+ DEBUG_BUILD && debug.warn('[React Router] Navigation context stack overflow - removing oldest context');
+ _navigationContextStack.shift();
+ }
+ _navigationContextStack.push({ token, targetPath, span });
+ return token;
+}
+
+/**
+ * Clears the navigation context if it's on top of the stack (LIFO).
+ * If our context is not on top (out-of-order completion), we leave it -
+ * it will be cleaned up by overflow protection when the stack fills up.
+ */
+export function clearNavigationContext(token: object): void {
+ const top = _navigationContextStack[_navigationContextStack.length - 1];
+ if (top?.token === token) {
+ _navigationContextStack.pop();
+ }
+}
+
+/** Gets the current (most recent) navigation context if inside a patchRoutesOnNavigation call. */
+export function getNavigationContext(): NavigationContext | null {
+ const length = _navigationContextStack.length;
+ // The `?? null` converts undefined (from array access) to null to match return type
+ return length > 0 ? (_navigationContextStack[length - 1] ?? null) : null;
+}
+
/**
* Initialize function to set dependencies that the router utilities need.
* Must be called before using any of the exported utility functions.
@@ -273,3 +320,20 @@ export function resolveRouteNameAndSource(
return [name || location.pathname, source];
}
+
+/**
+ * Gets the active root span if it's a pageload or navigation span.
+ */
+export function getActiveRootSpan(): Span | undefined {
+ const span = getActiveSpan();
+ const rootSpan = span ? getRootSpan(span) : undefined;
+
+ if (!rootSpan) {
+ return undefined;
+ }
+
+ const op = spanToJSON(rootSpan).op;
+
+ // Only use this root span if it is a pageload or navigation span
+ return op === 'navigation' || op === 'pageload' ? rootSpan : undefined;
+}
diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
index 276a5b9950fc..3d2b4f198cf5 100644
--- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
+++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx
@@ -59,6 +59,7 @@ vi.mock('../../src/reactrouter-compat-utils/utils', () => ({
transactionNameHasWildcard: vi.fn((name: string) => {
return name.includes('/*') || name === '*' || name.endsWith('*');
}),
+ getActiveRootSpan: vi.fn(() => undefined),
}));
vi.mock('../../src/reactrouter-compat-utils/lazy-routes', () => ({
diff --git a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
index 732b893ea8f8..0d1a493e08f2 100644
--- a/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
+++ b/packages/react/test/reactrouter-compat-utils/lazy-routes.test.ts
@@ -106,7 +106,9 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
proxy();
// Since handleAsyncHandlerResult is called internally, we verify through its side effects
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route);
+ // The third parameter is the captured location (undefined in jsdom test environment)
+ // The fourth parameter is the captured span (undefined since no active span in test)
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(['route1', 'route2'], route, undefined, undefined);
});
it('should handle functions that throw exceptions', () => {
@@ -137,35 +139,38 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
const proxy = createAsyncHandlerProxy(originalFunction, route, handlerKey, mockProcessResolvedRoutes);
proxy();
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route);
+ // The third parameter is the captured location (undefined in jsdom test environment)
+ // The fourth parameter is the captured span (undefined since no active span in test)
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith([], route, undefined, undefined);
});
});
describe('handleAsyncHandlerResult', () => {
const route: RouteObject = { path: '/test' };
const handlerKey = 'testHandler';
+ const mockLocation = { pathname: '/test', search: '', hash: '', state: null, key: 'default' };
it('should handle array results directly', () => {
const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle empty array results', () => {
const routes: RouteObject[] = [];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to arrays', async () => {
const routes: RouteObject[] = [{ path: '/route1' }, { path: '/route2' }];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
// Wait for the promise to resolve
await promiseResult;
@@ -173,25 +178,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
// Use setTimeout to wait for the async handling
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to empty arrays', async () => {
const routes: RouteObject[] = [];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
});
it('should handle Promise results that resolve to non-arrays', async () => {
const promiseResult = Promise.resolve('not an array');
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -202,7 +207,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise results that resolve to null', async () => {
const promiseResult = Promise.resolve(null);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -213,7 +218,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise results that resolve to undefined', async () => {
const promiseResult = Promise.resolve(undefined);
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
@@ -224,7 +229,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise rejections gracefully', async () => {
const promiseResult = Promise.reject(new Error('Test error'));
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
// Wait for the promise to be handled
await new Promise(resolve => setTimeout(resolve, 0));
@@ -240,7 +245,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
it('should handle Promise rejections with non-Error values', async () => {
const promiseResult = Promise.reject('string error');
- handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(promiseResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
await new Promise(resolve => setTimeout(resolve, 0));
@@ -253,25 +258,25 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
});
it('should ignore non-promise, non-array results', () => {
- handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult('string result', route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(123, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult({ not: 'array' }, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(null, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(undefined, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
it('should ignore boolean values', () => {
- handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes);
- handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(true, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+ handleAsyncHandlerResult(false, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
it('should ignore functions as results', () => {
const functionResult = () => 'test';
- handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(functionResult, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -281,7 +286,14 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
then: 'not a function',
};
- handleAsyncHandlerResult(fakeThenableButNotPromise, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(
+ fakeThenableButNotPromise,
+ route,
+ handlerKey,
+ mockProcessResolvedRoutes,
+ mockLocation,
+ undefined,
+ );
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -291,7 +303,7 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
then: null,
};
- handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(almostPromise, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
expect(mockProcessResolvedRoutes).not.toHaveBeenCalled();
});
@@ -306,12 +318,19 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
const routes: RouteObject[] = [{ path: '/dynamic1' }, { path: '/dynamic2' }];
const promiseResult = Promise.resolve(routes);
- handleAsyncHandlerResult(promiseResult, complexRoute, 'complexHandler', mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(
+ promiseResult,
+ complexRoute,
+ 'complexHandler',
+ mockProcessResolvedRoutes,
+ mockLocation,
+ undefined,
+ );
await promiseResult;
await new Promise(resolve => setTimeout(resolve, 0));
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute);
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, complexRoute, mockLocation, undefined);
});
it('should handle nested route objects in arrays', () => {
@@ -322,9 +341,18 @@ describe('reactrouter-compat-utils/lazy-routes', () => {
},
];
- handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes);
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, mockLocation, undefined);
+
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, mockLocation, undefined);
+ });
+
+ it('should convert null location to undefined for processResolvedRoutes', () => {
+ const routes: RouteObject[] = [{ path: '/route1' }];
+
+ handleAsyncHandlerResult(routes, route, handlerKey, mockProcessResolvedRoutes, null, undefined);
- expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route);
+ // When null is passed, it should convert to undefined for processResolvedRoutes
+ expect(mockProcessResolvedRoutes).toHaveBeenCalledWith(routes, route, undefined, undefined);
});
});
diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts
index 438b026104bd..401ea648b0fc 100644
--- a/packages/react/test/reactrouter-compat-utils/utils.test.ts
+++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts
@@ -1,5 +1,7 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
+ clearNavigationContext,
+ getNavigationContext,
getNormalizedName,
getNumberOfUrlSegments,
initializeRouterUtils,
@@ -9,6 +11,7 @@ import {
prefixWithSlash,
rebuildRoutePathFromAllRoutes,
resolveRouteNameAndSource,
+ setNavigationContext,
transactionNameHasWildcard,
} from '../../src/reactrouter-compat-utils';
import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types';
@@ -664,4 +667,137 @@ describe('reactrouter-compat-utils/utils', () => {
expect(transactionNameHasWildcard('/path/to/asterisk')).toBe(false); // 'asterisk' contains 'isk' but not '*'
});
});
+
+ describe('navigation context management', () => {
+ // Clean up navigation context after each test by popping until empty
+ afterEach(() => {
+ // Pop all remaining contexts
+ while (getNavigationContext() !== null) {
+ const ctx = getNavigationContext();
+ if (ctx) {
+ clearNavigationContext((ctx as any).token);
+ }
+ }
+ });
+
+ describe('setNavigationContext', () => {
+ it('should return unique tokens (object identity)', () => {
+ const token1 = setNavigationContext('/path1', undefined);
+ const token2 = setNavigationContext('/path2', undefined);
+ const token3 = setNavigationContext('/path3', undefined);
+
+ // Each token should be a unique object
+ expect(token1).not.toBe(token2);
+ expect(token2).not.toBe(token3);
+ expect(token1).not.toBe(token3);
+ });
+
+ it('should store targetPath and span in context', () => {
+ const mockSpan = { name: 'test-span' } as any;
+ setNavigationContext('/test-path', mockSpan);
+
+ const context = getNavigationContext();
+ expect(context).not.toBeNull();
+ expect(context?.targetPath).toBe('/test-path');
+ expect(context?.span).toBe(mockSpan);
+ });
+
+ it('should handle undefined targetPath', () => {
+ setNavigationContext(undefined, undefined);
+
+ const context = getNavigationContext();
+ expect(context).not.toBeNull();
+ expect(context?.targetPath).toBeUndefined();
+ });
+ });
+
+ describe('clearNavigationContext', () => {
+ it('should remove context when token matches top of stack (LIFO)', () => {
+ const token = setNavigationContext('/test', undefined);
+
+ expect(getNavigationContext()).not.toBeNull();
+
+ clearNavigationContext(token);
+
+ expect(getNavigationContext()).toBeNull();
+ });
+
+ it('should NOT remove context when token is not on top (out-of-order completion)', () => {
+ // Simulate: Nav1 starts, Nav2 starts, Nav1 tries to complete first
+ const token1 = setNavigationContext('/nav1', undefined);
+ const token2 = setNavigationContext('/nav2', undefined);
+
+ // Most recent should be nav2
+ expect(getNavigationContext()?.targetPath).toBe('/nav2');
+
+ // Nav1 tries to complete first (out of order) - should NOT pop because nav1 is not on top
+ clearNavigationContext(token1);
+
+ // Nav2 should still be the current context (nav1's context is still buried)
+ expect(getNavigationContext()?.targetPath).toBe('/nav2');
+
+ // Nav2 completes - should pop because nav2 IS on top
+ clearNavigationContext(token2);
+
+ // Now nav1's stale context is on top (will be cleaned by overflow protection)
+ expect(getNavigationContext()?.targetPath).toBe('/nav1');
+ });
+
+ it('should not throw when clearing with unknown token', () => {
+ const unknownToken = {};
+ expect(() => clearNavigationContext(unknownToken)).not.toThrow();
+ });
+
+ it('should correctly handle LIFO cleanup order', () => {
+ const token1 = setNavigationContext('/path1', undefined);
+ const token2 = setNavigationContext('/path2', undefined);
+ const token3 = setNavigationContext('/path3', undefined);
+
+ // Clear in LIFO order
+ clearNavigationContext(token3);
+ expect(getNavigationContext()?.targetPath).toBe('/path2');
+
+ clearNavigationContext(token2);
+ expect(getNavigationContext()?.targetPath).toBe('/path1');
+
+ clearNavigationContext(token1);
+ expect(getNavigationContext()).toBeNull();
+ });
+ });
+
+ describe('getNavigationContext', () => {
+ it('should return null when stack is empty', () => {
+ expect(getNavigationContext()).toBeNull();
+ });
+
+ it('should return the most recent context', () => {
+ setNavigationContext('/first', undefined);
+ setNavigationContext('/second', undefined);
+ setNavigationContext('/third', undefined);
+
+ expect(getNavigationContext()?.targetPath).toBe('/third');
+ });
+ });
+
+ describe('stack overflow protection', () => {
+ it('should remove oldest context when stack exceeds limit', () => {
+ // Push 12 contexts (limit is 10)
+ const tokens: object[] = [];
+ for (let i = 0; i < 12; i++) {
+ tokens.push(setNavigationContext(`/path${i}`, undefined));
+ }
+
+ // Most recent should be /path11
+ expect(getNavigationContext()?.targetPath).toBe('/path11');
+
+ // The oldest contexts (path0, path1) were evicted due to overflow
+ // Trying to clear them does nothing (their tokens no longer match anything)
+ clearNavigationContext(tokens[0]!);
+ clearNavigationContext(tokens[1]!);
+
+ // /path11 should still be current
+ expect(getNavigationContext()?.targetPath).toBe('/path11');
+ });
+ });
+ });
});
From c786fc52e5957747a59ce1ad085cf51be1f97d97 Mon Sep 17 00:00:00 2001
From: Lukas Stracke
Date: Tue, 9 Dec 2025 11:57:35 +0100
Subject: [PATCH 07/14] chore(ci): Fix double issue creation for unreferenced
PRs (#18442)
This fixes a problem, where our unreferenced PR GH workflow would
trigger another new issue being created because of a race condition
between creating the issue and updating the PR description
automatically.
---
.../workflows/create-issue-for-unreferenced-prs.yml | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml
index a0eee060f282..a47df32738d7 100644
--- a/.github/workflows/create-issue-for-unreferenced-prs.yml
+++ b/.github/workflows/create-issue-for-unreferenced-prs.yml
@@ -41,6 +41,14 @@ jobs:
return;
}
+ // Bail if this edit was made by the GitHub Actions bot (this workflow)
+ // This prevents infinite loops when we update the PR body with the new issue reference
+ // We check login specifically to not skip edits from other legitimate bots
+ if (context.payload.sender && context.payload.sender.login === 'github-actions[bot]') {
+ console.log(`PR #${pr.number} was edited by github-actions[bot] (this workflow), skipping.`);
+ return;
+ }
+
// Check if the PR is already approved
const reviewsResponse = await github.rest.pulls.listReviews({
owner: context.repo.owner,
@@ -109,7 +117,7 @@ jobs:
console.log(`Created issue #${issueID}.`);
// Update the PR body to reference the new issue
- const updatedPrBody = `${prBody}\n\nCloses #${issueID}`;
+ const updatedPrBody = `${prBody}\n\nCloses #${issueID} (added automatically)`;
await github.rest.pulls.update({
owner: context.repo.owner,
From ca146a57deaca8e6da3eb660177401f8a014ce66 Mon Sep 17 00:00:00 2001
From: Lukas Stracke
Date: Tue, 9 Dec 2025 12:00:12 +0100
Subject: [PATCH 08/14] chore(publish): Fix publish order for `@sentry/types`
(#18429)
Types depends on core but we accidentally published it before core. Not
the end of the world but theoretically, if publishing core failed, we
would have published a faulty types package. h/t @BYK for detecting
this!
Closes #18431
---
.craft.yml | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/.craft.yml b/.craft.yml
index cf86175ca43d..f2ffca132f23 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -4,14 +4,14 @@ preReleaseCommand: bash scripts/craft-pre-release.sh
targets:
# NPM Targets
## 1. Base Packages, node or browser SDKs depend on
- ## 1.1 Types
- - name: npm
- id: '@sentry/types'
- includeNames: /^sentry-types-\d.*\.tgz$/
- ## 1.2 Core SDKs
+ ## 1.1 Core SDKs
- name: npm
id: '@sentry/core'
includeNames: /^sentry-core-\d.*\.tgz$/
+ ## 1.2 Types
+ - name: npm
+ id: '@sentry/types'
+ includeNames: /^sentry-types-\d.*\.tgz$/
- name: npm
id: '@sentry/node-core'
includeNames: /^sentry-node-core-\d.*\.tgz$/
From 49facf21411058f14d9651a6609c1d6cd1193de0 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 12:05:14 +0100
Subject: [PATCH 09/14] chore(deps): bump next from 16.0.0 to 16.0.7 in
/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel (#18439)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [next](https://github.com/vercel/next.js) from 16.0.0 to 16.0.7.
Release notes
Sourced from next's
releases.
v16.0.7
Please see CVE-2025-66478 for
additional details about this release.
v16.0.6
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- bump the browserslist version to silence a warning in CI (#86625)
Credits
Huge thanks to @lukesandberg
for helping!
v16.0.5
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- fix(nodejs-middleware): await for body cloning to be properly
finalized (#85418)
Credits
Huge thanks to @lucasadrianof
for helping!
v16.0.4
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- fix: Rename proxy.js to middleware.js in NFT file (#86214)
- fix: prevent fetch abort errors propagating to user error boundaries
(#86277)
- Turbopack: fix passing project options from napi (#86256)
Credits
Huge thanks to @devjiwonchoi,
@sokra and @ztanner for
helping!
v16.0.3
Core Changes
- fix: Rspack throw error when using ForceCompleteRuntimePlugin: #85221
- fix: build CLI output not displaying Proxy (Middleware) when nodejs
runtime: #85403
- fix: staleTimes.static should consistently enforce a 30s minimum: #85479
- [turbopack] fix build of empty entries of pages: #84873
- Cache the head separately from the route tree: #84724
- Allow inspecting dev server on default port with
next dev
--inspect: #85037
... (truncated)
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.../e2e-tests/test-applications/nextjs-16-tunnel/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json
index 40389ad0888f..724dc9e58e4d 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json
@@ -27,7 +27,7 @@
"@sentry/core": "latest || *",
"ai": "^3.0.0",
"import-in-the-middle": "^1",
- "next": "16.0.0",
+ "next": "16.0.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^7",
From 23c16fd6fd7ffb6bf25db285ab1562d227716dfa Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 9 Dec 2025 12:05:22 +0100
Subject: [PATCH 10/14] chore(deps): bump next from 16.0.0 to 16.0.7 in
/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents (#18427)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [next](https://github.com/vercel/next.js) from 16.0.0 to 16.0.7.
Release notes
Sourced from next's
releases.
v16.0.7
Please see CVE-2025-66478 for
additional details about this release.
v16.0.6
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- bump the browserslist version to silence a warning in CI (#86625)
Credits
Huge thanks to @lukesandberg
for helping!
v16.0.5
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- fix(nodejs-middleware): await for body cloning to be properly
finalized (#85418)
Credits
Huge thanks to @lucasadrianof
for helping!
v16.0.4
[!NOTE]
This release is backporting bug fixes. It does not
include all pending features/changes on canary.
Core Changes
- fix: Rename proxy.js to middleware.js in NFT file (#86214)
- fix: prevent fetch abort errors propagating to user error boundaries
(#86277)
- Turbopack: fix passing project options from napi (#86256)
Credits
Huge thanks to @devjiwonchoi,
@sokra and @ztanner for
helping!
v16.0.3
Core Changes
- fix: Rspack throw error when using ForceCompleteRuntimePlugin: #85221
- fix: build CLI output not displaying Proxy (Middleware) when nodejs
runtime: #85403
- fix: staleTimes.static should consistently enforce a 30s minimum: #85479
- [turbopack] fix build of empty entries of pages: #84873
- Cache the head separately from the route tree: #84724
- Allow inspecting dev server on default port with
next dev
--inspect: #85037
... (truncated)
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.../test-applications/nextjs-16-cacheComponents/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
index de2d67b0ed4b..bbd1573fc5be 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
+++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/package.json
@@ -26,7 +26,7 @@
"@sentry/nextjs": "latest || *",
"@sentry/core": "latest || *",
"import-in-the-middle": "^1",
- "next": "16.0.0",
+ "next": "16.0.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"require-in-the-middle": "^7",
From 859608626338666a807699bd8be7b0b4b2cb520d Mon Sep 17 00:00:00 2001
From: Onur Temizkan
Date: Tue, 9 Dec 2025 20:57:00 +0000
Subject: [PATCH 11/14] fix(aws-serverless): Remove hyphens from AWS-lambda
origins (#18353)
Following up: https://github.com/getsentry/sentry-javascript/pull/18351
Looks like these are also forgotten in hyphens
---
.github/workflows/build.yml | 4 ++
dev-packages/e2e-tests/run.ts | 2 +
.../aws-serverless/src/stack.ts | 55 ++++++++++++++++++-
.../aws-serverless/tests/lambda-fixtures.ts | 28 +++++++++-
.../aws-serverless/tests/layer.test.ts | 12 ++--
.../aws-serverless/tests/npm.test.ts | 8 +--
.../src/integration/awslambda.ts | 2 +-
7 files changed, 96 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5575f81c9e4a..b351bdc647a0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -992,6 +992,8 @@ jobs:
working-directory: ${{ runner.temp }}/test-application
timeout-minutes: 7
run: ${{ matrix.build-command || 'pnpm test:build' }}
+ env:
+ SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }}
- name: Install Playwright
uses: ./.github/actions/install-playwright
@@ -1003,6 +1005,8 @@ jobs:
working-directory: ${{ runner.temp }}/test-application
timeout-minutes: 10
run: ${{ matrix.assert-command || 'pnpm test:assert' }}
+ env:
+ SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }}
- name: Upload Playwright Traces
uses: actions/upload-artifact@v5
diff --git a/dev-packages/e2e-tests/run.ts b/dev-packages/e2e-tests/run.ts
index 5312dc664cee..e0331f0694f8 100644
--- a/dev-packages/e2e-tests/run.ts
+++ b/dev-packages/e2e-tests/run.ts
@@ -76,6 +76,8 @@ async function run(): Promise {
REACT_APP_E2E_TEST_DSN: dsn,
E2E_TEST_SENTRY_ORG_SLUG: process.env.E2E_TEST_SENTRY_ORG_SLUG || DEFAULT_SENTRY_ORG_SLUG,
E2E_TEST_SENTRY_PROJECT: process.env.E2E_TEST_SENTRY_PROJECT || DEFAULT_SENTRY_PROJECT,
+ // Pass workspace root so tests copied to temp dirs can find local packages
+ SENTRY_E2E_WORKSPACE_ROOT: resolve(__dirname, '../..'),
};
const env = {
diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts
index d23feae60811..63463c914e1d 100644
--- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts
+++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts
@@ -15,6 +15,17 @@ const LAYER_DIR = './node_modules/@sentry/aws-serverless/';
const DEFAULT_NODE_VERSION = '22';
export const SAM_PORT = 3001;
+function resolvePackagesDir(): string {
+ // When running via the e2e test runner, tests are copied to a temp directory
+ // so we need the workspace root passed via env var
+ const workspaceRoot = process.env.SENTRY_E2E_WORKSPACE_ROOT;
+ if (workspaceRoot) {
+ return path.join(workspaceRoot, 'packages');
+ }
+ // Fallback for local development when running from the original location
+ return path.resolve(__dirname, '../../../../../packages');
+}
+
export class LocalLambdaStack extends Stack {
sentryLayer: CfnResource;
@@ -67,10 +78,48 @@ export class LocalLambdaStack extends Stack {
const functionName = `${addLayer ? 'Layer' : 'Npm'}${lambdaDir}`;
if (!addLayer) {
+ const lambdaPath = path.resolve(functionsDir, lambdaDir);
+ const packageLockPath = path.join(lambdaPath, 'package-lock.json');
+ const nodeModulesPath = path.join(lambdaPath, 'node_modules');
+
+ // Point the dependency at the locally built packages so tests use the current workspace bits
+ // We need to link all @sentry/* packages that are dependencies of aws-serverless
+ // because otherwise npm will try to install them from the registry, where the current version is not yet published
+ const packagesToLink = ['aws-serverless', 'node', 'core', 'node-core', 'opentelemetry'];
+ const dependencies: Record = {};
+
+ const packagesDir = resolvePackagesDir();
+ for (const pkgName of packagesToLink) {
+ const pkgDir = path.join(packagesDir, pkgName);
+ if (!fs.existsSync(pkgDir)) {
+ throw new Error(
+ `[LocalLambdaStack] Workspace package ${pkgName} not found at ${pkgDir}. Did you run the build?`,
+ );
+ }
+ const relativePath = path.relative(lambdaPath, pkgDir);
+ dependencies[`@sentry/${pkgName}`] = `file:${relativePath.replace(/\\/g, '/')}`;
+ }
+
console.log(`[LocalLambdaStack] Install dependencies for ${functionName}`);
- const packageJson = { dependencies: { '@sentry/aws-serverless': '* || latest' } };
- fs.writeFileSync(path.join(functionsDir, lambdaDir, 'package.json'), JSON.stringify(packageJson, null, 2));
- execFileSync('npm', ['install', '--prefix', path.join(functionsDir, lambdaDir)], { stdio: 'inherit' });
+
+ if (fs.existsSync(packageLockPath)) {
+ // Prevent stale lock files from pinning the published package version
+ fs.rmSync(packageLockPath);
+ }
+
+ if (fs.existsSync(nodeModulesPath)) {
+ // Ensure we reinstall from the workspace instead of reusing cached dependencies
+ fs.rmSync(nodeModulesPath, { recursive: true, force: true });
+ }
+
+ const packageJson = {
+ dependencies,
+ };
+
+ fs.writeFileSync(path.join(lambdaPath, 'package.json'), JSON.stringify(packageJson, null, 2));
+ // Use --install-links to copy files instead of creating symlinks for file: dependencies.
+ // Symlinks don't work inside the Docker container because the target paths don't exist there.
+ execFileSync('npm', ['install', '--install-links', '--prefix', lambdaPath], { stdio: 'inherit' });
}
new CfnResource(this, functionName, {
diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts
index d6f331c7e96b..23aab3a7d683 100644
--- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts
+++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts
@@ -17,7 +17,7 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien
console.log('[testEnvironment fixture] Setting up AWS Lambda test infrastructure');
execSync('docker network prune -f');
- execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`);
+ createDockerNetwork();
const hostIp = await getHostIp();
const app = new App();
@@ -71,6 +71,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien
resolve(void 0);
}, 5000);
});
+
+ removeDockerNetwork();
}
},
{ scope: 'worker', auto: true },
@@ -88,3 +90,27 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien
await use(lambdaClient);
},
});
+
+function createDockerNetwork() {
+ try {
+ execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`);
+ } catch (error) {
+ const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? '';
+ if (stderr.includes('already exists')) {
+ console.log(`[testEnvironment fixture] Reusing existing docker network ${DOCKER_NETWORK_NAME}`);
+ return;
+ }
+ throw error;
+ }
+}
+
+function removeDockerNetwork() {
+ try {
+ execSync(`docker network rm ${DOCKER_NETWORK_NAME}`);
+ } catch (error) {
+ const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? '';
+ if (!stderr.includes('No such network')) {
+ console.warn(`[testEnvironment fixture] Failed to remove docker network ${DOCKER_NETWORK_NAME}: ${stderr}`);
+ }
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts
index bb7ae03a96e7..966ddf032218 100644
--- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts
+++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts
@@ -23,7 +23,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.source': 'custom',
- 'sentry.origin': 'auto.otel.aws-lambda',
+ 'sentry.origin': 'auto.otel.aws_lambda',
'sentry.op': 'function.aws.lambda',
'cloud.account.id': '012345678912',
'faas.execution': expect.any(String),
@@ -32,7 +32,7 @@ test.describe('Lambda layer', () => {
'otel.kind': 'SERVER',
},
op: 'function.aws.lambda',
- origin: 'auto.otel.aws-lambda',
+ origin: 'auto.otel.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
@@ -91,7 +91,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.source': 'custom',
- 'sentry.origin': 'auto.otel.aws-lambda',
+ 'sentry.origin': 'auto.otel.aws_lambda',
'sentry.op': 'function.aws.lambda',
'cloud.account.id': '012345678912',
'faas.execution': expect.any(String),
@@ -100,7 +100,7 @@ test.describe('Lambda layer', () => {
'otel.kind': 'SERVER',
},
op: 'function.aws.lambda',
- origin: 'auto.otel.aws-lambda',
+ origin: 'auto.otel.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
@@ -214,7 +214,7 @@ test.describe('Lambda layer', () => {
data: {
'sentry.sample_rate': 1,
'sentry.source': 'custom',
- 'sentry.origin': 'auto.otel.aws-lambda',
+ 'sentry.origin': 'auto.otel.aws_lambda',
'sentry.op': 'function.aws.lambda',
'cloud.account.id': '012345678912',
'faas.execution': expect.any(String),
@@ -223,7 +223,7 @@ test.describe('Lambda layer', () => {
'otel.kind': 'SERVER',
},
op: 'function.aws.lambda',
- origin: 'auto.otel.aws-lambda',
+ origin: 'auto.otel.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts
index 9b4183425c95..e5b6ee1b9f32 100644
--- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts
+++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts
@@ -23,7 +23,7 @@ test.describe('NPM package', () => {
data: {
'sentry.sample_rate': 1,
'sentry.source': 'custom',
- 'sentry.origin': 'auto.otel.aws-lambda',
+ 'sentry.origin': 'auto.otel.aws_lambda',
'sentry.op': 'function.aws.lambda',
'cloud.account.id': '012345678912',
'faas.execution': expect.any(String),
@@ -32,7 +32,7 @@ test.describe('NPM package', () => {
'otel.kind': 'SERVER',
},
op: 'function.aws.lambda',
- origin: 'auto.otel.aws-lambda',
+ origin: 'auto.otel.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
@@ -91,7 +91,7 @@ test.describe('NPM package', () => {
data: {
'sentry.sample_rate': 1,
'sentry.source': 'custom',
- 'sentry.origin': 'auto.otel.aws-lambda',
+ 'sentry.origin': 'auto.otel.aws_lambda',
'sentry.op': 'function.aws.lambda',
'cloud.account.id': '012345678912',
'faas.execution': expect.any(String),
@@ -100,7 +100,7 @@ test.describe('NPM package', () => {
'otel.kind': 'SERVER',
},
op: 'function.aws.lambda',
- origin: 'auto.otel.aws-lambda',
+ origin: 'auto.otel.aws_lambda',
span_id: expect.stringMatching(/[a-f0-9]{16}/),
status: 'ok',
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts
index 2eaa1fd17354..0da2ea148a3f 100644
--- a/packages/aws-serverless/src/integration/awslambda.ts
+++ b/packages/aws-serverless/src/integration/awslambda.ts
@@ -24,7 +24,7 @@ export const instrumentAwsLambda = generateInstrumentOnce(
...options,
eventContextExtractor,
requestHook(span) {
- span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda');
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws_lambda');
span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda');
},
responseHook(_span, { err }) {
From fd67c1937818ec6778795129552c9392985edd1f Mon Sep 17 00:00:00 2001
From: Sigrid <32902192+s1gr1d@users.noreply.github.com>
Date: Wed, 10 Dec 2025 11:21:59 +0100
Subject: [PATCH 12/14] fix(core): Parse method from Request object in fetch
(#18453)
Fixes the case that the instrumentation defaulted to `GET` in case
`undefined` was parsed as a second `fetch` argument.
```js
const request = new Request("https://httpbin.org/post", { method: "POST" });
const response = await fetch(request, undefined); <-- will be GET (should be POST)
```
Closes #18455 (added automatically)
---
packages/core/src/instrument/fetch.ts | 11 +++++---
.../core/test/lib/instrument/fetch.test.ts | 25 +++++++++++++++++++
2 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/packages/core/src/instrument/fetch.ts b/packages/core/src/instrument/fetch.ts
index 0780b25bb29f..ef69ba8223e0 100644
--- a/packages/core/src/instrument/fetch.ts
+++ b/packages/core/src/instrument/fetch.ts
@@ -240,11 +240,16 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str
}
if (fetchArgs.length === 2) {
- const [url, options] = fetchArgs as [FetchResource, object];
+ const [resource, options] = fetchArgs as [FetchResource, object];
return {
- url: getUrlFromResource(url),
- method: hasProp(options, 'method') ? String(options.method).toUpperCase() : 'GET',
+ url: getUrlFromResource(resource),
+ method: hasProp(options, 'method')
+ ? String(options.method).toUpperCase()
+ : // Request object as first argument
+ isRequest(resource) && hasProp(resource, 'method')
+ ? String(resource.method).toUpperCase()
+ : 'GET',
};
}
diff --git a/packages/core/test/lib/instrument/fetch.test.ts b/packages/core/test/lib/instrument/fetch.test.ts
index 88d780a7dbad..215b0c513ee5 100644
--- a/packages/core/test/lib/instrument/fetch.test.ts
+++ b/packages/core/test/lib/instrument/fetch.test.ts
@@ -27,4 +27,29 @@ describe('instrument > parseFetchArgs', () => {
expect(actual).toEqual(expected);
});
+
+ describe('fetch with Request object', () => {
+ it.each([
+ [
+ 'Request object (as only arg)',
+ [new Request('http://example.com', { method: 'POST' })],
+ { method: 'POST', url: 'http://example.com/' },
+ ],
+ [
+ 'Request object (with undefined options arg)',
+ [new Request('http://example.com', { method: 'POST' }), undefined],
+ { method: 'POST', url: 'http://example.com/' },
+ ],
+ [
+ 'Request object (with overwritten options arg)',
+ [new Request('http://example.com', { method: 'POST' }), { method: 'DELETE' }],
+ // fetch options overwrite Request object options
+ { method: 'DELETE', url: 'http://example.com/' },
+ ],
+ ])('%s', (_name, args, expected) => {
+ const actual = parseFetchArgs(args as unknown[]);
+
+ expect(actual).toEqual(expected);
+ });
+ });
});
From 741ad6a39a05c2dee5f3f466e39718b6796b360f Mon Sep 17 00:00:00 2001
From: Tim Fish
Date: Wed, 10 Dec 2025 11:28:04 +0100
Subject: [PATCH 13/14] feat(node): Capture scope when event loop blocked
(#18040)
- Closes #17887
This currently only capture scope if it gets forked as isolation scope
(`Sentry.withIsolationScope`)
---
.../suites/thread-blocked-native/isolated.mjs | 37 ++++++++++
.../suites/thread-blocked-native/test.ts | 71 +++++++++++++++++--
.../node-integration-tests/utils/index.ts | 2 +-
packages/node-core/src/sdk/client.ts | 4 +-
packages/node-native/package.json | 2 +-
.../src/event-loop-block-integration.ts | 32 +++++----
.../src/event-loop-block-watchdog.ts | 44 ++++++++++--
packages/node/src/sdk/initOtel.ts | 21 ++++--
packages/opentelemetry/src/contextManager.ts | 28 +++++++-
packages/opentelemetry/src/index.ts | 1 +
yarn.lock | 8 +--
11 files changed, 212 insertions(+), 38 deletions(-)
create mode 100644 dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs
diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs
new file mode 100644
index 000000000000..c2c0f39fc44e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/isolated.mjs
@@ -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);
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 d168b8ce75d5..75f957f07af5 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
@@ -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') {
@@ -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-f\d]{16}/),
+ trace_id: expect.stringMatching(/[a-f\d]{32}/),
+ },
+ }
+ : {}),
device: {
arch: expect.any(String),
},
@@ -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: [
{
@@ -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: [
{
@@ -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();
});
@@ -160,7 +169,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
.withMockSentryServer()
.expect({
event: {
- ...ANR_EVENT,
+ ...ANR_EVENT(),
exception: EXCEPTION('0', 'longWorkOther'),
},
})
@@ -179,7 +188,7 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => {
expect(crashedThread).toBeDefined();
expect(event).toMatchObject({
- ...ANR_EVENT,
+ ...ANR_EVENT(),
exception: {
...EXCEPTION(crashedThread),
},
@@ -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();
+ });
});
diff --git a/dev-packages/node-integration-tests/utils/index.ts b/dev-packages/node-integration-tests/utils/index.ts
index e08d89a92131..92851b42ba5e 100644
--- a/dev-packages/node-integration-tests/utils/index.ts
+++ b/dev-packages/node-integration-tests/utils/index.ts
@@ -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;
diff --git a/packages/node-core/src/sdk/client.ts b/packages/node-core/src/sdk/client.ts
index efc144989421..1e783ee24b80 100644
--- a/packages/node-core/src/sdk/client.ts
+++ b/packages/node-core/src/sdk/client.ts
@@ -12,7 +12,7 @@ import {
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';
@@ -22,6 +22,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 {
public traceProvider: BasicTracerProvider | undefined;
+ public asyncLocalStorageLookup: AsyncLocalStorageLookup | undefined;
+
private _tracer: Tracer | undefined;
private _clientReportInterval: NodeJS.Timeout | undefined;
private _clientReportOnExitFlushListener: (() => void) | undefined;
diff --git a/packages/node-native/package.json b/packages/node-native/package.json
index f5f2ef232c4b..e4da1791544c 100644
--- a/packages/node-native/package.json
+++ b/packages/node-native/package.json
@@ -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.29.0",
"@sentry/node": "10.29.0"
},
diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts
index 713093f77961..7b5c4bc43430 100644
--- a/packages/node-native/src/event-loop-block-integration.ts
+++ b/packages/node-native/src/event-loop-block-integration.ts
@@ -1,7 +1,6 @@
import { isPromise } from 'node:util/types';
import { isMainThread, Worker } from 'node:worker_threads';
import type {
- Client,
ClientOptions,
Contexts,
DsnComponents,
@@ -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
}
@@ -57,10 +56,15 @@ function poll(enabled: boolean, clientOptions: ClientOptions): void {
* Starts polling
*/
function startPolling(
- client: Client,
+ client: NodeClient,
integrationOptions: Partial,
): IntegrationInternal | undefined {
- registerThread();
+ if (client.asyncLocalStorageLookup) {
+ const { asyncLocalStorage, contextSymbol } = client.asyncLocalStorageLookup;
+ registerThread({ asyncLocalStorage, stateLookup: ['_currentContext', contextSymbol] });
+ } else {
+ registerThread();
+ }
let enabled = true;
@@ -160,15 +164,19 @@ const _eventLoopBlockIntegration = ((options: Partial {
+ try {
+ polling = startPolling(client, options);
+
+ if (isMainThread) {
+ await startWorker(dsn, client, options);
+ }
+ } catch (err) {
+ log('Failed to start integration', err);
+ return;
}
- } catch (err) {
- log('Failed to start integration', err);
- }
+ });
},
start() {
polling?.start();
diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts
index 492070a2d1dc..a4eb696c7a95 100644
--- a/packages/node-native/src/event-loop-block-watchdog.ts
+++ b/packages/node-native/src/event-loop-block-watchdog.ts
@@ -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,
@@ -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,
@@ -178,7 +187,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void
function getExceptionAndThreads(
crashedThreadId: string,
- threads: ReturnType>,
+ threads: ReturnType>,
): Event {
const crashedThread = threads[crashedThreadId];
@@ -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 {
if (isRateLimited()) {
return;
}
- const threads = captureStackTrace();
+ const threads = captureStackTrace();
const crashedThread = threads[crashedThreadId];
if (!crashedThread) {
@@ -231,7 +256,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise {
}
try {
- await sendAbnormalSession(crashedThread.state?.session);
+ await sendAbnormalSession(crashedThread.pollState?.session);
} catch (error) {
log(`Failed to send abnormal session for thread '${crashedThreadId}':`, error);
}
@@ -250,8 +275,17 @@ async function sendBlockEvent(crashedThreadId: string): Promise {
...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 = Object.values(threads).reduce((acc, threadState) => {
- return { ...acc, ...threadState.state?.debugImages };
+ return { ...acc, ...threadState.pollState?.debugImages };
}, {});
applyDebugMeta(event, allDebugImages);
diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts
index 9eec5d752371..a0f1951c376b 100644
--- a/packages/node/src/sdk/initOtel.ts
+++ b/packages/node/src/sdk/initOtel.ts
@@ -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';
@@ -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 {
@@ -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),
@@ -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. */
diff --git a/packages/opentelemetry/src/contextManager.ts b/packages/opentelemetry/src/contextManager.ts
index e8632b095c02..ac8b2eab5c9b 100644
--- a/packages/opentelemetry/src/contextManager.ts
+++ b/packages/opentelemetry/src/contextManager.ts
@@ -1,3 +1,4 @@
+import type { AsyncLocalStorage } from 'node:async_hooks';
import type { Context, ContextManager } from '@opentelemetry/api';
import type { Scope } from '@sentry/core';
import { getCurrentScope, getIsolationScope } from '@sentry/core';
@@ -5,10 +6,22 @@ import {
SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY,
SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY,
SENTRY_FORK_SET_SCOPE_CONTEXT_KEY,
+ SENTRY_SCOPES_CONTEXT_KEY,
} from './constants';
import { getScopesFromContext, setContextOnScope, setScopesOnContext } from './utils/contextData';
import { setIsSetup } from './utils/setupCheck';
+export type AsyncLocalStorageLookup = {
+ asyncLocalStorage: AsyncLocalStorage;
+ contextSymbol: symbol;
+};
+
+type ExtendedContextManagerInstance = new (
+ ...args: unknown[]
+) => ContextManagerInstance & {
+ getAsyncLocalStorageLookup(): AsyncLocalStorageLookup;
+};
+
/**
* Wrap an OpenTelemetry ContextManager in a way that ensures the context is kept in sync with the Sentry Scope.
*
@@ -19,7 +32,7 @@ import { setIsSetup } from './utils/setupCheck';
*/
export function wrapContextManagerClass(
ContextManagerClass: new (...args: unknown[]) => ContextManagerInstance,
-): typeof ContextManagerClass {
+): ExtendedContextManagerInstance {
/**
* This is a custom ContextManager for OpenTelemetry, which extends the default AsyncLocalStorageContextManager.
* It ensures that we create new scopes per context, so that the OTEL Context & the Sentry Scope are always in sync.
@@ -69,7 +82,18 @@ export function wrapContextManagerClass;
}
diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts
index 6958d1c9fbdd..e0112812dc69 100644
--- a/packages/opentelemetry/src/index.ts
+++ b/packages/opentelemetry/src/index.ts
@@ -41,6 +41,7 @@ export { setupEventContextTrace } from './setupEventContextTrace';
export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy';
export { wrapContextManagerClass } from './contextManager';
+export type { AsyncLocalStorageLookup } from './contextManager';
export { SentryPropagator, shouldPropagateTraceForUrl } from './propagator';
export { SentrySpanProcessor } from './spanProcessor';
export { SentrySampler, wrapSamplingDecision } from './sampler';
diff --git a/yarn.lock b/yarn.lock
index 210e9d186cfb..a0d0b72f6718 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6993,10 +6993,10 @@
detect-libc "^2.0.3"
node-abi "^3.73.0"
-"@sentry-internal/node-native-stacktrace@^0.2.2":
- version "0.2.2"
- resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.2.tgz#b32dde884642f100dd691b12b643361040825eeb"
- integrity sha512-ZRS+a1Ik+w6awjp9na5vHBqLNkIxysfGDswLVAkjtVdBUxtfsEVI8OA6r8PijJC5Gm1oAJJap2e9H7TSiCUQIQ==
+"@sentry-internal/node-native-stacktrace@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.3.0.tgz#68c80dcf11ee070a3a54406b35d4571952caa793"
+ integrity sha512-ef0M2y2JDrC/H0AxMJJQInGTdZTlnwa6AAVWR4fMOpJRubkfdH2IZXE/nWU0Nj74oeJLQgdPtS6DeijLJtqq8Q==
dependencies:
detect-libc "^2.0.4"
node-abi "^3.73.0"
From cce2c81e4cc2767c3af1f8f58955b3549f9b5734 Mon Sep 17 00:00:00 2001
From: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Date: Wed, 10 Dec 2025 13:24:57 +0100
Subject: [PATCH 14/14] meta(changelog): Update changelog for 10.30.0
---
CHANGELOG.md | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15972e85dfdd..d967a7c39408 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,28 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 10.30.0
+
+- feat(nextjs): Deprecate Webpack top-level options ([#18343](https://github.com/getsentry/sentry-javascript/pull/18343))
+- feat(node): Capture scope when event loop blocked ([#18040](https://github.com/getsentry/sentry-javascript/pull/18040))
+- fix(aws-serverless): Remove hyphens from AWS-lambda origins ([#18353](https://github.com/getsentry/sentry-javascript/pull/18353))
+- fix(core): Parse method from Request object in fetch ([#18453](https://github.com/getsentry/sentry-javascript/pull/18453))
+- fix(react): Add transaction name guards for rapid lazy-route navigations ([#18346](https://github.com/getsentry/sentry-javascript/pull/18346))
+
+
+ Internal Changes
+
+- chore(ci): Fix double issue creation for unreferenced PRs ([#18442](https://github.com/getsentry/sentry-javascript/pull/18442))
+- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15 ([#18411](https://github.com/getsentry/sentry-javascript/pull/18411))
+- chore(deps): bump next from 15.5.4 to 15.5.7 in /dev-packages/e2e-tests/test-applications/nextjs-15-intl ([#18400](https://github.com/getsentry/sentry-javascript/pull/18400))
+- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16 ([#18399](https://github.com/getsentry/sentry-javascript/pull/18399))
+- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents ([#18427](https://github.com/getsentry/sentry-javascript/pull/18427))
+- chore(deps): bump next from 16.0.0 to 16.0.7 in /dev-packages/e2e-tests/test-applications/nextjs-16-tunnel ([#18439](https://github.com/getsentry/sentry-javascript/pull/18439))
+- chore(publish): Fix publish order for `@sentry/types` ([#18429](https://github.com/getsentry/sentry-javascript/pull/18429))
+- ci(deps): bump actions/create-github-app-token from 2.1.4 to 2.2.0 ([#18362](https://github.com/getsentry/sentry-javascript/pull/18362))
+
+
+
## 10.29.0
### Important Changes