Skip to content

Commit 4676ca3

Browse files
s1gr1dlforstmydea
authored
fix(nextjs): Remove Http integration from Next.js (#11304)
Next.js provides their own OTel http integration, which conflicts with ours ref #11016 added commit from this PR: #11319 --------- Co-authored-by: Luca Forstner <[email protected]> Co-authored-by: Francesco Novy <[email protected]>
1 parent 76a2869 commit 4676ca3

File tree

7 files changed

+147
-70
lines changed

7 files changed

+147
-70
lines changed

dev-packages/e2e-tests/test-applications/nextjs-14/tests/request-instrumentation.test.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,15 @@ test('Should send a transaction with a fetch span', async ({ page }) => {
1919
}),
2020
);
2121

22-
// TODO: Uncomment the below when fixed. For whatever reason that we now have accepted, spans created with Node.js' http.get() will not attach themselves to transactions.
23-
// More info: https://github.com/getsentry/sentry-javascript/pull/11016/files#diff-10fa195789425786c6e5e769380be18790768f0b76319ee41bbb488d9fe50405R4
24-
// expect((await transactionPromise).spans).toContainEqual(
25-
// expect.objectContaining({
26-
// data: expect.objectContaining({
27-
// 'http.method': 'GET',
28-
// 'sentry.op': 'http.client',
29-
// 'sentry.origin': 'auto.http.otel.http',
30-
// }),
31-
// description: 'GET http://example.com/',
32-
// }),
33-
// );
22+
expect((await transactionPromise).spans).toContainEqual(
23+
expect.objectContaining({
24+
data: expect.objectContaining({
25+
'http.method': 'GET',
26+
'sentry.op': 'http.client',
27+
// todo: without the HTTP integration in the Next.js SDK, this is set to 'manual' -> we could rename this to be more specific
28+
'sentry.origin': 'manual',
29+
}),
30+
description: 'GET http://example.com/',
31+
}),
32+
);
3433
});

dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ test('Should record exceptions and transactions for faulty route handlers', asyn
5656

5757
expect(routehandlerTransaction.contexts?.trace?.status).toBe('unknown_error');
5858
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
59+
expect(routehandlerTransaction.contexts?.trace?.origin).toBe('auto.function.nextjs');
5960

6061
expect(routehandlerError.exception?.values?.[0].value).toBe('route-handler-error');
6162
// TODO: Uncomment once we update the scope transaction name on the server side

packages/nextjs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,14 @@
6565
"access": "public"
6666
},
6767
"dependencies": {
68+
"@opentelemetry/api": "1.7.0",
6869
"@rollup/plugin-commonjs": "24.0.0",
6970
"@sentry/core": "8.0.0-alpha.9",
7071
"@sentry/node": "8.0.0-alpha.9",
7172
"@sentry/react": "8.0.0-alpha.9",
7273
"@sentry/types": "8.0.0-alpha.9",
7374
"@sentry/utils": "8.0.0-alpha.9",
75+
"@sentry/opentelemetry": "8.0.0-alpha.9",
7476
"@sentry/vercel-edge": "8.0.0-alpha.9",
7577
"@sentry/webpack-plugin": "2.16.0",
7678
"chalk": "3.0.0",

packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts

Lines changed: 74 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,58 @@
11
import {
2+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
23
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
34
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
45
SPAN_STATUS_ERROR,
56
addTracingExtensions,
67
captureException,
7-
continueTrace,
8+
getActiveSpan,
9+
getRootSpan,
810
handleCallbackErrors,
911
setHttpStatus,
1012
startSpan,
1113
} from '@sentry/core';
14+
import type { Span } from '@sentry/types';
1215
import { winterCGHeadersToDict } from '@sentry/utils';
13-
1416
import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils';
1517
import type { RouteHandlerContext } from './types';
1618
import { platformSupportsStreaming } from './utils/platformSupportsStreaming';
1719
import { flushQueue } from './utils/responseEnd';
1820
import { withIsolationScopeOrReuseFromRootSpan } from './utils/withIsolationScopeOrReuseFromRootSpan';
1921

22+
/** As our own HTTP integration is disabled (src/server/index.ts) the rootSpan comes from Next.js.
23+
* In case there is no root span, we start a new span. */
24+
function startOrUpdateSpan(spanName: string, cb: (rootSpan: Span) => Promise<Response>): Promise<Response> {
25+
const activeSpan = getActiveSpan();
26+
const rootSpan = activeSpan && getRootSpan(activeSpan);
27+
28+
if (rootSpan) {
29+
rootSpan.updateName(spanName);
30+
rootSpan.setAttributes({
31+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
32+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
33+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs',
34+
});
35+
36+
return cb(rootSpan);
37+
} else {
38+
return startSpan(
39+
{
40+
op: 'http.server',
41+
name: spanName,
42+
forceTransaction: true,
43+
attributes: {
44+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
45+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
46+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs',
47+
},
48+
},
49+
(span: Span) => {
50+
return cb(span);
51+
},
52+
);
53+
}
54+
}
55+
2056
/**
2157
* Wraps a Next.js route handler with performance and error instrumentation.
2258
*/
@@ -26,7 +62,9 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
2662
context: RouteHandlerContext,
2763
): (...args: Parameters<F>) => ReturnType<F> extends Promise<unknown> ? ReturnType<F> : Promise<ReturnType<F>> {
2864
addTracingExtensions();
65+
2966
const { method, parameterizedRoute, headers } = context;
67+
3068
return new Proxy(routeHandler, {
3169
apply: (originalFunction, thisArg, args) => {
3270
return withIsolationScopeOrReuseFromRootSpan(async isolationScope => {
@@ -35,63 +73,44 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
3573
headers: headers ? winterCGHeadersToDict(headers) : undefined,
3674
},
3775
});
38-
return continueTrace(
39-
{
40-
// TODO(v8): Make it so that continue trace will allow null as sentryTrace value and remove this fallback here
41-
sentryTrace: headers?.get('sentry-trace') ?? undefined,
42-
baggage: headers?.get('baggage'),
43-
},
44-
async () => {
45-
try {
46-
return await startSpan(
47-
{
48-
op: 'http.server',
49-
name: `${method} ${parameterizedRoute}`,
50-
forceTransaction: true,
51-
attributes: {
52-
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
53-
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.function.nextjs',
54-
},
55-
},
56-
async span => {
57-
const response: Response = await handleCallbackErrors(
58-
() => originalFunction.apply(thisArg, args),
59-
error => {
60-
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
61-
if (isRedirectNavigationError(error)) {
62-
// Don't do anything
63-
} else if (isNotFoundNavigationError(error)) {
64-
span.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
65-
} else {
66-
captureException(error, {
67-
mechanism: {
68-
handled: false,
69-
},
70-
});
71-
}
72-
},
73-
);
7476

75-
try {
76-
if (span && response.status) {
77-
setHttpStatus(span, response.status);
78-
}
79-
} catch {
80-
// best effort - response may be undefined?
81-
}
77+
try {
78+
return await startOrUpdateSpan(`${method} ${parameterizedRoute}`, async (rootSpan: Span) => {
79+
const response: Response = await handleCallbackErrors(
80+
() => originalFunction.apply(thisArg, args),
81+
error => {
82+
// Next.js throws errors when calling `redirect()`. We don't wanna report these.
83+
if (isRedirectNavigationError(error)) {
84+
// Don't do anything
85+
} else if (isNotFoundNavigationError(error) && rootSpan) {
86+
rootSpan.setStatus({ code: SPAN_STATUS_ERROR, message: 'not_found' });
87+
} else {
88+
captureException(error, {
89+
mechanism: {
90+
handled: false,
91+
},
92+
});
93+
}
94+
},
95+
);
8296

83-
return response;
84-
},
85-
);
86-
} finally {
87-
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') {
88-
// 1. Edge transport requires manual flushing
89-
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent
90-
await flushQueue();
97+
try {
98+
if (rootSpan && response.status) {
99+
setHttpStatus(rootSpan, response.status);
91100
}
101+
} catch {
102+
// best effort - response may be undefined?
92103
}
93-
},
94-
);
104+
105+
return response;
106+
});
107+
} finally {
108+
if (!platformSupportsStreaming() || process.env.NEXT_RUNTIME === 'edge') {
109+
// 1. Edge transport requires manual flushing
110+
// 2. Lambdas require manual flushing to prevent execution freeze before the event is sent
111+
await flushQueue();
112+
}
113+
}
95114
});
96115
},
97116
});

packages/nextjs/src/server/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { onUncaughtExceptionIntegration } from './onUncaughtExceptionIntegration
1212

1313
export * from '@sentry/node';
1414
import type { EventProcessor } from '@sentry/types';
15+
import { requestIsolationScopeIntegration } from './requestIsolationScopeIntegration';
1516

1617
export { captureUnderscoreErrorException } from '../common/_error';
1718
export { onUncaughtExceptionIntegration } from './onUncaughtExceptionIntegration';
@@ -75,10 +76,13 @@ export function init(options: NodeOptions): void {
7576
...getDefaultIntegrations(options).filter(
7677
integration =>
7778
integration.name !== 'OnUncaughtException' &&
78-
// Next.js comes with its own Node-Fetch instrumentation so we shouldn't add ours on-top
79-
integration.name !== 'NodeFetch',
79+
// Next.js comes with its own Node-Fetch instrumentation, so we shouldn't add ours on-top
80+
integration.name !== 'NodeFetch' &&
81+
// Next.js comes with its own Http instrumentation for OTel which lead to double spans for route handler requests
82+
integration.name !== 'Http',
8083
),
8184
onUncaughtExceptionIntegration(),
85+
requestIsolationScopeIntegration(),
8286
];
8387

8488
// This value is injected at build time, based on the output directory specified in the build config. Though a default
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { SpanKind } from '@opentelemetry/api';
2+
import {
3+
defineIntegration,
4+
getCapturedScopesOnSpan,
5+
getCurrentScope,
6+
getIsolationScope,
7+
getRootSpan,
8+
setCapturedScopesOnSpan,
9+
spanToJSON,
10+
} from '@sentry/core';
11+
import { getSpanKind } from '@sentry/opentelemetry';
12+
13+
/**
14+
* This integration is responsible for creating isolation scopes for incoming Http requests.
15+
* We do so by waiting for http spans to be created and then forking the isolation scope.
16+
*
17+
* Normally the isolation scopes would be created by our Http instrumentation, however Next.js brings it's own Http
18+
* instrumentation so we had to disable ours.
19+
*/
20+
export const requestIsolationScopeIntegration = defineIntegration(() => {
21+
return {
22+
name: 'RequestIsolationScope',
23+
setup(client) {
24+
client.on('spanStart', span => {
25+
const spanJson = spanToJSON(span);
26+
const data = spanJson.data || {};
27+
28+
// The following check is a heuristic to determine whether the started span is a span that tracks an incoming HTTP request
29+
if (
30+
(getSpanKind(span) === SpanKind.SERVER && data['http.method']) ||
31+
(span === getRootSpan(span) && data['next.route'])
32+
) {
33+
const scopes = getCapturedScopesOnSpan(span);
34+
35+
// Update the isolation scope, isolate this request
36+
const isolationScope = (scopes.isolationScope || getIsolationScope()).clone();
37+
const scope = scopes.scope || getCurrentScope();
38+
39+
setCapturedScopesOnSpan(span, scope, isolationScope);
40+
}
41+
});
42+
},
43+
};
44+
});

packages/opentelemetry/src/utils/mapStatus.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const canonicalGrpcErrorCodesMap: Record<string, SpanStatus['message']> = {
2626
'16': 'unauthenticated',
2727
} as const;
2828

29+
const isStatusErrorMessageValid = (message: string): boolean => {
30+
return Object.values(canonicalGrpcErrorCodesMap).includes(message as SpanStatus['message']);
31+
};
32+
2933
/**
3034
* Get a Sentry span status from an otel span.
3135
*/
@@ -39,7 +43,11 @@ export function mapStatus(span: AbstractSpan): SpanStatus {
3943
return { code: SPAN_STATUS_OK };
4044
// If the span is already marked as erroneous we return that exact status
4145
} else if (status.code === SpanStatusCode.ERROR) {
42-
return { code: SPAN_STATUS_ERROR, message: status.message };
46+
if (typeof status.message === 'undefined' || isStatusErrorMessageValid(status.message)) {
47+
return { code: SPAN_STATUS_ERROR, message: status.message };
48+
} else {
49+
return { code: SPAN_STATUS_ERROR, message: 'unknown_error' };
50+
}
4351
}
4452
}
4553

0 commit comments

Comments
 (0)