Skip to content

Commit 46f996e

Browse files
authored
feat(node): Undici integration (#7582)
1 parent 6162fb8 commit 46f996e

File tree

3 files changed

+473
-0
lines changed

3 files changed

+473
-0
lines changed

packages/node/src/integrations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { ContextLines } from './contextlines';
88
export { Context } from './context';
99
export { RequestData } from './requestdata';
1010
export { LocalVariables } from './localvariables';
11+
export { Undici } from './undici';
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import type { Hub } from '@sentry/core';
2+
import type { EventProcessor, Integration } from '@sentry/types';
3+
import {
4+
dynamicRequire,
5+
dynamicSamplingContextToSentryBaggageHeader,
6+
parseSemver,
7+
stringMatchesSomePattern,
8+
stripUrlQueryAndFragment,
9+
} from '@sentry/utils';
10+
11+
import type { NodeClient } from '../../client';
12+
import { isSentryRequest } from '../utils/http';
13+
import type { DiagnosticsChannel, RequestCreateMessage, RequestEndMessage, RequestErrorMessage } from './types';
14+
15+
const NODE_VERSION = parseSemver(process.versions.node);
16+
17+
export enum ChannelName {
18+
// https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md#undicirequestcreate
19+
RequestCreate = 'undici:request:create',
20+
RequestEnd = 'undici:request:headers',
21+
RequestError = 'undici:request:error',
22+
}
23+
24+
export interface UndiciOptions {
25+
/**
26+
* Whether breadcrumbs should be recorded for requests
27+
* Defaults to true
28+
*/
29+
breadcrumbs: boolean;
30+
}
31+
32+
const DEFAULT_UNDICI_OPTIONS: UndiciOptions = {
33+
breadcrumbs: true,
34+
};
35+
36+
/**
37+
* Instruments outgoing HTTP requests made with the `undici` package via
38+
* Node's `diagnostics_channel` API.
39+
*
40+
* Supports Undici 4.7.0 or higher.
41+
*
42+
* Requires Node 16.17.0 or higher.
43+
*/
44+
export class Undici implements Integration {
45+
/**
46+
* @inheritDoc
47+
*/
48+
public static id: string = 'Undici';
49+
50+
/**
51+
* @inheritDoc
52+
*/
53+
public name: string = Undici.id;
54+
55+
private readonly _options: UndiciOptions;
56+
57+
public constructor(_options: Partial<UndiciOptions> = {}) {
58+
this._options = {
59+
...DEFAULT_UNDICI_OPTIONS,
60+
..._options,
61+
};
62+
}
63+
64+
/**
65+
* @inheritDoc
66+
*/
67+
public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
68+
// Requires Node 16+ to use the diagnostics_channel API.
69+
if (NODE_VERSION.major && NODE_VERSION.major < 16) {
70+
return;
71+
}
72+
73+
let ds: DiagnosticsChannel | undefined;
74+
try {
75+
// eslint-disable-next-line @typescript-eslint/no-var-requires
76+
ds = dynamicRequire(module, 'diagnostics_channel') as DiagnosticsChannel;
77+
} catch (e) {
78+
// no-op
79+
}
80+
81+
if (!ds || !ds.subscribe) {
82+
return;
83+
}
84+
85+
// https://github.com/nodejs/undici/blob/e6fc80f809d1217814c044f52ed40ef13f21e43c/docs/api/DiagnosticsChannel.md
86+
ds.subscribe(ChannelName.RequestCreate, message => {
87+
const { request } = message as RequestCreateMessage;
88+
89+
const url = new URL(request.path, request.origin);
90+
const stringUrl = url.toString();
91+
92+
if (isSentryRequest(stringUrl)) {
93+
return;
94+
}
95+
96+
const hub = getCurrentHub();
97+
const client = hub.getClient<NodeClient>();
98+
const scope = hub.getScope();
99+
100+
const activeSpan = scope.getSpan();
101+
102+
if (activeSpan && client) {
103+
const clientOptions = client.getOptions();
104+
105+
// eslint-disable-next-line deprecation/deprecation
106+
const shouldCreateSpan = clientOptions.shouldCreateSpanForRequest
107+
? // eslint-disable-next-line deprecation/deprecation
108+
clientOptions.shouldCreateSpanForRequest(stringUrl)
109+
: true;
110+
111+
if (shouldCreateSpan) {
112+
const data: Record<string, unknown> = {};
113+
const params = url.searchParams.toString();
114+
if (params) {
115+
data['http.query'] = `?${params}`;
116+
}
117+
if (url.hash) {
118+
data['http.fragment'] = url.hash;
119+
}
120+
121+
const span = activeSpan.startChild({
122+
op: 'http.client',
123+
description: `${request.method || 'GET'} ${stripUrlQueryAndFragment(stringUrl)}`,
124+
data,
125+
});
126+
request.__sentry__ = span;
127+
128+
// eslint-disable-next-line deprecation/deprecation
129+
const shouldPropagate = clientOptions.tracePropagationTargets
130+
? // eslint-disable-next-line deprecation/deprecation
131+
stringMatchesSomePattern(stringUrl, clientOptions.tracePropagationTargets)
132+
: true;
133+
134+
if (shouldPropagate) {
135+
// TODO: Only do this based on tracePropagationTargets
136+
request.addHeader('sentry-trace', span.toTraceparent());
137+
if (span.transaction) {
138+
const dynamicSamplingContext = span.transaction.getDynamicSamplingContext();
139+
const sentryBaggageHeader = dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext);
140+
if (sentryBaggageHeader) {
141+
request.addHeader('baggage', sentryBaggageHeader);
142+
}
143+
}
144+
}
145+
}
146+
}
147+
});
148+
149+
ds.subscribe(ChannelName.RequestEnd, message => {
150+
const { request, response } = message as RequestEndMessage;
151+
152+
const url = new URL(request.path, request.origin);
153+
const stringUrl = url.toString();
154+
155+
if (isSentryRequest(stringUrl)) {
156+
return;
157+
}
158+
159+
const span = request.__sentry__;
160+
if (span) {
161+
span.setHttpStatus(response.statusCode);
162+
span.finish();
163+
}
164+
165+
if (this._options.breadcrumbs) {
166+
getCurrentHub().addBreadcrumb(
167+
{
168+
category: 'http',
169+
data: {
170+
method: request.method,
171+
status_code: response.statusCode,
172+
url: stringUrl,
173+
},
174+
type: 'http',
175+
},
176+
{
177+
event: 'response',
178+
request,
179+
response,
180+
},
181+
);
182+
}
183+
});
184+
185+
ds.subscribe(ChannelName.RequestError, message => {
186+
const { request } = message as RequestErrorMessage;
187+
188+
const url = new URL(request.path, request.origin);
189+
const stringUrl = url.toString();
190+
191+
if (isSentryRequest(stringUrl)) {
192+
return;
193+
}
194+
195+
const span = request.__sentry__;
196+
if (span) {
197+
span.setStatus('internal_error');
198+
span.finish();
199+
}
200+
201+
if (this._options.breadcrumbs) {
202+
getCurrentHub().addBreadcrumb(
203+
{
204+
category: 'http',
205+
data: {
206+
method: request.method,
207+
url: stringUrl,
208+
},
209+
level: 'error',
210+
type: 'http',
211+
},
212+
{
213+
event: 'error',
214+
request,
215+
},
216+
);
217+
}
218+
});
219+
}
220+
}

0 commit comments

Comments
 (0)