Skip to content

Commit c9d1c50

Browse files
committed
feat(node-experimental): Add NodeFetch integration
1 parent 49572bd commit c9d1c50

File tree

10 files changed

+331
-15
lines changed

10 files changed

+331
-15
lines changed

packages/e2e-tests/test-applications/node-experimental-fastify-app/src/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ app.get('/test-outgoing-http', async function (req, res) {
3838
res.send(data);
3939
});
4040

41+
app.get('/test-outgoing-fetch', async function (req, res) {
42+
const response = await fetch('http://localhost:3030/test-inbound-headers');
43+
const data = await response.json();
44+
45+
res.send(data);
46+
});
47+
4148
app.get('/test-transaction', async function (req, res) {
4249
Sentry.startSpan({ name: 'test-span' }, () => {
4350
Sentry.startSpan({ name: 'child-span' }, () => {});

packages/e2e-tests/test-applications/node-experimental-fastify-app/tests/propagation.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,94 @@ test('Propagates trace for outgoing http requests', async ({ baseURL }) => {
9898
}),
9999
);
100100
});
101+
102+
test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => {
103+
const inboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
104+
return (
105+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
106+
transactionEvent?.transaction === 'GET /test-inbound-headers'
107+
);
108+
});
109+
110+
const outboundTransactionPromise = waitForTransaction('node-experimental-fastify-app', transactionEvent => {
111+
return (
112+
transactionEvent?.contexts?.trace?.op === 'http.server' &&
113+
transactionEvent?.transaction === 'GET /test-outgoing-fetch'
114+
);
115+
});
116+
117+
const { data } = await axios.get(`${baseURL}/test-outgoing-fetch`);
118+
119+
const inboundTransaction = await inboundTransactionPromise;
120+
const outboundTransaction = await outboundTransactionPromise;
121+
122+
const traceId = outboundTransaction?.contexts?.trace?.trace_id;
123+
const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as
124+
| ReturnType<Span['toJSON']>
125+
| undefined;
126+
127+
expect(outgoingHttpSpan).toBeDefined();
128+
129+
const outgoingHttpSpanId = outgoingHttpSpan?.span_id;
130+
131+
expect(traceId).toEqual(expect.any(String));
132+
133+
// data is passed through from the inbound request, to verify we have the correct headers set
134+
const inboundHeaderSentryTrace = data.headers?.['sentry-trace'];
135+
const inboundHeaderBaggage = data.headers?.['baggage'];
136+
137+
expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`);
138+
expect(inboundHeaderBaggage).toBeDefined();
139+
140+
const baggage = (inboundHeaderBaggage || '').split(',');
141+
expect(baggage).toEqual(
142+
expect.arrayContaining([
143+
'sentry-environment=qa',
144+
`sentry-trace_id=${traceId}`,
145+
expect.stringMatching(/sentry-public_key=/),
146+
]),
147+
);
148+
149+
expect(outboundTransaction).toEqual(
150+
expect.objectContaining({
151+
contexts: expect.objectContaining({
152+
trace: {
153+
data: {
154+
url: 'http://localhost:3030/test-outgoing-fetch',
155+
'otel.kind': 'SERVER',
156+
'http.response.status_code': 200,
157+
},
158+
op: 'http.server',
159+
span_id: expect.any(String),
160+
status: 'ok',
161+
tags: {
162+
'http.status_code': 200,
163+
},
164+
trace_id: traceId,
165+
},
166+
}),
167+
}),
168+
);
169+
170+
expect(inboundTransaction).toEqual(
171+
expect.objectContaining({
172+
contexts: expect.objectContaining({
173+
trace: {
174+
data: {
175+
url: 'http://localhost:3030/test-inbound-headers',
176+
'otel.kind': 'SERVER',
177+
'http.response.status_code': 200,
178+
},
179+
op: 'http.server',
180+
parent_span_id: outgoingHttpSpanId,
181+
span_id: expect.any(String),
182+
status: 'ok',
183+
tags: {
184+
'http.status_code': 200,
185+
},
186+
trace_id: traceId,
187+
},
188+
}),
189+
}),
190+
);
191+
});

packages/node-experimental/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"@sentry/node": "7.73.0",
4646
"@sentry/opentelemetry-node": "7.73.0",
4747
"@sentry/types": "7.73.0",
48-
"@sentry/utils": "7.73.0"
48+
"@sentry/utils": "7.73.0",
49+
"opentelemetry-instrumentation-fetch-node": "~1.1.0"
4950
},
5051
"scripts": {
5152
"build": "run-p build:transpile build:types",

packages/node-experimental/src/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export {
2626

2727
export { Express } from './express';
2828
export { Http } from './http';
29+
export { NodeFetch } from './node-fetch';
2930
export { Fastify } from './fastify';
3031
export { GraphQL } from './graphql';
3132
export { Mongo } from './mongo';
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { Span } from '@opentelemetry/api';
2+
import { SpanKind } from '@opentelemetry/api';
3+
import { registerInstrumentations } from '@opentelemetry/instrumentation';
4+
import { hasTracingEnabled } from '@sentry/core';
5+
import type { EventProcessor, Hub, Integration } from '@sentry/types';
6+
import { FetchInstrumentation } from 'opentelemetry-instrumentation-fetch-node';
7+
8+
import { OTEL_ATTR_ORIGIN } from '../constants';
9+
import type { NodeExperimentalClient } from '../sdk/client';
10+
import { getCurrentHub } from '../sdk/hub';
11+
import { getRequestSpanData } from '../utils/getRequestSpanData';
12+
import { getSpanKind } from '../utils/getSpanKind';
13+
14+
interface NodeFetchOptions {
15+
/**
16+
* Whether breadcrumbs should be recorded for requests
17+
* Defaults to true
18+
*/
19+
breadcrumbs?: boolean;
20+
21+
/**
22+
* Whether tracing spans should be created for requests
23+
* Defaults to false
24+
*/
25+
spans?: boolean;
26+
}
27+
28+
/**
29+
* Fetch instrumentation based on opentelemetry-instrumentation-fetch.
30+
* This instrumentation does two things:
31+
* * Create breadcrumbs for outgoing requests
32+
* * Create spans for outgoing requests
33+
*/
34+
export class NodeFetch implements Integration {
35+
/**
36+
* @inheritDoc
37+
*/
38+
public static id: string = 'NodeFetch';
39+
40+
/**
41+
* @inheritDoc
42+
*/
43+
public name: string;
44+
45+
/**
46+
* If spans for HTTP requests should be captured.
47+
*/
48+
public shouldCreateSpansForRequests: boolean;
49+
50+
private _unload?: () => void;
51+
private readonly _breadcrumbs: boolean;
52+
// If this is undefined, use default behavior based on client settings
53+
private readonly _spans: boolean | undefined;
54+
55+
/**
56+
* @inheritDoc
57+
*/
58+
public constructor(options: NodeFetchOptions = {}) {
59+
this.name = NodeFetch.id;
60+
this._breadcrumbs = typeof options.breadcrumbs === 'undefined' ? true : options.breadcrumbs;
61+
this._spans = typeof options.spans === 'undefined' ? undefined : options.spans;
62+
63+
// Properly set in setupOnce based on client settings
64+
this.shouldCreateSpansForRequests = false;
65+
}
66+
67+
/**
68+
* @inheritDoc
69+
*/
70+
public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, _getCurrentHub: () => Hub): void {
71+
// No need to instrument if we don't want to track anything
72+
if (!this._breadcrumbs && this._spans === false) {
73+
return;
74+
}
75+
76+
const client = getCurrentHub().getClient<NodeExperimentalClient>();
77+
const clientOptions = client?.getOptions();
78+
79+
// This is used in the sampler function
80+
this.shouldCreateSpansForRequests =
81+
typeof this._spans === 'boolean' ? this._spans : hasTracingEnabled(clientOptions);
82+
83+
// Register instrumentations we care about
84+
this._unload = registerInstrumentations({
85+
instrumentations: [
86+
new FetchInstrumentation({
87+
onRequest: ({ span }: { span: Span }) => {
88+
this._updateSpan(span);
89+
this._addRequestBreadcrumb(span);
90+
},
91+
}),
92+
],
93+
});
94+
}
95+
96+
/**
97+
* Unregister this integration.
98+
*/
99+
public unregister(): void {
100+
this._unload?.();
101+
}
102+
103+
/** Update the span with data we need. */
104+
private _updateSpan(span: Span): void {
105+
span.setAttribute(OTEL_ATTR_ORIGIN, 'auto.http.otel.node_fetch');
106+
}
107+
108+
/** Add a breadcrumb for outgoing requests. */
109+
private _addRequestBreadcrumb(span: Span): void {
110+
if (!this._breadcrumbs || getSpanKind(span) !== SpanKind.CLIENT) {
111+
return;
112+
}
113+
114+
const data = getRequestSpanData(span);
115+
getCurrentHub().addBreadcrumb({
116+
category: 'http',
117+
data: {
118+
...data,
119+
},
120+
type: 'http',
121+
});
122+
}
123+
}

packages/node-experimental/src/opentelemetry/spanProcessor.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { logger } from '@sentry/utils';
99

1010
import { OTEL_CONTEXT_HUB_KEY } from '../constants';
1111
import { Http } from '../integrations';
12+
import { NodeFetch } from '../integrations/node-fetch';
1213
import type { NodeExperimentalClient } from '../sdk/client';
1314
import { getCurrentHub } from '../sdk/hub';
1415
import { getSpanHub, setSpanHub, setSpanParent, setSpanScope } from './spanData';
@@ -76,18 +77,22 @@ export class SentrySpanProcessor extends BatchSpanProcessor implements SpanProce
7677
function shouldCaptureSentrySpan(span: Span): boolean {
7778
const client = getCurrentHub().getClient<NodeExperimentalClient>();
7879
const httpIntegration = client ? client.getIntegration(Http) : undefined;
80+
const fetchIntegration = client ? client.getIntegration(NodeFetch) : undefined;
7981

8082
// If we encounter a client or server span with url & method, we assume this comes from the http instrumentation
8183
// In this case, if `shouldCreateSpansForRequests` is false, we want to _record_ the span but not _sample_ it,
8284
// So we can generate a breadcrumb for it but no span will be sent
8385
if (
84-
httpIntegration &&
8586
(span.kind === SpanKind.CLIENT || span.kind === SpanKind.SERVER) &&
8687
span.attributes[SemanticAttributes.HTTP_URL] &&
87-
span.attributes[SemanticAttributes.HTTP_METHOD] &&
88-
!httpIntegration.shouldCreateSpansForRequests
88+
span.attributes[SemanticAttributes.HTTP_METHOD]
8989
) {
90-
return false;
90+
const shouldCreateSpansForRequests =
91+
span.attributes['http.client'] === 'fetch'
92+
? fetchIntegration?.shouldCreateSpansForRequests
93+
: httpIntegration?.shouldCreateSpansForRequests;
94+
95+
return shouldCreateSpansForRequests !== false;
9196
}
9297

9398
return true;

packages/node-experimental/src/sdk/init.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,30 @@
11
import { hasTracingEnabled } from '@sentry/core';
22
import { defaultIntegrations as defaultNodeIntegrations, init as initNode } from '@sentry/node';
3+
import type { Integration } from '@sentry/types';
4+
import { parseSemver } from '@sentry/utils';
35

46
import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations';
57
import { Http } from '../integrations/http';
8+
import { NodeFetch } from '../integrations/node-fetch';
69
import type { NodeExperimentalOptions } from '../types';
710
import { NodeExperimentalClient } from './client';
811
import { getCurrentHub } from './hub';
912
import { initOtel } from './initOtel';
1013
import { setOtelContextAsyncContextStrategy } from './otelAsyncContextStrategy';
1114

15+
const NODE_VERSION: ReturnType<typeof parseSemver> = parseSemver(process.versions.node);
1216
const ignoredDefaultIntegrations = ['Http', 'Undici'];
1317

14-
export const defaultIntegrations = [
18+
export const defaultIntegrations: Integration[] = [
1519
...defaultNodeIntegrations.filter(i => !ignoredDefaultIntegrations.includes(i.name)),
1620
new Http(),
1721
];
1822

23+
// Only add NodeFetch if Node >= 16, as previous versions do not support it
24+
if (NODE_VERSION.major && NODE_VERSION.major >= 16) {
25+
defaultIntegrations.push(new NodeFetch());
26+
}
27+
1928
/**
2029
* Initialize Sentry for Node.
2130
*/

0 commit comments

Comments
 (0)