diff --git a/packages/nextjs/src/common/utils/withIsolationScopeOrReuseFromRootSpan.ts b/packages/nextjs/src/common/utils/withIsolationScopeOrReuseFromRootSpan.ts new file mode 100644 index 000000000000..82231022b980 --- /dev/null +++ b/packages/nextjs/src/common/utils/withIsolationScopeOrReuseFromRootSpan.ts @@ -0,0 +1,39 @@ +import { + getActiveSpan, + getCapturedScopesOnSpan, + getDefaultIsolationScope, + getRootSpan, + spanToJSON, + withIsolationScope, +} from '@sentry/core'; +import type { Scope } from '@sentry/types'; + +/** + * Wrap a callback with a new isolation scope. + * However, if we have an active root span that was generated by next, we want to reuse the isolation scope from that span. + */ +export function withIsolationScopeOrReuseFromRootSpan(cb: (isolationScope: Scope) => T): T { + const activeSpan = getActiveSpan(); + + if (!activeSpan) { + return withIsolationScope(cb); + } + + const rootSpan = getRootSpan(activeSpan); + + // Verify this is a next span + if (!spanToJSON(rootSpan).data?.['next.route']) { + return withIsolationScope(cb); + } + + const scopes = getCapturedScopesOnSpan(rootSpan); + + const isolationScope = scopes.isolationScope; + + // If this is the default isolation scope, we still want to fork one + if (isolationScope === getDefaultIsolationScope()) { + return withIsolationScope(cb); + } + + return withIsolationScope(isolationScope, cb); +} diff --git a/packages/nextjs/src/common/utils/wrapperUtils.ts b/packages/nextjs/src/common/utils/wrapperUtils.ts index 311be587c439..ef14e19b02fa 100644 --- a/packages/nextjs/src/common/utils/wrapperUtils.ts +++ b/packages/nextjs/src/common/utils/wrapperUtils.ts @@ -10,13 +10,13 @@ import { startSpan, startSpanManual, withActiveSpan, - withIsolationScope, } from '@sentry/core'; import type { Span } from '@sentry/types'; import { isString } from '@sentry/utils'; import { platformSupportsStreaming } from './platformSupportsStreaming'; import { autoEndSpanOnResponseEnd, flushQueue } from './responseEnd'; +import { withIsolationScopeOrReuseFromRootSpan } from './withIsolationScopeOrReuseFromRootSpan'; declare module 'http' { interface IncomingMessage { @@ -89,7 +89,7 @@ export function withTracedServerSideDataFetcher Pr }, ): (...params: Parameters) => Promise> { return async function (this: unknown, ...args: Parameters): Promise> { - return withIsolationScope(async isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(async isolationScope => { isolationScope.setSDKProcessingMetadata({ request: req, }); diff --git a/packages/nextjs/src/common/withServerActionInstrumentation.ts b/packages/nextjs/src/common/withServerActionInstrumentation.ts index ff089944ac76..a3e95575217a 100644 --- a/packages/nextjs/src/common/withServerActionInstrumentation.ts +++ b/packages/nextjs/src/common/withServerActionInstrumentation.ts @@ -6,7 +6,6 @@ import { getClient, handleCallbackErrors, startSpan, - withIsolationScope, } from '@sentry/core'; import { logger } from '@sentry/utils'; @@ -14,6 +13,7 @@ import { DEBUG_BUILD } from './debug-build'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; import { flushQueue } from './utils/responseEnd'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; interface Options { formData?: FormData; @@ -58,7 +58,7 @@ async function withServerActionInstrumentationImplementation> { addTracingExtensions(); - return withIsolationScope(isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(isolationScope => { const sendDefaultPii = getClient()?.getOptions().sendDefaultPii; let sentryTraceHeader; diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts index 81b2f88e7888..1519667a7abd 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentry.ts @@ -5,7 +5,6 @@ import { continueTrace, setHttpStatus, startSpanManual, - withIsolationScope, } from '@sentry/core'; import { consoleSandbox, isString, logger, objectify, stripUrlQueryAndFragment } from '@sentry/utils'; @@ -13,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler } from './types'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; import { flushQueue } from './utils/responseEnd'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; /** * Wrap the given API route handler for tracing and error capturing. Thin wrapper around `withSentry`, which only @@ -54,7 +54,7 @@ export function wrapApiHandlerWithSentry(apiHandler: NextApiHandler, parameteriz addTracingExtensions(); - return withIsolationScope(isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(isolationScope => { return continueTrace( { // TODO(v8): Make it so that continue trace will allow null as sentryTrace value and remove this fallback here diff --git a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts index ec8791f95584..83f5b4aded98 100644 --- a/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts +++ b/packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts @@ -1,7 +1,8 @@ -import { addTracingExtensions, captureCheckIn, withIsolationScope } from '@sentry/core'; +import { addTracingExtensions, captureCheckIn } from '@sentry/core'; import type { NextApiRequest } from 'next'; import type { VercelCronsConfig } from './types'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; type EdgeRequest = { nextUrl: URL; @@ -19,7 +20,7 @@ export function wrapApiHandlerWithSentryVercelCrons { - return withIsolationScope(() => { + return withIsolationScopeOrReuseFromRootSpan(() => { if (!args || !args[0]) { return originalFunction.apply(thisArg, args); } diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index e0b771b7643b..e6dd1072a16b 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -8,7 +8,6 @@ import { getCurrentScope, handleCallbackErrors, startSpanManual, - withIsolationScope, } from '@sentry/core'; import type { WebFetchHeaders } from '@sentry/types'; import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; @@ -17,6 +16,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. @@ -47,7 +47,7 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - return withIsolationScope(isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(isolationScope => { isolationScope.setSDKProcessingMetadata({ request: { headers: headers ? winterCGHeadersToDict(headers) : undefined, diff --git a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts index 90d7f739d531..b14b5cff2e07 100644 --- a/packages/nextjs/src/common/wrapPageComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapPageComponentWithSentry.ts @@ -1,5 +1,6 @@ -import { addTracingExtensions, captureException, getCurrentScope, withIsolationScope } from '@sentry/core'; +import { addTracingExtensions, captureException, getCurrentScope } from '@sentry/core'; import { extractTraceparentData } from '@sentry/utils'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; interface FunctionComponent { (...args: unknown[]): unknown; @@ -25,7 +26,7 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C if (isReactClassComponent(pageComponent)) { return class SentryWrappedPageComponent extends pageComponent { public render(...args: unknown[]): unknown { - return withIsolationScope(() => { + return withIsolationScopeOrReuseFromRootSpan(() => { const scope = getCurrentScope(); // We extract the sentry trace data that is put in the component props by datafetcher wrappers const sentryTraceData = @@ -60,7 +61,7 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C } else if (typeof pageComponent === 'function') { return new Proxy(pageComponent, { apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) { - return withIsolationScope(() => { + return withIsolationScopeOrReuseFromRootSpan(() => { const scope = getCurrentScope(); // We extract the sentry trace data that is put in the component props by datafetcher wrappers const sentryTraceData = argArray?.[0]?._sentryTraceData; diff --git a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts index 50300cf33b02..edee1e54cec4 100644 --- a/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts +++ b/packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts @@ -8,7 +8,6 @@ import { handleCallbackErrors, setHttpStatus, startSpan, - withIsolationScope, } from '@sentry/core'; import { winterCGHeadersToDict } from '@sentry/utils'; @@ -16,6 +15,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavi import type { RouteHandlerContext } from './types'; import { platformSupportsStreaming } from './utils/platformSupportsStreaming'; import { flushQueue } from './utils/responseEnd'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; /** * Wraps a Next.js route handler with performance and error instrumentation. @@ -29,7 +29,7 @@ export function wrapRouteHandlerWithSentry any>( const { method, parameterizedRoute, headers } = context; return new Proxy(routeHandler, { apply: (originalFunction, thisArg, args) => { - return withIsolationScope(async isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(async isolationScope => { isolationScope.setSDKProcessingMetadata({ request: { headers: headers ? winterCGHeadersToDict(headers) : undefined, diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index f91a2181b58b..76521b71e0d1 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -7,7 +7,6 @@ import { getCurrentScope, handleCallbackErrors, startSpanManual, - withIsolationScope, } from '@sentry/core'; import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils'; @@ -16,6 +15,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/ import type { ServerComponentContext } from '../common/types'; import { commonObjectToPropagationContext } from './utils/commonObjectTracing'; import { flushQueue } from './utils/responseEnd'; +import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan'; /** * Wraps an `app` directory server component with Sentry error instrumentation. @@ -34,7 +34,7 @@ export function wrapServerComponentWithSentry any> return new Proxy(appDirComponent, { apply: (originalFunction, thisArg, args) => { // TODO: If we ever allow withIsolationScope to take a scope, we should pass a scope here that is shared between all of the server components, similar to what `commonObjectToPropagationContext` does. - return withIsolationScope(isolationScope => { + return withIsolationScopeOrReuseFromRootSpan(isolationScope => { const completeHeadersDict: Record = context.headers ? winterCGHeadersToDict(context.headers) : {}; diff --git a/packages/nextjs/test/edge/withIsolationScopeOrReuseFromRootSpan.test.ts b/packages/nextjs/test/edge/withIsolationScopeOrReuseFromRootSpan.test.ts new file mode 100644 index 000000000000..8f42b8a9264b --- /dev/null +++ b/packages/nextjs/test/edge/withIsolationScopeOrReuseFromRootSpan.test.ts @@ -0,0 +1,96 @@ +import { + Scope, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCapturedScopesOnSpan, + startSpan, +} from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/utils'; +import { init } from '@sentry/vercel-edge'; +import { AsyncLocalStorage } from 'async_hooks'; + +import { withIsolationScopeOrReuseFromRootSpan } from '../../src/common/utils/withIsolationScopeOrReuseFromRootSpan'; + +describe('withIsolationScopeOrReuseFromRootSpan', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + + init({ + enableTracing: true, + }); + }); + + it('works without any span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }); + + it('works with a non-next.js span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + startSpan({ name: 'other' }, span => { + setCapturedScopesOnSpan(span, getCurrentScope(), customScope); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }); + }); + + it('works with a next.js span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + startSpan( + { + name: 'other', + attributes: { 'next.route': 'aha' }, + }, + span => { + setCapturedScopesOnSpan(span, getCurrentScope(), customScope); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).toBe(customScope); + expect(isolationScope.getScopeData().tags).toEqual({ bb: 'bb' }); + }); + }, + ); + }); + + it('works with a next.js span that has default isolation scope', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + startSpan( + { + name: 'other', + attributes: { 'next.route': 'aha' }, + }, + () => { + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }, + ); + }); +}); diff --git a/packages/nextjs/test/server/withIsolationScopeOrReuseFromRootSpan.test.ts b/packages/nextjs/test/server/withIsolationScopeOrReuseFromRootSpan.test.ts new file mode 100644 index 000000000000..0cfc53e0a5b2 --- /dev/null +++ b/packages/nextjs/test/server/withIsolationScopeOrReuseFromRootSpan.test.ts @@ -0,0 +1,96 @@ +import { + Scope, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCapturedScopesOnSpan, + startSpan, +} from '@sentry/core'; +import { init } from '@sentry/node'; +import { GLOBAL_OBJ } from '@sentry/utils'; +import { AsyncLocalStorage } from 'async_hooks'; + +import { withIsolationScopeOrReuseFromRootSpan } from '../../src/common/utils/withIsolationScopeOrReuseFromRootSpan'; + +describe('withIsolationScopeOrReuseFromRootSpan', () => { + beforeEach(() => { + getIsolationScope().clear(); + getCurrentScope().clear(); + getGlobalScope().clear(); + (GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage; + + init({ + enableTracing: true, + }); + }); + + it('works without any span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }); + + it('works with a non-next.js span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + startSpan({ name: 'other' }, span => { + setCapturedScopesOnSpan(span, getCurrentScope(), customScope); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }); + }); + + it('works with a next.js span', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + const customScope = new Scope(); + + startSpan( + { + name: 'other', + attributes: { 'next.route': 'aha' }, + }, + span => { + setCapturedScopesOnSpan(span, getCurrentScope(), customScope); + + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).toBe(customScope); + expect(isolationScope.getScopeData().tags).toEqual({ bb: 'bb' }); + }); + }, + ); + }); + + it('works with a next.js span that has default isolation scope', () => { + const initialIsolationScope = getIsolationScope(); + initialIsolationScope.setTag('aa', 'aa'); + + startSpan( + { + name: 'other', + attributes: { 'next.route': 'aha' }, + }, + () => { + withIsolationScopeOrReuseFromRootSpan(isolationScope => { + isolationScope.setTag('bb', 'bb'); + expect(isolationScope).not.toBe(initialIsolationScope); + expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' }); + }); + }, + ); + }); +});