Skip to content

Commit 72ddee3

Browse files
committed
fix(node): Avoid double-wrapping http module
1 parent 2e164e1 commit 72ddee3

File tree

3 files changed

+87
-323
lines changed

3 files changed

+87
-323
lines changed

dev-packages/node-integration-tests/suites/express/with-http/test.ts

+24-32
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,28 @@ describe('express with http import', () => {
66
cleanupChildProcesses();
77
});
88

9-
createEsmAndCjsTests(
10-
__dirname,
11-
'scenario.mjs',
12-
'instrument.mjs',
13-
(createRunner, test) => {
14-
test('it works when importing the http module', async () => {
15-
const runner = createRunner()
16-
.expect({
17-
transaction: {
18-
transaction: 'GET /test2',
19-
},
20-
})
21-
.expect({
22-
transaction: {
23-
transaction: 'GET /test',
24-
},
25-
})
26-
.expect({
27-
transaction: {
28-
transaction: 'GET /test3',
29-
},
30-
})
31-
.start();
32-
await runner.makeRequest('get', '/test');
33-
await runner.makeRequest('get', '/test3');
34-
await runner.completed();
35-
});
36-
// TODO: This is failing on ESM because importing http is triggering the http spans twice :(
37-
// We need to fix this!
38-
},
39-
{ failsOnEsm: true },
40-
);
9+
createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => {
10+
test('it works when importing the http module', async () => {
11+
const runner = createRunner()
12+
.expect({
13+
transaction: {
14+
transaction: 'GET /test2',
15+
},
16+
})
17+
.expect({
18+
transaction: {
19+
transaction: 'GET /test',
20+
},
21+
})
22+
.expect({
23+
transaction: {
24+
transaction: 'GET /test3',
25+
},
26+
})
27+
.start();
28+
await runner.makeRequest('get', '/test');
29+
await runner.makeRequest('get', '/test3');
30+
await runner.completed();
31+
});
32+
});
4133
});

packages/node/src/integrations/http/SentryHttpInstrumentation.ts

+63-134
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
/* eslint-disable max-lines */
1+
import { subscribe } from 'node:diagnostics_channel';
22
import type * as http from 'node:http';
3-
import type { IncomingMessage, RequestOptions } from 'node:http';
4-
import type * as https from 'node:https';
53
import type { EventEmitter } from 'node:stream';
64
import { context, propagation } from '@opentelemetry/api';
75
import { VERSION } from '@opentelemetry/core';
86
import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
9-
import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
7+
import { InstrumentationBase } from '@opentelemetry/instrumentation';
108
import type { AggregationCounts, Client, SanitizedRequestData, Scope } from '@sentry/core';
119
import {
1210
addBreadcrumb,
11+
addNonEnumerableProperty,
1312
generateSpanId,
1413
getBreadcrumbLogLevelFromHttpStatusCode,
1514
getClient,
@@ -24,11 +23,6 @@ import {
2423
} from '@sentry/core';
2524
import { DEBUG_BUILD } from '../../debug-build';
2625
import { getRequestUrl } from '../../utils/getRequestUrl';
27-
import { stealthWrap } from './utils';
28-
import { getRequestInfo } from './vendor/getRequestInfo';
29-
30-
type Http = typeof http;
31-
type Https = typeof https;
3226

3327
const INSTRUMENTATION_NAME = '@sentry/instrumentation-http';
3428

@@ -58,7 +52,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
5852
* @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request.
5953
* @param request Contains the {@type RequestOptions} object used to make the outgoing request.
6054
*/
61-
ignoreOutgoingRequests?: (url: string, request: RequestOptions) => boolean;
55+
ignoreOutgoingRequests?: (url: string, request: http.RequestOptions) => boolean;
6256

6357
/**
6458
* Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`.
@@ -67,7 +61,7 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
6761
* @param url Contains the entire URL, including query string (if any), protocol, host, etc. of the outgoing request.
6862
* @param request Contains the {@type RequestOptions} object used to make the outgoing request.
6963
*/
70-
ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean;
64+
ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean;
7165

7266
/**
7367
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry.
@@ -107,72 +101,65 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
107101
}
108102

109103
/** @inheritdoc */
110-
public init(): [InstrumentationNodeModuleDefinition, InstrumentationNodeModuleDefinition] {
111-
return [this._getHttpsInstrumentation(), this._getHttpInstrumentation()];
112-
}
104+
public init(): [] {
105+
subscribe('http.server.request.start', data => {
106+
const server = (data as { server: http.Server }).server;
107+
this._patchServerEmit(server);
108+
});
113109

114-
/** Get the instrumentation for the http module. */
115-
private _getHttpInstrumentation(): InstrumentationNodeModuleDefinition {
116-
return new InstrumentationNodeModuleDefinition(
117-
'http',
118-
['*'],
119-
(moduleExports: Http): Http => {
120-
// Patch incoming requests for request isolation
121-
stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction());
110+
subscribe('http.client.response.finish', data => {
111+
const request = (data as { request: http.ClientRequest }).request;
112+
const response = (data as { response: http.IncomingMessage }).response;
122113

123-
// Patch outgoing requests for breadcrumbs
124-
const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction());
125-
stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest));
114+
this._onOutgoingRequestFinish(request, response);
115+
});
126116

127-
return moduleExports;
128-
},
129-
() => {
130-
// no unwrap here
131-
},
132-
);
117+
return [];
133118
}
134119

135-
/** Get the instrumentation for the https module. */
136-
private _getHttpsInstrumentation(): InstrumentationNodeModuleDefinition {
137-
return new InstrumentationNodeModuleDefinition(
138-
'https',
139-
['*'],
140-
(moduleExports: Https): Https => {
141-
// Patch incoming requests for request isolation
142-
stealthWrap(moduleExports.Server.prototype, 'emit', this._getPatchIncomingRequestFunction());
120+
/**
121+
* This is triggered when an outgoing request finishes.
122+
* It has access to the final request and response objects.
123+
*/
124+
private _onOutgoingRequestFinish(request: http.ClientRequest, response: http.IncomingMessage): void {
125+
const _breadcrumbs = this.getConfig().breadcrumbs;
126+
const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs;
127+
const options = getRequestOptions(request);
143128

144-
// Patch outgoing requests for breadcrumbs
145-
const patchedRequest = stealthWrap(moduleExports, 'request', this._getPatchOutgoingRequestFunction());
146-
stealthWrap(moduleExports, 'get', this._getPatchOutgoingGetFunction(patchedRequest));
129+
const _ignoreOutgoingRequests = this.getConfig().ignoreOutgoingRequests;
130+
const shouldCreateBreadcrumb =
131+
typeof _ignoreOutgoingRequests === 'function' ? !_ignoreOutgoingRequests(getRequestUrl(request), options) : true;
147132

148-
return moduleExports;
149-
},
150-
() => {
151-
// no unwrap here
152-
},
153-
);
133+
if (breadCrumbsEnabled && shouldCreateBreadcrumb) {
134+
addRequestBreadcrumb(request, response);
135+
}
154136
}
155137

156138
/**
157-
* Patch the incoming request function for request isolation.
139+
* Patch a server.emit function to handle process isolation for incoming requests.
140+
* This will only patch the emit function if it was not already patched.
158141
*/
159-
private _getPatchIncomingRequestFunction(): (
160-
original: (event: string, ...args: unknown[]) => boolean,
161-
) => (this: unknown, event: string, ...args: unknown[]) => boolean {
142+
private _patchServerEmit(server: http.Server): void {
143+
// eslint-disable-next-line @typescript-eslint/unbound-method
144+
const originalEmit = server.emit;
145+
146+
// This means it was already patched, do nothing
147+
if ((originalEmit as { __sentry_patched__?: boolean }).__sentry_patched__) {
148+
return;
149+
}
150+
162151
// eslint-disable-next-line @typescript-eslint/no-this-alias
163152
const instrumentation = this;
164153
const { ignoreIncomingRequestBody } = instrumentation.getConfig();
165154

166-
return (
167-
original: (event: string, ...args: unknown[]) => boolean,
168-
): ((this: unknown, event: string, ...args: unknown[]) => boolean) => {
169-
return function incomingRequest(this: unknown, ...args: [event: string, ...args: unknown[]]): boolean {
155+
const newEmit = new Proxy(originalEmit, {
156+
apply(target, thisArg, args: [event: string, ...args: unknown[]]) {
170157
// Only traces request events
171158
if (args[0] !== 'request') {
172-
return original.apply(this, args);
159+
return target.apply(thisArg, args);
173160
}
174161

175-
instrumentation._diag.debug('http instrumentation for incoming request');
162+
DEBUG_BUILD && logger.log('http instrumentation for incoming request');
176163

177164
const isolationScope = getIsolationScope().clone();
178165
const request = args[1] as http.IncomingMessage;
@@ -217,89 +204,20 @@ export class SentryHttpInstrumentation extends InstrumentationBase<SentryHttpIns
217204

218205
// If we don't want to extract the trace from the header, we can skip this
219206
if (!instrumentation.getConfig().extractIncomingTraceFromHeader) {
220-
return original.apply(this, args);
207+
return target.apply(thisArg, args);
221208
}
222209

223210
const ctx = propagation.extract(context.active(), normalizedRequest.headers);
224211
return context.with(ctx, () => {
225-
return original.apply(this, args);
212+
return target.apply(thisArg, args);
226213
});
227214
});
228-
};
229-
};
230-
}
231-
232-
/**
233-
* Patch the outgoing request function for breadcrumbs.
234-
*/
235-
private _getPatchOutgoingRequestFunction(): (
236-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
237-
original: (...args: any[]) => http.ClientRequest,
238-
) => (options: URL | http.RequestOptions | string, ...args: unknown[]) => http.ClientRequest {
239-
// eslint-disable-next-line @typescript-eslint/no-this-alias
240-
const instrumentation = this;
241-
242-
return (original: (...args: unknown[]) => http.ClientRequest): ((...args: unknown[]) => http.ClientRequest) => {
243-
return function outgoingRequest(this: unknown, ...args: unknown[]): http.ClientRequest {
244-
instrumentation._diag.debug('http instrumentation for outgoing requests');
245-
246-
// Making a copy to avoid mutating the original args array
247-
// We need to access and reconstruct the request options object passed to `ignoreOutgoingRequests`
248-
// so that it matches what Otel instrumentation passes to `ignoreOutgoingRequestHook`.
249-
// @see https://github.com/open-telemetry/opentelemetry-js/blob/7293e69c1e55ca62e15d0724d22605e61bd58952/experimental/packages/opentelemetry-instrumentation-http/src/http.ts#L756-L789
250-
const argsCopy = [...args];
251-
252-
const options = argsCopy.shift() as URL | http.RequestOptions | string;
253-
254-
const extraOptions =
255-
typeof argsCopy[0] === 'object' && (typeof options === 'string' || options instanceof URL)
256-
? (argsCopy.shift() as http.RequestOptions)
257-
: undefined;
258-
259-
const { optionsParsed } = getRequestInfo(instrumentation._diag, options, extraOptions);
260-
261-
const request = original.apply(this, args) as ReturnType<typeof http.request>;
262-
263-
request.prependListener('response', (response: http.IncomingMessage) => {
264-
const _breadcrumbs = instrumentation.getConfig().breadcrumbs;
265-
const breadCrumbsEnabled = typeof _breadcrumbs === 'undefined' ? true : _breadcrumbs;
266-
267-
const _ignoreOutgoingRequests = instrumentation.getConfig().ignoreOutgoingRequests;
268-
const shouldCreateBreadcrumb =
269-
typeof _ignoreOutgoingRequests === 'function'
270-
? !_ignoreOutgoingRequests(getRequestUrl(request), optionsParsed)
271-
: true;
272-
273-
if (breadCrumbsEnabled && shouldCreateBreadcrumb) {
274-
addRequestBreadcrumb(request, response);
275-
}
276-
});
215+
},
216+
});
277217

278-
return request;
279-
};
280-
};
281-
}
218+
addNonEnumerableProperty(newEmit, '__sentry_patched__', true);
282219

283-
/** Path the outgoing get function for breadcrumbs. */
284-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
285-
private _getPatchOutgoingGetFunction(clientRequest: (...args: any[]) => http.ClientRequest) {
286-
return (_original: unknown): ((...args: unknown[]) => http.ClientRequest) => {
287-
// Re-implement http.get. This needs to be done (instead of using
288-
// getPatchOutgoingRequestFunction to patch it) because we need to
289-
// set the trace context header before the returned http.ClientRequest is
290-
// ended. The Node.js docs state that the only differences between
291-
// request and get are that (1) get defaults to the HTTP GET method and
292-
// (2) the returned request object is ended immediately. The former is
293-
// already true (at least in supported Node versions up to v10), so we
294-
// simply follow the latter. Ref:
295-
// https://nodejs.org/dist/latest/docs/api/http.html#http_http_get_options_callback
296-
// https://github.com/googleapis/cloud-trace-nodejs/blob/master/src/instrumentations/instrumentation-http.ts#L198
297-
return function outgoingGetRequest(...args: unknown[]): http.ClientRequest {
298-
const req = clientRequest(...args);
299-
req.end();
300-
return req;
301-
};
302-
};
220+
server.emit = newEmit;
303221
}
304222
}
305223

@@ -359,7 +277,7 @@ function getBreadcrumbData(request: http.ClientRequest): Partial<SanitizedReques
359277
* we monkey patch `req.on('data')` to intercept the body chunks.
360278
* This way, we only read the body if the user also consumes the body, ensuring we do not change any behavior in unexpected ways.
361279
*/
362-
function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope): void {
280+
function patchRequestToCaptureBody(req: http.IncomingMessage, isolationScope: Scope): void {
363281
let bodyByteLength = 0;
364282
const chunks: Buffer[] = [];
365283

@@ -451,6 +369,17 @@ function patchRequestToCaptureBody(req: IncomingMessage, isolationScope: Scope):
451369
}
452370
}
453371

372+
function getRequestOptions(request: http.ClientRequest): http.RequestOptions {
373+
return {
374+
method: request.method,
375+
protocol: request.protocol,
376+
host: request.host,
377+
hostname: request.host,
378+
path: request.path,
379+
headers: request.getHeaders(),
380+
};
381+
}
382+
454383
/**
455384
* Starts a session and tracks it in the context of a given isolation scope.
456385
* When the passed response is finished, the session is put into a task and is

0 commit comments

Comments
 (0)