Skip to content

Commit 3a45a3c

Browse files
authored
fix(nextjs): Re-enable OTEL fetch instrumentation and disable Next.js fetch instrumentation (#11686)
1 parent c1d54ff commit 3a45a3c

File tree

15 files changed

+225
-9
lines changed

15 files changed

+225
-9
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { checkHandler } from '../../utils';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export const GET = checkHandler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export async function GET() {
6+
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch-external-disallowed/check`).then(
7+
res => res.json(),
8+
);
9+
return NextResponse.json(data);
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { checkHandler } from '../../utils';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export const GET = checkHandler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { NextResponse } from 'next/server';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export async function GET() {
6+
const data = await fetch(`http://localhost:3030/propagation/test-outgoing-fetch/check`).then(res => res.json());
7+
return NextResponse.json(data);
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { checkHandler } from '../../utils';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export const GET = checkHandler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server';
2+
import { makeHttpRequest } from '../utils';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
export async function GET() {
7+
const data = await makeHttpRequest(`http://localhost:3030/propagation/test-outgoing-http-external-disallowed/check`);
8+
return NextResponse.json(data);
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { checkHandler } from '../../utils';
2+
3+
export const dynamic = 'force-dynamic';
4+
5+
export const GET = checkHandler;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from 'next/server';
2+
import { makeHttpRequest } from '../utils';
3+
4+
export const dynamic = 'force-dynamic';
5+
6+
export async function GET() {
7+
const data = await makeHttpRequest(`http://localhost:3030/propagation/test-outgoing-http/check`);
8+
return NextResponse.json(data);
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import http from 'http';
2+
import { headers } from 'next/headers';
3+
import { NextResponse } from 'next/server';
4+
5+
export function makeHttpRequest(url: string) {
6+
return new Promise(resolve => {
7+
const data: any[] = [];
8+
http
9+
.request(url, httpRes => {
10+
httpRes.on('data', chunk => {
11+
data.push(chunk);
12+
});
13+
httpRes.on('error', error => {
14+
resolve({ error: error.message, url });
15+
});
16+
httpRes.on('end', () => {
17+
try {
18+
const json = JSON.parse(Buffer.concat(data).toString());
19+
resolve(json);
20+
} catch {
21+
resolve({ data: Buffer.concat(data).toString(), url });
22+
}
23+
});
24+
})
25+
.end();
26+
});
27+
}
28+
29+
export function checkHandler() {
30+
const headerList = headers();
31+
32+
const headerObj: Record<string, unknown> = {};
33+
headerList.forEach((value, key) => {
34+
headerObj[key] = value;
35+
});
36+
37+
return NextResponse.json({ headers: headerObj });
38+
}

dev-packages/e2e-tests/test-applications/nextjs-14/app/request-instrumentation/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import http from 'http';
33
export const dynamic = 'force-dynamic';
44

55
export default async function Page() {
6-
await fetch('http://example.com/', { cache: 'no-cache' });
6+
await fetch('http://example.com/', { cache: 'no-cache' }).then(res => res.text());
77
await new Promise<void>(resolve => {
88
http.get('http://example.com/', res => {
99
res.on('data', () => {

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

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function register() {
1212
// We are doing a lot of events at once in this test
1313
bufferSize: 1000,
1414
},
15+
tracePropagationTargets: [
16+
'http://localhost:3030/propagation/test-outgoing-fetch/check',
17+
'http://localhost:3030/propagation/test-outgoing-http/check',
18+
],
1519
});
1620
}
1721
}

dev-packages/e2e-tests/test-applications/nextjs-14/playwright.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const eventProxyPort = 3031;
2222
const config: PlaywrightTestConfig = {
2323
testDir: './tests',
2424
/* Maximum time one test can run for. */
25-
timeout: 150_000,
25+
timeout: 30_000,
2626
expect: {
2727
/**
2828
* Maximum time expect() should wait for the condition to be met.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/event-proxy-server';
3+
4+
test('Propagates trace for outgoing http requests', async ({ baseURL, request }) => {
5+
const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
6+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-http/check';
7+
});
8+
9+
const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
10+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-http';
11+
});
12+
13+
const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-http`)).json();
14+
15+
const inboundTransaction = await inboundTransactionPromise;
16+
const outboundTransaction = await outboundTransactionPromise;
17+
18+
expect(inboundTransaction.contexts?.trace?.trace_id).toStrictEqual(expect.any(String));
19+
expect(inboundTransaction.contexts?.trace?.trace_id).toBe(outboundTransaction.contexts?.trace?.trace_id);
20+
21+
const httpClientSpan = outboundTransaction.spans?.find(span => span.op === 'http.client');
22+
23+
expect(httpClientSpan).toBeDefined();
24+
expect(httpClientSpan?.span_id).toStrictEqual(expect.any(String));
25+
expect(inboundTransaction.contexts?.trace?.parent_span_id).toBe(httpClientSpan?.span_id);
26+
27+
expect(headers).toMatchObject({
28+
baggage: expect.any(String),
29+
'sentry-trace': `${outboundTransaction.contexts?.trace?.trace_id}-${httpClientSpan?.span_id}-1`,
30+
});
31+
});
32+
33+
test('Propagates trace for outgoing fetch requests', async ({ baseURL, request }) => {
34+
const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
35+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch/check';
36+
});
37+
38+
const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
39+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch';
40+
});
41+
42+
const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-fetch`)).json();
43+
44+
const inboundTransaction = await inboundTransactionPromise;
45+
const outboundTransaction = await outboundTransactionPromise;
46+
47+
expect(inboundTransaction.contexts?.trace?.trace_id).toStrictEqual(expect.any(String));
48+
expect(inboundTransaction.contexts?.trace?.trace_id).toBe(outboundTransaction.contexts?.trace?.trace_id);
49+
50+
const httpClientSpan = outboundTransaction.spans?.find(
51+
span => span.op === 'http.client' && span.data?.['sentry.origin'] === 'auto.http.otel.node_fetch',
52+
);
53+
54+
// Right now we assert that the OTEL span is the last span before propagating
55+
expect(httpClientSpan).toBeDefined();
56+
expect(httpClientSpan?.span_id).toStrictEqual(expect.any(String));
57+
expect(inboundTransaction.contexts?.trace?.parent_span_id).toBe(httpClientSpan?.span_id);
58+
59+
expect(headers).toMatchObject({
60+
baggage: expect.any(String),
61+
'sentry-trace': `${outboundTransaction.contexts?.trace?.trace_id}-${httpClientSpan?.span_id}-1`,
62+
});
63+
});
64+
65+
test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({
66+
baseURL,
67+
request,
68+
}) => {
69+
const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
70+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-http-external-disallowed/check';
71+
});
72+
73+
const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
74+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-http-external-disallowed';
75+
});
76+
77+
const { headers } = await (await request.get(`${baseURL}/propagation/test-outgoing-http-external-disallowed`)).json();
78+
79+
expect(headers.baggage).toBeUndefined();
80+
expect(headers['sentry-trace']).toBeUndefined();
81+
82+
const inboundTransaction = await inboundTransactionPromise;
83+
const outboundTransaction = await outboundTransactionPromise;
84+
85+
expect(typeof outboundTransaction.contexts?.trace?.trace_id).toBe('string');
86+
expect(inboundTransaction.contexts?.trace?.trace_id).not.toBe(outboundTransaction.contexts?.trace?.trace_id);
87+
});
88+
89+
test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({
90+
baseURL,
91+
request,
92+
}) => {
93+
const inboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
94+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed/check';
95+
});
96+
97+
const outboundTransactionPromise = waitForTransaction('nextjs-14', transactionEvent => {
98+
return transactionEvent.transaction === 'GET /propagation/test-outgoing-fetch-external-disallowed';
99+
});
100+
101+
const { headers } = await (
102+
await request.get(`${baseURL}/propagation/test-outgoing-fetch-external-disallowed`)
103+
).json();
104+
105+
expect(headers.baggage).toBeUndefined();
106+
expect(headers['sentry-trace']).toBeUndefined();
107+
108+
const inboundTransaction = await inboundTransactionPromise;
109+
const outboundTransaction = await outboundTransactionPromise;
110+
111+
expect(typeof outboundTransaction.contexts?.trace?.trace_id).toBe('string');
112+
expect(inboundTransaction.contexts?.trace?.trace_id).not.toBe(outboundTransaction.contexts?.trace?.trace_id);
113+
});

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

+8-5
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,27 @@ test('Should send a transaction with a fetch span', async ({ page }) => {
88

99
await page.goto(`/request-instrumentation`);
1010

11-
expect((await transactionPromise).spans).toContainEqual(
11+
await expect(transactionPromise).resolves.toBeDefined();
12+
13+
const transactionEvent = await transactionPromise;
14+
15+
expect(transactionEvent.spans).toContainEqual(
1216
expect.objectContaining({
1317
data: expect.objectContaining({
1418
'http.method': 'GET',
1519
'sentry.op': 'http.client',
16-
'next.span_type': 'AppRender.fetch', // This span is created by Next.js own fetch instrumentation
20+
'sentry.origin': 'auto.http.otel.node_fetch',
1721
}),
1822
description: 'GET http://example.com/',
1923
}),
2024
);
2125

22-
expect((await transactionPromise).spans).toContainEqual(
26+
expect(transactionEvent.spans).toContainEqual(
2327
expect.objectContaining({
2428
data: expect.objectContaining({
2529
'http.method': 'GET',
2630
'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',
31+
'sentry.origin': 'auto.http.otel.http',
2932
}),
3033
description: 'GET http://example.com/',
3134
}),

packages/nextjs/src/server/index.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,16 @@ export function init(options: NodeOptions): void {
7373
const customDefaultIntegrations = [
7474
...getDefaultIntegrations(options).filter(
7575
integration =>
76-
// Next.js comes with its own Node-Fetch instrumentation, so we shouldn't add ours on-top
77-
integration.name !== 'NodeFetch' &&
7876
// Next.js comes with its own Http instrumentation for OTel which would lead to double spans for route handler requests
7977
integration.name !== 'Http',
8078
),
8179
httpIntegration(),
8280
];
8381

82+
// Turn off Next.js' own fetch instrumentation
83+
// https://github.com/lforst/nextjs-fork/blob/1994fd186defda77ad971c36dc3163db263c993f/packages/next/src/server/lib/patch-fetch.ts#L245
84+
process.env.NEXT_OTEL_FETCH_DISABLED = '1';
85+
8486
// This value is injected at build time, based on the output directory specified in the build config. Though a default
8587
// is set there, we set it here as well, just in case something has gone wrong with the injection.
8688
const distDirName = globalWithInjectedValues.__rewriteFramesDistDir__;

0 commit comments

Comments
 (0)