Skip to content

Commit facaae4

Browse files
authored
feat(node): Use @opentelemetry/instrumentation-undici for fetch tracing (#13485)
This PR migrates the `nativeNodeFetchIntegration` to use `@opentelemetry/instrumentation-undici` instead of `opentelemetry-instrumentation-fetch-node`. The instrumentation is still exported as `nativeNodeFetchIntegration` and is named `NodeFetch` to ensure backwards compatibility and the tests pass ~~without changes~~. Note: One `nextjs-14` e2e test did need a change due to the new/differing attribute names. It's worth noting that `@opentelemetry/instrumentation-undici` [uses different attributes](open-telemetry/opentelemetry-js-contrib#2417 (comment)) from the latest semantic convention version vs what we are using and what's used by `opentelemetry-instrumentation-fetch-node`. It looks like the [http instrumentation is migrating to these too](open-telemetry/opentelemetry-js#4940) so some of the changes in this PR will ensure that the http instrumentation continues to work after these updates.
1 parent 2815eb7 commit facaae4

File tree

14 files changed

+63
-169
lines changed

14 files changed

+63
-169
lines changed

.github/dependabot.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ updates:
2020
- dependency-name: "@sentry/esbuild-plugin"
2121
- dependency-name: "@opentelemetry/*"
2222
- dependency-name: "@prisma/instrumentation"
23-
- dependency-name: "opentelemetry-instrumentation-fetch-node"
2423
versioning-strategy: increase
2524
commit-message:
2625
prefix: feat

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ test('Should send a transaction with a fetch span', async ({ page }) => {
1515
expect(transactionEvent.spans).toContainEqual(
1616
expect.objectContaining({
1717
data: expect.objectContaining({
18-
'http.method': 'GET',
18+
'http.request.method': 'GET',
1919
'sentry.op': 'http.client',
2020
'sentry.origin': 'auto.http.otel.node_fetch',
2121
}),

dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/scenario.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ Sentry.init({
1111
});
1212

1313
async function run(): Promise<void> {
14-
// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
15-
await new Promise(resolve => setTimeout(resolve, 100));
1614
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
1715
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
1816
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());

dev-packages/node-integration-tests/suites/tracing/requests/fetch-unsampled/scenario.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ Sentry.init({
1313
async function run(): Promise<void> {
1414
// Wrap in span that is not sampled
1515
await Sentry.startSpan({ name: 'outer' }, async () => {
16-
// Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented
17-
await new Promise(resolve => setTimeout(resolve, 100));
1816
await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text());
1917
await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text());
2018
await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text());

packages/bun/src/integrations/bunserver.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
23
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
34
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
45
captureException,
@@ -65,7 +66,7 @@ function instrumentBunServeOptions(serveOptions: Parameters<typeof Bun.serve>[0]
6566
const parsedUrl = parseUrl(request.url);
6667
const attributes: SpanAttributes = {
6768
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve',
68-
'http.request.method': request.method || 'GET',
69+
[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET',
6970
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
7071
};
7172
if (parsedUrl.search) {

packages/cloudflare/src/request.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { ExecutionContext, IncomingRequestCfProperties } from '@cloudflare/workers-types';
22

33
import {
4+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
45
SEMANTIC_ATTRIBUTE_SENTRY_OP,
56
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
67
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
8+
SEMANTIC_ATTRIBUTE_URL_FULL,
79
captureException,
810
continueTrace,
911
flush,
@@ -45,8 +47,8 @@ export function wrapRequestHandler(
4547
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.cloudflare',
4648
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
4749
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server',
48-
['http.request.method']: request.method,
49-
['url.full']: request.url,
50+
[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method,
51+
[SEMANTIC_ATTRIBUTE_URL_FULL]: request.url,
5052
};
5153

5254
const contentLength = request.headers.get('content-length');

packages/core/src/semanticAttributes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,7 @@ export const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit';
4141
export const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key';
4242

4343
export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size';
44+
45+
/** TODO: Remove these once we update to latest semantic conventions */
46+
export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method';
47+
export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full';

packages/node/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"@opentelemetry/instrumentation-nestjs-core": "0.40.0",
8787
"@opentelemetry/instrumentation-pg": "0.44.0",
8888
"@opentelemetry/instrumentation-redis-4": "0.42.0",
89+
"@opentelemetry/instrumentation-undici": "0.5.0",
8990
"@opentelemetry/resources": "^1.25.1",
9091
"@opentelemetry/sdk-trace-base": "^1.25.1",
9192
"@opentelemetry/semantic-conventions": "^1.25.1",
@@ -99,9 +100,6 @@
99100
"devDependencies": {
100101
"@types/node": "^14.18.0"
101102
},
102-
"optionalDependencies": {
103-
"opentelemetry-instrumentation-fetch-node": "1.2.3"
104-
},
105103
"scripts": {
106104
"build": "run-p build:transpile build:types",
107105
"build:dev": "yarn build",

packages/node/src/integrations/node-fetch.ts

Lines changed: 25 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,9 @@
1-
import type { Span } from '@opentelemetry/api';
2-
import { trace } from '@opentelemetry/api';
3-
import { context, propagation } from '@opentelemetry/api';
4-
import { addBreadcrumb, defineIntegration, getCurrentScope, hasTracingEnabled } from '@sentry/core';
5-
import {
6-
addOpenTelemetryInstrumentation,
7-
generateSpanContextForPropagationContext,
8-
getPropagationContextFromSpan,
9-
} from '@sentry/opentelemetry';
1+
import type { UndiciRequest, UndiciResponse } from '@opentelemetry/instrumentation-undici';
2+
import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici';
3+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, addBreadcrumb, defineIntegration } from '@sentry/core';
4+
import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry';
105
import type { IntegrationFn, SanitizedRequestData } from '@sentry/types';
11-
import { getSanitizedUrlString, logger, parseUrl } from '@sentry/utils';
12-
import { DEBUG_BUILD } from '../debug-build';
13-
import { NODE_MAJOR } from '../nodeVersion';
14-
15-
import type { FetchInstrumentation } from 'opentelemetry-instrumentation-fetch-node';
16-
17-
import { addOriginToSpan } from '../utils/addOriginToSpan';
18-
19-
interface FetchRequest {
20-
method: string;
21-
origin: string;
22-
path: string;
23-
headers: string | string[];
24-
}
25-
26-
interface FetchResponse {
27-
headers: Buffer[];
28-
statusCode: number;
29-
}
6+
import { getSanitizedUrlString, parseUrl } from '@sentry/utils';
307

318
interface NodeFetchOptions {
329
/**
@@ -46,106 +23,38 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => {
4623
const _breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs;
4724
const _ignoreOutgoingRequests = options.ignoreOutgoingRequests;
4825

49-
async function getInstrumentation(): Promise<FetchInstrumentation | void> {
50-
// Only add NodeFetch if Node >= 18, as previous versions do not support it
51-
if (NODE_MAJOR < 18) {
52-
DEBUG_BUILD && logger.log('NodeFetch is not supported on Node < 18, skipping instrumentation...');
53-
return;
54-
}
55-
56-
try {
57-
const pkg = await import('opentelemetry-instrumentation-fetch-node');
58-
const { FetchInstrumentation } = pkg;
59-
60-
class SentryNodeFetchInstrumentation extends FetchInstrumentation {
61-
// We extend this method so we have access to request _and_ response for the breadcrumb
62-
public onHeaders({ request, response }: { request: FetchRequest; response: FetchResponse }): void {
63-
if (_breadcrumbs) {
64-
_addRequestBreadcrumb(request, response);
65-
}
66-
67-
return super.onHeaders({ request, response });
68-
}
69-
}
70-
71-
return new SentryNodeFetchInstrumentation({
72-
ignoreRequestHook: (request: FetchRequest) => {
26+
return {
27+
name: 'NodeFetch',
28+
setupOnce() {
29+
const instrumentation = new UndiciInstrumentation({
30+
requireParentforSpans: false,
31+
ignoreRequestHook: request => {
7332
const url = getAbsoluteUrl(request.origin, request.path);
74-
const tracingDisabled = !hasTracingEnabled();
7533
const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url);
7634

77-
if (shouldIgnore) {
78-
return true;
79-
}
80-
81-
// If tracing is disabled, we still want to propagate traces
82-
// So we do that manually here, matching what the instrumentation does otherwise
83-
if (tracingDisabled) {
84-
const ctx = context.active();
85-
const addedHeaders: Record<string, string> = {};
86-
87-
// We generate a virtual span context from the active one,
88-
// Where we attach the URL to the trace state, so the propagator can pick it up
89-
const activeSpan = trace.getSpan(ctx);
90-
const propagationContext = activeSpan
91-
? getPropagationContextFromSpan(activeSpan)
92-
: getCurrentScope().getPropagationContext();
93-
94-
const spanContext = generateSpanContextForPropagationContext(propagationContext);
95-
// We know that in practice we'll _always_ haven a traceState here
96-
spanContext.traceState = spanContext.traceState?.set('sentry.url', url);
97-
const ctxWithUrlTraceState = trace.setSpanContext(ctx, spanContext);
98-
99-
propagation.inject(ctxWithUrlTraceState, addedHeaders);
100-
101-
const requestHeaders = request.headers;
102-
if (Array.isArray(requestHeaders)) {
103-
Object.entries(addedHeaders).forEach(headers => requestHeaders.push(...headers));
104-
} else {
105-
request.headers += Object.entries(addedHeaders)
106-
.map(([k, v]) => `${k}: ${v}\r\n`)
107-
.join('');
108-
}
109-
110-
// Prevent starting a span for this request
111-
return true;
112-
}
113-
114-
return false;
35+
return !!shouldIgnore;
11536
},
116-
onRequest: ({ span }: { span: Span }) => {
117-
_updateSpan(span);
37+
startSpanHook: () => {
38+
return {
39+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.node_fetch',
40+
};
41+
},
42+
responseHook: (_, { request, response }) => {
43+
if (_breadcrumbs) {
44+
addRequestBreadcrumb(request, response);
45+
}
11846
},
119-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
120-
} as any);
121-
} catch (error) {
122-
// Could not load instrumentation
123-
DEBUG_BUILD && logger.log('Error while loading NodeFetch instrumentation: \n', error);
124-
}
125-
}
126-
127-
return {
128-
name: 'NodeFetch',
129-
setupOnce() {
130-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
131-
getInstrumentation().then(instrumentation => {
132-
if (instrumentation) {
133-
addOpenTelemetryInstrumentation(instrumentation);
134-
}
13547
});
48+
49+
addOpenTelemetryInstrumentation(instrumentation);
13650
},
13751
};
13852
}) satisfies IntegrationFn;
13953

14054
export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchIntegration);
14155

142-
/** Update the span with data we need. */
143-
function _updateSpan(span: Span): void {
144-
addOriginToSpan(span, 'auto.http.otel.node_fetch');
145-
}
146-
14756
/** Add a breadcrumb for outgoing requests. */
148-
function _addRequestBreadcrumb(request: FetchRequest, response: FetchResponse): void {
57+
function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void {
14958
const data = getBreadcrumbData(request);
15059

15160
addBreadcrumb(
@@ -165,7 +74,7 @@ function _addRequestBreadcrumb(request: FetchRequest, response: FetchResponse):
16574
);
16675
}
16776

168-
function getBreadcrumbData(request: FetchRequest): Partial<SanitizedRequestData> {
77+
function getBreadcrumbData(request: UndiciRequest): Partial<SanitizedRequestData> {
16978
try {
17079
const url = new URL(request.path, request.origin);
17180
const parsedUrl = parseUrl(url.toString());

packages/opentelemetry/src/propagator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { propagation, trace } from '@opentelemetry/api';
55
import { W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core';
66
import { SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions';
77
import type { continueTrace } from '@sentry/core';
8+
import { SEMANTIC_ATTRIBUTE_URL_FULL } from '@sentry/core';
89
import { hasTracingEnabled } from '@sentry/core';
910
import { getRootSpan } from '@sentry/core';
1011
import { spanToJSON } from '@sentry/core';
@@ -292,7 +293,8 @@ function getExistingBaggage(carrier: unknown): string | undefined {
292293
* 2. Else, if the active span has no URL attribute (e.g. it is unsampled), we check a special trace state (which we set in our sampler).
293294
*/
294295
function getCurrentURL(span: Span): string | undefined {
295-
const urlAttribute = spanToJSON(span).data?.[SEMATTRS_HTTP_URL];
296+
const spanData = spanToJSON(span).data;
297+
const urlAttribute = spanData?.[SEMATTRS_HTTP_URL] || spanData?.[SEMANTIC_ATTRIBUTE_URL_FULL];
296298
if (urlAttribute) {
297299
return urlAttribute;
298300
}

packages/opentelemetry/src/sampler.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { TraceState } from '@opentelemetry/core';
55
import type { Sampler, SamplingResult } from '@opentelemetry/sdk-trace-base';
66
import { SamplingDecision } from '@opentelemetry/sdk-trace-base';
77
import {
8+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
89
SEMANTIC_ATTRIBUTE_SENTRY_OP,
910
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
11+
SEMANTIC_ATTRIBUTE_URL_FULL,
1012
hasTracingEnabled,
1113
sampleSpan,
1214
} from '@sentry/core';
@@ -54,7 +56,7 @@ export class SentrySampler implements Sampler {
5456
// but we want to leave downstream sampling decisions up to the server
5557
if (
5658
spanKind === SpanKind.CLIENT &&
57-
spanAttributes[SEMATTRS_HTTP_METHOD] &&
59+
(spanAttributes[SEMATTRS_HTTP_METHOD] || spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]) &&
5860
(!parentSpan || parentContext?.isRemote)
5961
) {
6062
return wrapSamplingDecision({ decision: undefined, context, spanAttributes });
@@ -196,7 +198,7 @@ function getBaseTraceState(context: Context, spanAttributes: SpanAttributes): Tr
196198
let traceState = parentContext?.traceState || new TraceState();
197199

198200
// We always keep the URL on the trace state, so we can access it in the propagator
199-
const url = spanAttributes[SEMATTRS_HTTP_URL];
201+
const url = spanAttributes[SEMATTRS_HTTP_URL] || spanAttributes[SEMANTIC_ATTRIBUTE_URL_FULL];
200202
if (url && typeof url === 'string') {
201203
traceState = traceState.set(SENTRY_TRACE_STATE_URL, url);
202204
}

packages/opentelemetry/src/utils/isSentryRequest.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions';
2-
import { getClient, isSentryRequestUrl } from '@sentry/core';
2+
import { SEMANTIC_ATTRIBUTE_URL_FULL, getClient, isSentryRequestUrl } from '@sentry/core';
33

44
import type { AbstractSpan } from '../types';
55
import { spanHasAttributes } from './spanTypes';
@@ -16,7 +16,7 @@ export function isSentryRequestSpan(span: AbstractSpan): boolean {
1616

1717
const { attributes } = span;
1818

19-
const httpUrl = attributes[SEMATTRS_HTTP_URL];
19+
const httpUrl = attributes[SEMATTRS_HTTP_URL] || attributes[SEMANTIC_ATTRIBUTE_URL_FULL];
2020

2121
if (!httpUrl) {
2222
return false;

packages/opentelemetry/src/utils/parseSpanDescription.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import {
1414
import type { SpanAttributes, TransactionSource } from '@sentry/types';
1515
import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '@sentry/utils';
1616

17-
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core';
17+
import {
18+
SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD,
19+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
20+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
21+
SEMANTIC_ATTRIBUTE_URL_FULL,
22+
} from '@sentry/core';
1823
import { SEMANTIC_ATTRIBUTE_SENTRY_GRAPHQL_OPERATION } from '../semanticAttributes';
1924
import type { AbstractSpan } from '../types';
2025
import { getSpanKind } from './getSpanKind';
@@ -45,10 +50,7 @@ export function inferSpanData(name: string, attributes: SpanAttributes, kind: Sp
4550
}
4651

4752
// if http.method exists, this is an http request span
48-
//
49-
// TODO: Referencing `http.request.method` is a temporary workaround until the semantic
50-
// conventions export an attribute key for it.
51-
const httpMethod = attributes['http.request.method'] || attributes[SEMATTRS_HTTP_METHOD];
53+
const httpMethod = attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || attributes[SEMATTRS_HTTP_METHOD];
5254
if (httpMethod) {
5355
return descriptionForHttpMethod({ attributes, name, kind }, httpMethod);
5456
}
@@ -213,7 +215,7 @@ export function getSanitizedUrl(
213215
// This is the relative path of the URL, e.g. /sub
214216
const httpTarget = attributes[SEMATTRS_HTTP_TARGET];
215217
// This is the full URL, including host & query params etc., e.g. https://example.com/sub?foo=bar
216-
const httpUrl = attributes[SEMATTRS_HTTP_URL];
218+
const httpUrl = attributes[SEMATTRS_HTTP_URL] || attributes[SEMANTIC_ATTRIBUTE_URL_FULL];
217219
// This is the normalized route name - may not always be available!
218220
const httpRoute = attributes[SEMATTRS_HTTP_ROUTE];
219221

0 commit comments

Comments
 (0)