Skip to content

Commit 75d288c

Browse files
authored
feat(next): Handle existing root spans for isolation scope (#11479)
This updates handling of next.js instrumentation to re-use an isolation scope from a root span. This should ensure we have consistent isolation scopes, no matter if next.js auto creates spans or not.
1 parent a49f031 commit 75d288c

11 files changed

+250
-17
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import {
2+
getActiveSpan,
3+
getCapturedScopesOnSpan,
4+
getDefaultIsolationScope,
5+
getRootSpan,
6+
spanToJSON,
7+
withIsolationScope,
8+
} from '@sentry/core';
9+
import type { Scope } from '@sentry/types';
10+
11+
/**
12+
* Wrap a callback with a new isolation scope.
13+
* However, if we have an active root span that was generated by next, we want to reuse the isolation scope from that span.
14+
*/
15+
export function withIsolationScopeOrReuseFromRootSpan<T>(cb: (isolationScope: Scope) => T): T {
16+
const activeSpan = getActiveSpan();
17+
18+
if (!activeSpan) {
19+
return withIsolationScope(cb);
20+
}
21+
22+
const rootSpan = getRootSpan(activeSpan);
23+
24+
// Verify this is a next span
25+
if (!spanToJSON(rootSpan).data?.['next.route']) {
26+
return withIsolationScope(cb);
27+
}
28+
29+
const scopes = getCapturedScopesOnSpan(rootSpan);
30+
31+
const isolationScope = scopes.isolationScope;
32+
33+
// If this is the default isolation scope, we still want to fork one
34+
if (isolationScope === getDefaultIsolationScope()) {
35+
return withIsolationScope(cb);
36+
}
37+
38+
return withIsolationScope(isolationScope, cb);
39+
}

packages/nextjs/src/common/utils/wrapperUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import {
1010
startSpan,
1111
startSpanManual,
1212
withActiveSpan,
13-
withIsolationScope,
1413
} from '@sentry/core';
1514
import type { Span } from '@sentry/types';
1615
import { isString } from '@sentry/utils';
1716

1817
import { platformSupportsStreaming } from './platformSupportsStreaming';
1918
import { autoEndSpanOnResponseEnd, flushQueue } from './responseEnd';
19+
import { withIsolationScopeOrReuseFromRootSpan } from './withIsolationScopeOrReuseFromRootSpan';
2020

2121
declare module 'http' {
2222
interface IncomingMessage {
@@ -89,7 +89,7 @@ export function withTracedServerSideDataFetcher<F extends (...args: any[]) => Pr
8989
},
9090
): (...params: Parameters<F>) => Promise<ReturnType<F>> {
9191
return async function (this: unknown, ...args: Parameters<F>): Promise<ReturnType<F>> {
92-
return withIsolationScope(async isolationScope => {
92+
return withIsolationScopeOrReuseFromRootSpan(async isolationScope => {
9393
isolationScope.setSDKProcessingMetadata({
9494
request: req,
9595
});

packages/nextjs/src/common/withServerActionInstrumentation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import {
66
getClient,
77
handleCallbackErrors,
88
startSpan,
9-
withIsolationScope,
109
} from '@sentry/core';
1110
import { logger } from '@sentry/utils';
1211

1312
import { DEBUG_BUILD } from './debug-build';
1413
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1514
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
1615
import { flushQueue } from './utils/responseEnd';
16+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
1717

1818
interface Options {
1919
formData?: FormData;
@@ -58,7 +58,7 @@ async function withServerActionInstrumentationImplementation<A extends (...args:
5858
callback: A,
5959
): Promise<ReturnType<A>> {
6060
addTracingExtensions();
61-
return withIsolationScope(isolationScope => {
61+
return withIsolationScopeOrReuseFromRootSpan(isolationScope => {
6262
const sendDefaultPii = getClient()?.getOptions().sendDefaultPii;
6363

6464
let sentryTraceHeader;

packages/nextjs/src/common/wrapApiHandlerWithSentry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import {
55
continueTrace,
66
setHttpStatus,
77
startSpanManual,
8-
withIsolationScope,
98
} from '@sentry/core';
109
import { consoleSandbox, isString, logger, objectify, stripUrlQueryAndFragment } from '@sentry/utils';
1110

1211
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
1312
import type { AugmentedNextApiRequest, AugmentedNextApiResponse, NextApiHandler } from './types';
1413
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
1514
import { flushQueue } from './utils/responseEnd';
15+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
1616

1717
/**
1818
* 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
5454

5555
addTracingExtensions();
5656

57-
return withIsolationScope(isolationScope => {
57+
return withIsolationScopeOrReuseFromRootSpan(isolationScope => {
5858
return continueTrace(
5959
{
6060
// TODO(v8): Make it so that continue trace will allow null as sentryTrace value and remove this fallback here

packages/nextjs/src/common/wrapApiHandlerWithSentryVercelCrons.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { addTracingExtensions, captureCheckIn, withIsolationScope } from '@sentry/core';
1+
import { addTracingExtensions, captureCheckIn } from '@sentry/core';
22
import type { NextApiRequest } from 'next';
33

44
import type { VercelCronsConfig } from './types';
5+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
56

67
type EdgeRequest = {
78
nextUrl: URL;
@@ -19,7 +20,7 @@ export function wrapApiHandlerWithSentryVercelCrons<F extends (...args: any[]) =
1920
return new Proxy(handler, {
2021
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2122
apply: (originalFunction, thisArg, args: any[]) => {
22-
return withIsolationScope(() => {
23+
return withIsolationScopeOrReuseFromRootSpan(() => {
2324
if (!args || !args[0]) {
2425
return originalFunction.apply(thisArg, args);
2526
}

packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
getCurrentScope,
99
handleCallbackErrors,
1010
startSpanManual,
11-
withIsolationScope,
1211
} from '@sentry/core';
1312
import type { WebFetchHeaders } from '@sentry/types';
1413
import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';
@@ -17,6 +16,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
1716
import type { GenerationFunctionContext } from '../common/types';
1817
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1918
import { commonObjectToPropagationContext } from './utils/commonObjectTracing';
19+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
2020

2121
/**
2222
* Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation.
@@ -47,7 +47,7 @@ export function wrapGenerationFunctionWithSentry<F extends (...args: any[]) => a
4747
data = { params, searchParams };
4848
}
4949

50-
return withIsolationScope(isolationScope => {
50+
return withIsolationScopeOrReuseFromRootSpan(isolationScope => {
5151
isolationScope.setSDKProcessingMetadata({
5252
request: {
5353
headers: headers ? winterCGHeadersToDict(headers) : undefined,

packages/nextjs/src/common/wrapPageComponentWithSentry.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { addTracingExtensions, captureException, getCurrentScope, withIsolationScope } from '@sentry/core';
1+
import { addTracingExtensions, captureException, getCurrentScope } from '@sentry/core';
22
import { extractTraceparentData } from '@sentry/utils';
3+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
34

45
interface FunctionComponent {
56
(...args: unknown[]): unknown;
@@ -25,7 +26,7 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C
2526
if (isReactClassComponent(pageComponent)) {
2627
return class SentryWrappedPageComponent extends pageComponent {
2728
public render(...args: unknown[]): unknown {
28-
return withIsolationScope(() => {
29+
return withIsolationScopeOrReuseFromRootSpan(() => {
2930
const scope = getCurrentScope();
3031
// We extract the sentry trace data that is put in the component props by datafetcher wrappers
3132
const sentryTraceData =
@@ -60,7 +61,7 @@ export function wrapPageComponentWithSentry(pageComponent: FunctionComponent | C
6061
} else if (typeof pageComponent === 'function') {
6162
return new Proxy(pageComponent, {
6263
apply(target, thisArg, argArray: [{ _sentryTraceData?: string } | undefined]) {
63-
return withIsolationScope(() => {
64+
return withIsolationScopeOrReuseFromRootSpan(() => {
6465
const scope = getCurrentScope();
6566
// We extract the sentry trace data that is put in the component props by datafetcher wrappers
6667
const sentryTraceData = argArray?.[0]?._sentryTraceData;

packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import {
88
handleCallbackErrors,
99
setHttpStatus,
1010
startSpan,
11-
withIsolationScope,
1211
} from '@sentry/core';
1312
import { winterCGHeadersToDict } from '@sentry/utils';
1413

1514
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1615
import type { RouteHandlerContext } from './types';
1716
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
1817
import { flushQueue } from './utils/responseEnd';
18+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
1919

2020
/**
2121
* Wraps a Next.js route handler with performance and error instrumentation.
@@ -29,7 +29,7 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
2929
const { method, parameterizedRoute, headers } = context;
3030
return new Proxy(routeHandler, {
3131
apply: (originalFunction, thisArg, args) => {
32-
return withIsolationScope(async isolationScope => {
32+
return withIsolationScopeOrReuseFromRootSpan(async isolationScope => {
3333
isolationScope.setSDKProcessingMetadata({
3434
request: {
3535
headers: headers ? winterCGHeadersToDict(headers) : undefined,

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
getCurrentScope,
88
handleCallbackErrors,
99
startSpanManual,
10-
withIsolationScope,
1110
} from '@sentry/core';
1211
import { propagationContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';
1312

@@ -16,6 +15,7 @@ import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/
1615
import type { ServerComponentContext } from '../common/types';
1716
import { commonObjectToPropagationContext } from './utils/commonObjectTracing';
1817
import { flushQueue } from './utils/responseEnd';
18+
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
1919

2020
/**
2121
* Wraps an `app` directory server component with Sentry error instrumentation.
@@ -34,7 +34,7 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
3434
return new Proxy(appDirComponent, {
3535
apply: (originalFunction, thisArg, args) => {
3636
// 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.
37-
return withIsolationScope(isolationScope => {
37+
return withIsolationScopeOrReuseFromRootSpan(isolationScope => {
3838
const completeHeadersDict: Record<string, string> = context.headers
3939
? winterCGHeadersToDict(context.headers)
4040
: {};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
Scope,
3+
getCurrentScope,
4+
getGlobalScope,
5+
getIsolationScope,
6+
setCapturedScopesOnSpan,
7+
startSpan,
8+
} from '@sentry/core';
9+
import { GLOBAL_OBJ } from '@sentry/utils';
10+
import { init } from '@sentry/vercel-edge';
11+
import { AsyncLocalStorage } from 'async_hooks';
12+
13+
import { withIsolationScopeOrReuseFromRootSpan } from '../../src/common/utils/withIsolationScopeOrReuseFromRootSpan';
14+
15+
describe('withIsolationScopeOrReuseFromRootSpan', () => {
16+
beforeEach(() => {
17+
getIsolationScope().clear();
18+
getCurrentScope().clear();
19+
getGlobalScope().clear();
20+
(GLOBAL_OBJ as any).AsyncLocalStorage = AsyncLocalStorage;
21+
22+
init({
23+
enableTracing: true,
24+
});
25+
});
26+
27+
it('works without any span', () => {
28+
const initialIsolationScope = getIsolationScope();
29+
initialIsolationScope.setTag('aa', 'aa');
30+
31+
withIsolationScopeOrReuseFromRootSpan(isolationScope => {
32+
isolationScope.setTag('bb', 'bb');
33+
expect(isolationScope).not.toBe(initialIsolationScope);
34+
expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' });
35+
});
36+
});
37+
38+
it('works with a non-next.js span', () => {
39+
const initialIsolationScope = getIsolationScope();
40+
initialIsolationScope.setTag('aa', 'aa');
41+
42+
const customScope = new Scope();
43+
44+
startSpan({ name: 'other' }, span => {
45+
setCapturedScopesOnSpan(span, getCurrentScope(), customScope);
46+
47+
withIsolationScopeOrReuseFromRootSpan(isolationScope => {
48+
isolationScope.setTag('bb', 'bb');
49+
expect(isolationScope).not.toBe(initialIsolationScope);
50+
expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' });
51+
});
52+
});
53+
});
54+
55+
it('works with a next.js span', () => {
56+
const initialIsolationScope = getIsolationScope();
57+
initialIsolationScope.setTag('aa', 'aa');
58+
59+
const customScope = new Scope();
60+
61+
startSpan(
62+
{
63+
name: 'other',
64+
attributes: { 'next.route': 'aha' },
65+
},
66+
span => {
67+
setCapturedScopesOnSpan(span, getCurrentScope(), customScope);
68+
69+
withIsolationScopeOrReuseFromRootSpan(isolationScope => {
70+
isolationScope.setTag('bb', 'bb');
71+
expect(isolationScope).toBe(customScope);
72+
expect(isolationScope.getScopeData().tags).toEqual({ bb: 'bb' });
73+
});
74+
},
75+
);
76+
});
77+
78+
it('works with a next.js span that has default isolation scope', () => {
79+
const initialIsolationScope = getIsolationScope();
80+
initialIsolationScope.setTag('aa', 'aa');
81+
82+
startSpan(
83+
{
84+
name: 'other',
85+
attributes: { 'next.route': 'aha' },
86+
},
87+
() => {
88+
withIsolationScopeOrReuseFromRootSpan(isolationScope => {
89+
isolationScope.setTag('bb', 'bb');
90+
expect(isolationScope).not.toBe(initialIsolationScope);
91+
expect(isolationScope.getScopeData().tags).toEqual({ aa: 'aa', bb: 'bb' });
92+
});
93+
},
94+
);
95+
});
96+
});

0 commit comments

Comments
 (0)