-
Notifications
You must be signed in to change notification settings - Fork 226
/
http-shared.js
424 lines (378 loc) · 13.9 KB
/
http-shared.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
/*
* Copyright Elasticsearch B.V. and other contributors where applicable.
* Licensed under the BSD 2-Clause License; you may not use this file except in
* compliance with the BSD 2-Clause License.
*/
'use strict';
var { URL, urlToHttpOptions } = require('url');
var endOfStream = require('end-of-stream');
const semver = require('semver');
var { getHTTPDestination } = require('./context');
const transactionForResponse = new WeakMap();
exports.transactionForResponse = transactionForResponse;
const nodeHttpRequestSupportsSeparateUrlArg = semver.gte(
process.version,
'10.9.0',
);
/**
* safeUrlToHttpOptions is a version of `urlToHttpOptions` -- available in
* later Node.js versions (https://nodejs.org/api/all.html#all_url_urlurltohttpoptionsurl)
* -- where the returned object is made "safe" to use as the `options` argument
* to `http.request()` and `https.request()`.
*
* By "safe" here we mean that it will not accidentally be considered a `url`
* argument. This matters in the instrumentation below because the following are
* handled differently:
* http.request(<options>, 'this is a bogus callback')
* http.request(<url>, 'this is a bogus callback')
*/
let safeUrlToHttpOptions;
if (!urlToHttpOptions) {
// Adapted from https://github.com/nodejs/node/blob/v18.13.0/lib/internal/url.js#L1408-L1431
// Added in: v15.7.0, v14.18.0.
safeUrlToHttpOptions = function (url) {
const options = {
protocol: url.protocol,
hostname:
typeof url.hostname === 'string' &&
String.prototype.startsWith(url.hostname, '[')
? String.prototype.slice(url.hostname, 1, -1)
: url.hostname,
hash: url.hash,
search: url.search,
pathname: url.pathname,
path: `${url.pathname || ''}${url.search || ''}`,
href: url.href,
};
if (url.port !== '') {
options.port = Number(url.port);
}
if (url.username || url.password) {
options.auth = `${decodeURIComponent(url.username)}:${decodeURIComponent(
url.password,
)}`;
}
return options;
};
} else if (
semver.satisfies(process.version, '>=19.9.0 <20') ||
semver.satisfies(process.version, '>=18.17.0 <19')
) {
// Starting in node v19.9.0 (as of https://github.com/nodejs/node/pull/46989)
// `urlToHttpOptions(url)` returns an object which is considered a `url`
// argument by `http.request()` -- because of the internal `isURL(url)` test.
// Starting with node v18.17.0, the same is true with the internal switch
// to the "Ada" lib for URL parsing.
safeUrlToHttpOptions = function (url) {
const options = urlToHttpOptions(url);
// Specifically we are dropping the `Symbol(context)` field.
Object.getOwnPropertySymbols(options).forEach((sym) => {
delete options[sym];
});
return options;
};
} else if (
semver.satisfies(process.version, '>=20', { includePrerelease: true })
) {
// This only works for versions of node v20 after
// https://github.com/nodejs/node/pull/47339 which changed the internal
// `isURL()` to duck-type test for the `href` field. `href` isn't an option
// to `http.request()` so there is no harm in dropping it.
safeUrlToHttpOptions = function (url) {
const options = urlToHttpOptions(url);
delete options.href;
return options;
};
} else {
safeUrlToHttpOptions = urlToHttpOptions;
}
exports.instrumentRequest = function (agent, moduleName) {
var ins = agent._instrumentation;
return function (orig) {
return function (event, req, res) {
if (event === 'request') {
agent.logger.debug(
'intercepted request event call to %s.Server.prototype.emit for %s',
moduleName,
req.url,
);
if (shouldIgnoreRequest(agent, req)) {
agent.logger.debug('ignoring request to %s', req.url);
// Don't leak previous transaction.
agent._instrumentation.supersedeWithEmptyRunContext();
} else {
// Decide whether to use trace-context headers, if any, for a
// distributed trace.
const traceparent =
req.headers.traceparent || req.headers['elastic-apm-traceparent'];
const tracestate = req.headers.tracestate;
const trans = agent.startTransaction(null, null, {
childOf: traceparent,
tracestate,
});
trans.type = 'request';
trans.req = req;
trans.res = res;
transactionForResponse.set(res, trans);
ins.bindEmitter(req);
ins.bindEmitter(res);
endOfStream(res, function (err) {
if (trans.ended) return;
if (!err) return trans.end();
if (agent._conf.errorOnAbortedRequests) {
var duration = trans._timer.elapsed();
if (duration > agent._conf.abortedErrorThreshold * 1000) {
agent.captureError(
'Socket closed with active HTTP request (>' +
agent._conf.abortedErrorThreshold +
' sec)',
{
request: req,
extra: { abortTime: duration },
},
);
}
}
// Handle case where res.end is called after an error occurred on the
// stream (e.g. if the underlying socket was prematurely closed)
const end = res.end;
res.end = function () {
const result = end.apply(this, arguments);
trans.end();
return result;
};
});
}
}
return orig.apply(this, arguments);
};
};
};
function shouldIgnoreRequest(agent, req) {
var i;
for (i = 0; i < agent._conf.ignoreUrlStr.length; i++) {
if (agent._conf.ignoreUrlStr[i] === req.url) return true;
}
for (i = 0; i < agent._conf.ignoreUrlRegExp.length; i++) {
if (agent._conf.ignoreUrlRegExp[i].test(req.url)) return true;
}
for (i = 0; i < agent._conf.transactionIgnoreUrlRegExp.length; i++) {
if (agent._conf.transactionIgnoreUrlRegExp[i].test(req.url)) return true;
}
var ua = req.headers['user-agent'];
if (!ua) return false;
for (i = 0; i < agent._conf.ignoreUserAgentStr.length; i++) {
if (ua.indexOf(agent._conf.ignoreUserAgentStr[i]) === 0) return true;
}
for (i = 0; i < agent._conf.ignoreUserAgentRegExp.length; i++) {
if (agent._conf.ignoreUserAgentRegExp[i].test(ua)) return true;
}
return false;
}
/**
* Safely get the Host header used in the given client request without incurring
* the core Node.js DEP0066 warning for using `req._headers`.
*
* @param {http.ClientRequest} req
* @returns {string}
*/
function getSafeHost(req) {
return req.getHeader ? req.getHeader('Host') : req._headers.host;
}
exports.traceOutgoingRequest = function (agent, moduleName, method) {
var ins = agent._instrumentation;
return function wrapHttpRequest(orig) {
return function wrappedHttpRequest(input, options, cb) {
const parentRunContext = ins.currRunContext();
var span = ins.createSpan(null, 'external', 'http', { exitSpan: true });
var id = span && span.transaction.id;
agent.logger.debug('intercepted call to %s.%s %o', moduleName, method, {
id,
});
// Reproduce the argument handling from node/lib/_http_client.js#ClientRequest().
//
// The `new URL(...)` calls in this block *could* throw INVALID_URL, but
// that would happen anyway when calling `orig(...)`. The only slight
// downside is that the Error stack won't originate inside "_http_client.js".
if (!nodeHttpRequestSupportsSeparateUrlArg) {
// Signature from node <10.9.0:
// http.request(options[, callback])
// options <Object> | <string> | <URL>
cb = options;
options = input;
if (typeof options === 'string') {
options = safeUrlToHttpOptions(new URL(options));
} else if (options instanceof URL) {
options = safeUrlToHttpOptions(options);
} else {
options = Object.assign({}, options);
}
} else {
// Signature from node >=10.9.0:
// http.request(options[, callback])
// http.request(url[, options][, callback])
// url <string> | <URL>
// options <Object>
if (typeof input === 'string') {
input = safeUrlToHttpOptions(new URL(input));
} else if (input instanceof URL) {
input = safeUrlToHttpOptions(input);
} else {
cb = options;
options = input;
input = null;
}
if (typeof options === 'function') {
cb = options;
options = input || {};
} else {
options = Object.assign(input || {}, options);
}
}
const newArgs = [options];
if (cb !== undefined) {
if (typeof cb === 'function') {
newArgs.push(ins.bindFunctionToRunContext(parentRunContext, cb));
} else {
newArgs.push(cb);
}
}
// W3C trace-context propagation.
// There are a number of reasons why `span` might be null: child of an
// exit span, `transactionMaxSpans` was hit, unsampled transaction, etc.
// If so, then fallback to the current run context's span or transaction,
// if any.
const parent =
span ||
parentRunContext.currSpan() ||
parentRunContext.currTransaction();
if (parent) {
const headers = Object.assign({}, options.headers);
parent.propagateTraceContextHeaders(
headers,
function (carrier, name, value) {
carrier[name] = value;
},
);
options.headers = headers;
}
if (!span) {
return orig.apply(this, newArgs);
}
const spanRunContext = parentRunContext.enterSpan(span);
var req = ins.withRunContext(spanRunContext, orig, this, ...newArgs);
var protocol = req.agent && req.agent.protocol;
agent.logger.debug('request details: %o', {
protocol,
host: getSafeHost(req),
id,
});
ins.bindEmitter(req);
span.action = req.method;
span.name = req.method + ' ' + getSafeHost(req);
// TODO: Research if it's possible to add this to the prototype instead.
// Or if it's somehow preferable to listen for when a `response` listener
// is added instead of when `response` is emitted.
const emit = req.emit;
req.emit = function wrappedEmit(type, res) {
if (type === 'response') onResponse(res);
if (type === 'abort') onAbort(type);
return emit.apply(req, arguments);
};
const url = getUrlFromRequestAndOptions(req, options, moduleName + ':');
if (!url) {
agent.logger.warn('unable to identify http.ClientRequest url %o', {
id,
});
}
let statusCode;
return req;
// In case the request is ended prematurely
function onAbort(type) {
if (span.ended) return;
agent.logger.debug('intercepted http.ClientRequest abort event %o', {
id,
});
onEnd();
}
function onEnd() {
span.setHttpContext({
method: req.method,
status_code: statusCode,
url,
});
// Add destination info only when socket conn is established
if (url) {
// The `getHTTPDestination` function might throw in case an
// invalid URL is given to the `URL()` function. Until we can
// be 100% sure this doesn't happen, we better catch it here.
// For details, see:
// https://github.com/elastic/apm-agent-nodejs/issues/1769
try {
span._setDestinationContext(getHTTPDestination(url));
} catch (e) {
agent.logger.error(
'Could not set destination context: %s',
e.message,
);
}
}
span._setOutcomeFromHttpStatusCode(statusCode);
span.end();
}
function onResponse(res) {
agent.logger.debug('intercepted http.ClientRequest response event %o', {
id,
});
ins.bindEmitterToRunContext(parentRunContext, res);
statusCode = res.statusCode;
res.prependListener('end', function () {
agent.logger.debug('intercepted http.IncomingMessage end event %o', {
id,
});
onEnd();
});
}
};
};
};
// Creates a sanitized URL suitable for the span's HTTP context
//
// This function reconstructs a URL using the request object's properties
// where it can (node versions v14.5.0, v12.19.0 and later), and falling
// back to the options where it can not. This function also strips any
// authentication information provided with the hostname. In other words
//
// http://username:[email protected]/foo
//
// becomes http://example.com/foo
//
// NOTE: The options argument may not be the same options that are passed
// to http.request if the caller uses the the http.request(url,options,...)
// method signature. The agent normalizes the url and options into a single
// options object. This function expects those pre-normalized options.
//
// @param {ClientRequest} req
// @param {object} options
// @param {string} fallbackProtocol
// @return string|undefined
function getUrlFromRequestAndOptions(req, options, fallbackProtocol) {
if (!req) {
return undefined;
}
options = options || {};
req = req || {};
req.agent = req.agent || {};
if (isProxiedRequest(req)) {
return req.path;
}
const port = options.port ? `:${options.port}` : '';
// req.host and req.protocol are node versions v14.5.0/v12.19.0 and later
const host = req.host || options.hostname || options.host || 'localhost';
const protocol = req.protocol || req.agent.protocol || fallbackProtocol;
return `${protocol}//${host}${port}${req.path}`;
}
function isProxiedRequest(req) {
return req.path.indexOf('https:') === 0 || req.path.indexOf('http:') === 0;
}
exports.getUrlFromRequestAndOptions = getUrlFromRequestAndOptions;