UNPKG

@instana/core

Version:
499 lines (428 loc) 19.3 kB
/* * (c) Copyright IBM Corp. 2021 * (c) Copyright Instana Inc. and contributors 2016 */ 'use strict'; const coreHttpModule = require('http'); const coreHttpsModule = require('https'); const url = require('url'); const tracingUtil = require('../../tracingUtil'); const { dropLeadingQuestionMark, filterParams, sanitizeUrl, splitAndFilter } = require('../../../util/url'); const { getExtraHeadersFromOptions, mergeExtraHeadersFromIncomingMessage, mergeExtraHeadersFromServerResponseOrClientRequest } = require('./captureHttpHeadersUtil'); const constants = require('../../constants'); const cls = require('../../cls'); const hook = require('../../../util/hook'); const URL = url.URL; let extraHttpHeadersToCapture; let logger; let isActive = false; exports.init = function init(config) { logger = config.logger; instrument(coreHttpModule, false); instrument(coreHttpsModule, true); extraHttpHeadersToCapture = config.tracing.http.extraHttpHeadersToCapture; hook.onModuleLoad('request', logDeprecatedWarning); }; function logDeprecatedWarning() { logger.warn( // eslint-disable-next-line max-len '[Deprecation Warning] The support for request library is deprecated and will be removed in the next major release. Please consider migrating to an appropriate package.' ); } exports.updateConfig = function updateConfig(config) { extraHttpHeadersToCapture = config.tracing.http.extraHttpHeadersToCapture; }; exports.activate = function activate(extraConfig) { if ( extraConfig && extraConfig.tracing && extraConfig.tracing.http && Array.isArray(extraConfig.tracing.http.extraHttpHeadersToCapture) ) { extraHttpHeadersToCapture = extraConfig.tracing.http.extraHttpHeadersToCapture; } isActive = true; }; exports.deactivate = function deactivate() { isActive = false; }; /** * @param {string | number | Array.<string>} headerValue * @param {(headerValue: string) => boolean} validator * @returns {boolean} */ function evaluateHeaderValue(headerValue, validator) { if (headerValue == null) { return false; } if (typeof headerValue === 'string' || typeof headerValue === 'number') { return validator(String(headerValue)); } else if (Array.isArray(headerValue)) { const len = headerValue.length; for (let i = 0; i < len; i++) { if (validator(headerValue[i]) === true) { return true; } } return false; } } /** * Checks whether an outgoing HTTP request should not be traced. This is intended to suppress the creation of HTTP exits * for which a higher level instrumentation exists, for example, HTTP requests made by AWS SQS that represent the queue * polling when we already created an SQS entry span for it. * * When using GCP storage library, the default way to upload a file is the resumable strategy. * This strategy uses google-auth-library -> gaxios -> http/https node core libs, which create http exit spans. * We want to suppress these spans because they have no further benefit for the customers. * Important: the http request happens after the gcp span async context. Therefor the parentSpan * is the http server entry. * * https://github.com/googleapis/nodejs-storage/blob/v6.9.4/src/resumable-upload.ts#L912 * https://github.com/googleapis/gaxios/blob/v5.1.0/src/gaxios.ts * * @param {InstanaSpan} parentSpan The currently active parent span * @param {Object} options The standard lib request options: * https://nodejs.org/dist/latest-v14.x/docs/api/http.html#http_http_request_options_callback * @return {boolean} true, if the HTTP request that is about to happen should _not_ create a span. */ function shouldBeBypassed(parentSpan, options) { const headers = options && options.headers; const userAgent = (headers && headers['User-Agent']) || (headers && headers['user-agent']); const isAWSNodeJSHeader = evaluateHeaderValue( userAgent, header => header.toLowerCase().indexOf('aws-sdk-nodejs') > -1 || header.toLowerCase().indexOf('aws-sdk-js') > -1 ); const hostInfo = (headers && headers.Host) || (headers && headers.host); // Same regex used by AWS SDK at /lib/services/sqs.js const hostMatchesSQS = evaluateHeaderValue(hostInfo, header => header.match(/^sqs\.(?:.+?)\./) !== null); // 'user-agent': 'aws-sdk-js/3.329.0 os/darwin/22.5.0 lang/js md/nodejs/18.14.2 api/sqs/3.329.0' const agentMatchesSQS = evaluateHeaderValue(userAgent, header => header.toLowerCase().indexOf('api/sqs') > -1); if (parentSpan && parentSpan.n === 'sqs' && isAWSNodeJSHeader && (hostMatchesSQS || agentMatchesSQS)) { return true; } const isGCPNodeJSHeader = evaluateHeaderValue( userAgent, header => header.toLowerCase().indexOf('google-api-nodejs-client') > -1 ); const hostMatchesGPC = options && options.host && options.host === 'storage.googleapis.com'; if (isGCPNodeJSHeader && hostMatchesGPC) return true; return false; } function instrument(coreModule, forceHttps) { const originalRequest = coreModule.request; coreModule.request = function request() { let clientRequest; // When http.request is called with http.request(options, undefined) (happens with request-promise, for example), // arguments.length will still be 2 but there is no callback. Even though we push a callback into the arguments // array, this cb will then be at index 2, thus never get called in older versions of Node, hence the span would // not be finished/transmitted. We normalize the args by removing undefined/null from the end. while (arguments.length > 0 && arguments[arguments.length - 1] == null) { arguments.length--; } const originalArgs = new Array(arguments.length); for (let i = 0; i < arguments.length; i++) { originalArgs[i] = arguments[i]; } let urlArg = null; let options = null; let callback = null; let callbackIndex = -1; if (typeof originalArgs[0] === 'string' || isUrlObject(originalArgs[0])) { urlArg = originalArgs[0]; } else if (typeof originalArgs[0] === 'object') { options = originalArgs[0]; } if (!options && typeof originalArgs[1] === 'object') { options = originalArgs[1]; } if (typeof originalArgs[1] === 'function') { callback = originalArgs[1]; callbackIndex = 1; } if (!callback && typeof originalArgs[2] === 'function') { callback = originalArgs[2]; callbackIndex = 2; } let w3cTraceContext = cls.getW3cTraceContext(); const skipTracingResult = cls.skipExitTracing({ isActive, extendedResponse: true, checkReducedSpan: true }); const parentSpan = skipTracingResult.parentSpan; if (skipTracingResult.skip || shouldBeBypassed(skipTracingResult.parentSpan, options)) { let traceLevelHeaderHasBeenAdded = false; if (skipTracingResult.suppressed) { traceLevelHeaderHasBeenAdded = tryToAddTraceLevelAddHeaderToOpts(options, '0', w3cTraceContext); } clientRequest = originalRequest.apply(coreModule, arguments); if (skipTracingResult.suppressed && !traceLevelHeaderHasBeenAdded) { clientRequest.setHeader(constants.traceLevelHeaderName, '0'); setW3cHeadersOnRequest(clientRequest, w3cTraceContext); } return clientRequest; } cls.ns.run(() => { // NOTE: Check for parentSpan existence, because of allowRootExitSpan is being enabled const span = cls.startSpan({ spanName: 'node.http.client', kind: constants.EXIT, traceId: parentSpan?.t, parentSpanId: parentSpan?.s }); // startSpan updates the W3C trace context and writes it back to CLS, so we have to refetch the updated context // object from CLS. w3cTraceContext = cls.getW3cTraceContext(); let completeCallUrl; let params; if (urlArg && typeof urlArg === 'string') { // just one string.... completeCallUrl = sanitizeUrl(urlArg); params = splitAndFilter(urlArg); } else if (urlArg && isUrlObject(urlArg)) { completeCallUrl = sanitizeUrl(url.format(urlArg)); params = dropLeadingQuestionMark(filterParams(urlArg.search)); } else if (options) { const urlAndQuery = constructFromUrlOpts(options, coreModule, forceHttps); completeCallUrl = urlAndQuery[0]; params = urlAndQuery[1]; } span.stack = tracingUtil.getStackTrace(request); const boundCallback = cls.ns.bind(function boundCallback(res) { span.data.http = { method: clientRequest.method, url: completeCallUrl, status: res.statusCode, params }; const headers = captureRequestHeaders(options, clientRequest, res); if (headers) { span.data.http.header = headers; } span.d = Date.now() - span.ts; span.ec = res.statusCode >= 500 ? 1 : 0; span.transmit(); if (callback) { callback(res); } }); if (callbackIndex >= 0) { originalArgs[callbackIndex] = boundCallback; } else { originalArgs.push(boundCallback); } let instanaHeadersHaveBeenAdded = false; try { instanaHeadersHaveBeenAdded = tryToAddHeadersToOpts(options, span, w3cTraceContext); clientRequest = originalRequest.apply(coreModule, originalArgs); } catch (e) { // A synchronous exception indicates a failure that is not covered by the listeners. Using a malformed URL for // example is a case that triggers a synchronous exception. span.data.http = {}; span.data.http.url = completeCallUrl; tracingUtil.setErrorDetails(span, e, 'http'); span.d = Date.now() - span.ts; span.ec = 1; span.transmit(); throw e; } cls.ns.bindEmitter(clientRequest); if (!instanaHeadersHaveBeenAdded) { instanaHeadersHaveBeenAdded = setHeadersOnRequest(clientRequest, span, w3cTraceContext); } let isTimeout = false; clientRequest.on('timeout', () => { // From the Node.js HTTP client documentation: // // > Emitted when the underlying socket times out from inactivity. This **only notifies** that the socket // > has been idle. **The request must be aborted manually.** // // This means that the timeout event is only an indication that something is wrong. After the timeout occurred, // a well behaved client will do one of two things: // // 1) Abort the request via req.abort(). This will result in a "socket hang up" error event being emitted by // the request. // 2) The request continues as normal and the timeout is ignored. In this case, we could end up either in a // success or in an error state. This is most likely unintentional HTTP client behavior. // // On top of this, commonly used HTTP client libraries add additional timeout capabilities based on wall clock // time on top of Node.js' timeout capabilities (which typically end in an abort() call). isTimeout = true; }); clientRequest.on('error', err => { let errorMessage = err.message || err.code; if (isTimeout) { errorMessage = 'Timeout exceeded'; if (clientRequest.aborted) { errorMessage += ', request aborted'; } } else if (clientRequest.aborted) { errorMessage = 'Request aborted'; } span.data.http = { method: clientRequest.method, url: completeCallUrl }; // TODO: special-case, we remove this in separate PR span.data.http.error = errorMessage; span.d = Date.now() - span.ts; span.ec = 1; tracingUtil.setErrorDetails(span, err, 'http'); span.transmit(); }); }); return clientRequest; }; coreModule.get = function get() { const req = coreModule.request.apply(coreModule, arguments); req.end(); return req; }; } function constructFromUrlOpts(options, self, forceHttps) { if (options.href) { return [sanitizeUrl(options.href), splitAndFilter(options.href)]; } try { const agent = options.agent || self.agent; const isHttps = forceHttps || options.protocol === 'https:' || agent?.protocol === 'https:'; // Use protocol-aware default port, 443 for HTTPS, 80 for HTTP const port = options.port || options.defaultPort || agent?.defaultPort || (isHttps ? 443 : 80); const protocol = (port === 443 && 'https:') || options.protocol || (agent && agent.protocol) || (forceHttps && 'https:') || 'http:'; const host = options.hostname || options.host || 'localhost'; const path = options.path || '/'; // Only append port if it is NOT the default for the chosen protocol // - For HTTPS, append port only if it is not 443 // - For HTTP, append port only if it is not 80 const hostWithPort = (isHttps && port !== 443) || (!isHttps && port !== 80) ? `${host}:${port}` : host; return [sanitizeUrl(`${protocol}//${hostWithPort}${path}`), splitAndFilter(path)]; } catch (e) { return [undefined, undefined]; } } function isUrlObject(argument) { return URL && argument instanceof url.URL; } function tryToAddHeadersToOpts(options, span, w3cTraceContext) { // Some HTTP spec background: If the request has a header Expect: 100-continue, the client will first send the // request headers, without the body. The client is then ought to wait for the server to send a first, preliminary // response with the status code 100 Continue (if all is well). Only then will the client send the actual request // body. // The Node.js HTTP client core module implements this in the following way: If the option's object given to the // `http(s).request` contains "Expect": "100-continue", it will immediately flush the headers and send them internally // via `send(''). That is, when `http.request(...)` returns, the headers have already been sent and can no longer be // modified. The client code is expected to not call `request.setHeaders` on that request. Usually the client code // will listen for the request to emit the 'continue' event (signalling that the server has sent "100 Continue") and // only then write the response body to the request, for example by calling `request.end(body)`. // Thus, at the very least, when this header is present in the incoming request options arguments, we need to add our // INSTANA-... HTTP headers to options.headers instead of calling request.setHeader later. In fact, we opt for the // slightly more general solution: If there is an options object parameter with a `headers` object, we just always // add our headers there. We use request.setHeader on the ClientRequest object only when the headers object is missing // (see setHeadersOnRequest). if (hasHeadersOption(options)) { if (!isItSafeToModifiyHeadersInOptions(options)) { return true; } options.headers[constants.spanIdHeaderName] = span.s; options.headers[constants.traceIdHeaderName] = span.t; options.headers[constants.traceLevelHeaderName] = '1'; tryToAddW3cHeaderToOpts(options, w3cTraceContext); return true; } return false; } function tryToAddTraceLevelAddHeaderToOpts(options, level, w3cTraceContext) { if (hasHeadersOption(options)) { if (!isItSafeToModifiyHeadersInOptions(options)) { // Return true to convince the caller that headers have been added although we have in fact not added them. This // will result in no headers being added. See isItSafeToModifiyHeadersInOptions for the motivation behind this. return true; } options.headers[constants.traceLevelHeaderName] = level; tryToAddW3cHeaderToOpts(options, w3cTraceContext); return true; } return false; } function tryToAddW3cHeaderToOpts(options, w3cTraceContext) { if (w3cTraceContext) { options.headers[constants.w3cTraceParent] = w3cTraceContext.renderTraceParent(); if (w3cTraceContext.hasTraceState()) { options.headers[constants.w3cTraceState] = w3cTraceContext.renderTraceState(); } } } function hasHeadersOption(options) { return options && typeof options === 'object' && options.headers && typeof options.headers === 'object'; } function setHeadersOnRequest(clientRequest, span, w3cTraceContext) { if (!isItSafeToModifiyHeadersForRequest(clientRequest)) { return; } if (span.shouldSuppressDownstream) { // Suppress trace propagation to downstream services. clientRequest.setHeader(constants.traceLevelHeaderName, '0'); setW3cHeadersOnRequest(clientRequest, w3cTraceContext); return; } clientRequest.setHeader(constants.spanIdHeaderName, span.s); clientRequest.setHeader(constants.traceIdHeaderName, span.t); clientRequest.setHeader(constants.traceLevelHeaderName, '1'); setW3cHeadersOnRequest(clientRequest, w3cTraceContext); } function setW3cHeadersOnRequest(clientRequest, w3cTraceContext) { if (w3cTraceContext) { clientRequest.setHeader(constants.w3cTraceParent, w3cTraceContext.renderTraceParent()); if (w3cTraceContext.hasTraceState()) { clientRequest.setHeader(constants.w3cTraceState, w3cTraceContext.renderTraceState()); } } } function isItSafeToModifiyHeadersInOptions(options) { const keys = Object.keys(options.headers); let key; for (let i = 0; i < keys.length; i++) { key = keys[i]; if ( 'authorization' === key.toLowerCase() && typeof options.headers[key] === 'string' && options.headers[key].indexOf('AWS') === 0 ) { // This is a signed AWS API request (probably from the aws-sdk package). // Adding our headers too this request would trigger a SignatureDoesNotMatch error in case the request will be // retried: // "SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. // Check your key and signing method." // See https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html // // Additionally, adding our headers to this request would not have any benefit - the receiving end will be an AWS // service like S3 and those are not instrumented. (There is a very small chance that the receiving end is an // instrumented Lambda function behind an API gateway and the user is using // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/APIGateway.html to invoke this Gateway/Lambda // combination, which _would_ benefit from tracing headers.) return false; } } return true; } function isItSafeToModifiyHeadersForRequest(clientRequest) { const authHeader = clientRequest.getHeader('Authorization'); // see comment in isItSafeToModifiyHeadersInOptions return !authHeader || authHeader.indexOf('AWS') !== 0; } function captureRequestHeaders(options, clientRequest, response) { let headers = getExtraHeadersFromOptions(options, extraHttpHeadersToCapture); headers = mergeExtraHeadersFromServerResponseOrClientRequest(headers, clientRequest, extraHttpHeadersToCapture); headers = mergeExtraHeadersFromIncomingMessage(headers, response, extraHttpHeadersToCapture); return headers; }