UNPKG

atatus-nodejs

Version:

Atatus APM agent for Node.js

640 lines (564 loc) 22.2 kB
/* * 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'); var utils = require('../utils'); 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; var captureBody = agent._conf.captureBody 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['atatus-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); var shouldCaptureBody = captureBody === 'response' || captureBody === 'all' // we only need to patch res.write if we are captureBody if (shouldCaptureBody) { var resBodyBuf var resBodyBufLimitedExceeded res._orig_write = res.write res.write = function (chunk, encoding, callback) { agent.logger.debug('response write append chunk : ' + chunk) try { if ( !resBodyBufLimitedExceeded && utils.totalChunkLength(resBodyBuf, chunk) < utils._MAX_HTTP_BODY_CHARS ) { resBodyBuf = utils.appendChunk(resBodyBuf, chunk) } else { resBodyBufLimitedExceeded = true resBodyBuf = 'BODY_SIZE_EXCEEDED' } } catch (e) { agent.logger.debug('response write failed : ', e) } res._orig_write.apply(this, arguments) } res._orig_end = res.end res.end = function (chunk, encoding, callback) { var finalBuf = resBodyBuf try { if (chunk && typeof chunk !== 'function') { agent.logger.debug('response end append chunk : ' + chunk) if ( !resBodyBufLimitedExceeded && utils.totalChunkLength(resBodyBuf, chunk) < utils._MAX_HTTP_BODY_CHARS ) { finalBuf = utils.appendChunk(resBodyBuf, chunk) } else { finalBuf = 'BODY_SIZE_EXCEEDED' } } } catch (e) { agent.logger.debug('response end failed : ', e) } const result = res._orig_end.apply(this, arguments) if (finalBuf && finalBuf.length) { res._atbody = finalBuf } trans.end() return result } } 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 }, }, ); } } if (!shouldCaptureBody) { // 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; // } function trackOutgoingRequest(agent, request, span) { var captureBody = agent._conf.captureBody var shouldCaptureRequestBody = captureBody === 'request' || captureBody === 'all' var shouldCaptureResponseBody = captureBody === 'response' || captureBody === 'all' var contentTypes = agent._conf.logBodyContentTypes || [] var isAllowedContentType = false // var finished = false var requestBody = null var originalRequestWrite = request.write request.write = function(chunk, encoding, callback) { var writeReturnValue = originalRequestWrite.call(request, chunk, encoding, callback) if (shouldCaptureRequestBody) { requestBody = utils.appendChunk(requestBody, chunk) } return writeReturnValue } var originalRequestEnd = request.end request.end = function(chunk, encoding, callback) { var endReturnValue = originalRequestEnd.call(request, chunk, encoding, callback) if (shouldCaptureRequestBody) { requestBody = utils.appendChunk(requestBody, chunk) requestBody = utils.getResponseBodyString(requestBody, false) } span.setHttpContext({ request: { headers: utils.getResponseHeaders(request), body: requestBody }, method: request.method }) return endReturnValue } request.on("response", function (res) { var responseBody = null // Check allowed content type for (let i = 0; i < contentTypes.length; i++) { isAllowedContentType = res.headers['content-type'] && res.headers['content-type'].includes(contentTypes[i]) if (isAllowedContentType) { break } } if (agent._conf.analyticsOutgoingOnPatch) { var myStream = res; var dataEventTracked = false; var endEventTracked = false; myStream._org_on = myStream.on; myStream.on = function (evt, handler) { var passOnHandler = handler; if (evt === "data" && !dataEventTracked) { dataEventTracked = true; passOnHandler = function (chs) { if (isAllowedContentType && shouldCaptureResponseBody) { responseBody = utils.appendChunk(responseBody, chs); } return handler(chs); }; } else if (evt === "end" && !endEventTracked) { endEventTracked = true; passOnHandler = function (chs) { if (isAllowedContentType && shouldCaptureResponseBody) { var isCompresssed = myStream.isCompressed || !!((myStream.headers && myStream.headers['content-encoding'] || '').length) responseBody = utils.getResponseBodyString(responseBody, isCompresssed) } span.setHttpContext({ response: { body: responseBody }, statusCode: myStream.statusCode }) return handler(chs); }; } return myStream._org_on(evt, passOnHandler); }; } else { // FIXME: Stripe API is breaking res.on('data', ) calls. // So we ignore the capturing of response body. const headers = utils.getResponseHeaders(request) if (headers && headers.host === 'api.stripe.com') { isAllowedContentType = false } else { res.on('data', function(d) { if (isAllowedContentType && shouldCaptureResponseBody) { responseBody = utils.appendChunk(responseBody, d) } }) } // res.on('abort', function() { // finished = true // }) res.on('end', function() { // finished = true if (isAllowedContentType && shouldCaptureResponseBody) { var isCompresssed = res.isCompressed || !!((res.headers && res.headers['content-encoding'] || '').length) responseBody = utils.getResponseBodyString(responseBody, isCompresssed) } span.setHttpContext({ response: { body: responseBody }, statusCode: res.statusCode }) }) } }) // request.on('error', function(error) { // finished = true // }) // fail safe if not finished // setTimeout(() => { // if (!finished) { // finished = true // } // }, agent._conf.analyticsOutgoingTimeout || 30000) } exports.traceOutgoingRequest = function (agent, moduleName, method) { var ins = agent._instrumentation; var analytics = agent._conf.analytics; var analyticsCaptureOutgoing = agent._conf.analyticsCaptureOutgoing; 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) || 'http:'; var headerHost = utils.getSafeHost(req); if (agent._conf.serverHosts && agent._conf.serverHosts.indexOf(headerHost) !== -1) { agent.logger.debug('ignore %s request to intake API %o', moduleName, { id: id }); return req; } else { agent.logger.debug('request details: %o', { protocol: protocol, host: headerHost, id: id }); } // var protocol = req.agent && req.agent.protocol; // agent.logger.debug('request details: %o', { // protocol, // host: utils.getSafeHost(req), // id, // }); ins.bindEmitter(req); span.action = req.method; // span.name = req.method + ' ' + utils.getSafeHost(req); // span.name = req.method + ' ' + headerHost + url.parse(req.path).pathname span.name = headerHost; span.setHttpContext({ url: protocol + '//' + headerHost + req.path }); // span.setHttpContext({ url: req.method + ' ' + headerHost + req.path }) // Capture external request separately in analytics if (analytics && analyticsCaptureOutgoing && !!(agent._conf.backendConfig && agent._conf.backendConfig.analytics)) { trackOutgoingRequest(agent, req, span); } // 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); if (analytics && analyticsCaptureOutgoing && !!(agent._conf.backendConfig && agent._conf.backendConfig.analytics)) { // The content encoding is not coming in any of the above response 'on' calls except this emit call res.isCompressed = !!((res.headers && res.headers['content-encoding'] || '').length) span.setHttpContext({ responseHeaders: { ...res.headers } }) } 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:password@example.com/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;