UNPKG

newrelic

Version:
373 lines (333 loc) 12.1 kB
/* * Copyright 2020 New Relic Corporation. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ 'use strict' const recordExternal = require('../../metrics/recorders/http_external') const urltils = require('../../util/urltils') const logger = require('../../logger').child({ component: 'outbound' }) const shimmer = require('../../shimmer') const symbols = require('../../symbols') const synthetics = require('../../synthetics') const { URL } = require('node:url') const NAMES = require('../../metrics/names') const DEFAULT_HOST = 'localhost' const DEFAULT_HTTP_PORT = 80 const DEFAULT_SSL_PORT = 443 /** * Determines the default port to 80 if protocol is undefined or http: * Otherwise it assigns it as 443 * * @param {object} opts HTTP request options * @returns {number} default port */ function getDefaultPort(opts) { return !opts.protocol || opts.protocol === 'http:' ? DEFAULT_HTTP_PORT : DEFAULT_SSL_PORT } /** * Determines the port based on http opts * * @param {object} opts HTTP request options * @param {number} defaultPort the default port * @returns {number} port */ function getPort(opts, defaultPort) { let port = opts.port || opts.defaultPort if (!port) { port = defaultPort } return port } /** * Determines the default hostname based on http opts * * @param {object} opts HTTP request options * @returns {string} default host */ function getDefaultHostName(opts) { return opts.hostname || opts.host || DEFAULT_HOST } /** * Extracts host, hostname, port from http request options * * @param {object} opts HTTP request options * @returns {object} { host, hostname, port } */ function extractHostPort(opts) { const defaultPort = getDefaultPort(opts) const hostname = getDefaultHostName(opts) const port = getPort(opts, defaultPort) let host = hostname if (port && port !== defaultPort) { host += `:${port}` } return { host, hostname, port } } /** * Extracts the host, hostname, and port from HTTP request options when using a proxy * * @param {object} opts HTTP request options * @returns {object|null} { host, hostname, port } if proxy request is detected, or null otherwise. */ function extractHostPortViaProxy(opts) { const pathname = opts.pathname || opts.path if (pathname && (pathname.startsWith('https://') || pathname.startsWith('http://'))) { const url = new URL(pathname) return { host: url.host, hostname: url.hostname, port: url.port || url.protocol === 'https:' ? '443' : '80' } } return null } /** * Instruments an outbound HTTP request. * * @param {Agent} agent instantiation of lib/agent.js * @param {object} opts HTTP request options * @param {Function} makeRequest function for issuing actual HTTP request * @returns {object} The instrumented outbound HTTP request. */ module.exports = function instrumentOutbound(agent, opts, makeRequest) { const viaProxy = extractHostPortViaProxy(opts) const { host, hostname, port } = viaProxy ? viaProxy : extractHostPort(opts) if (!hostname || port < 1) { logger.warn('Invalid host name (%s) or port (%s) for outbound request.', hostname, port) return makeRequest(opts) } const name = NAMES.EXTERNAL.PREFIX + host const parent = agent.tracer.getSegment() if (parent && parent.opaque) { logger.trace( 'Not capturing data for outbound request (%s) because parent segment opaque (%s)', name, parent.name ) return makeRequest(opts) } return agent.tracer.addSegment( name, recordExternal(host, 'http'), parent, false, instrumentRequest.bind(null, { agent, opts, makeRequest, host, port, hostname }) ) } /** * Injects DT headers, creates segment for outbound http request. * Instruments the request.emit to properly handle the response of the * outbound http request * * @param {object} params to function * @param {Agent} params.agent New Relic agent * @param {string|object} params.opts a url string or HTTP request options * @param {Function} params.makeRequest function to make request * @param {string} params.host domain + port of outbound request * @param {string} params.hostname domain of outbound request * @param {string} params.port port of outbound request * @param {TraceSegment} segment outbound http segment * @param {Transaction} transaction active tx * @returns {http.IncomingMessage} request actual http outbound request */ function instrumentRequest( { agent, opts, makeRequest, host, port, hostname }, segment, transaction ) { const outboundHeaders = Object.create(null) opts.headers = opts.headers || {} synthetics.assignHeadersToOutgoingRequest(agent.config, transaction, outboundHeaders) maybeAddDtHeaders(transaction, outboundHeaders, opts?.headers) opts.headers = assignOutgoingHeaders(opts.headers, outboundHeaders) const request = applySegment({ opts, makeRequest, hostname, host, port, segment, config: agent.config }) instrumentRequestEmit(agent, host, segment, request) return request } /** * It will add DT headers to the outgoing headers, if enabled * * @param {Transaction} transaction active transaction * @param {object} outboundHeaders headers that are getting attached to external http call * @param {object} headers headers for http request */ function maybeAddDtHeaders(transaction, outboundHeaders, headers = {}) { // TODO: remove `symbols.disableDT` check when we drop `aws-sdk@2` if (headers[symbols.disableDT] || headers['x-new-relic-disable-dt']) { logger.trace('Distributed tracing disabled by instrumentation.') // do not try to delete this header because AWS will fail with signature fail // See: https://github.com/newrelic/node-newrelic/issues/1549 return } transaction.insertDistributedTraceHeaders(outboundHeaders) } /** * Assigns new headers for outgoing request * * @param {object|Array} currentHeaders current headers from request options headers * @param {object} outboundHeaders headers to assign to outgoing request * @returns {object|Array} properly formatted headers */ function assignOutgoingHeaders(currentHeaders, outboundHeaders) { let headers if (Array.isArray(currentHeaders)) { headers = currentHeaders.slice() Array.prototype.push.apply( headers, Object.keys(outboundHeaders).map(function getHeaderTuples(key) { return [key, outboundHeaders[key]] }) ) } else { headers = Object.assign(Object.create(null), currentHeaders, outboundHeaders) } return headers } /** * Starts the http outbound segment and attaches relevant attributes to the segment/span. * * @param {object} params to function * @param {string|object} params.opts a url string or HTTP request options * @param {Function} params.makeRequest function to make request * @param {string} params.host host of outbound request * @param {string} params.port port of outbound request * @param {string} params.hostname host + port of outbound request * @param {TraceSegment} params.segment outbound http segment * @param {object} params.config agent config * @returns {http.IncomingMessage} request actual http outbound request */ function applySegment({ opts, makeRequest, host, port, hostname, segment, config }) { segment.start() const request = makeRequest(opts) // Parse the appropriate URL from the request options // 1. If the path is a proxy url use the entire path // 2. construct url from path, protocol and host if href does not already include path // 3. use href to construct url let absoluteUrl = null if (request?.path?.startsWith('http://') || request?.path?.startsWith('https://')) { absoluteUrl = request.path } else if (!opts?.href?.includes(request.path)) { const protocol = opts.protocol || 'http:' absoluteUrl = `${protocol}//${host}${request.path}` } else { absoluteUrl = opts.href } let obfuscatedPath, parsed try { const urlObject = new URL(absoluteUrl) parsed = urltils.scrubAndParseParameters(urlObject) obfuscatedPath = urltils.obfuscatePath(config, parsed.path) segment.name += obfuscatedPath } catch (err) { logger.debug('Could not parse URL from request options: %s', err.message) obfuscatedPath = request.path } const proto = parsed?.protocol || opts.protocol || 'http:' segment.captureExternalAttributes({ protocol: proto, hostname, host, method: opts.method, port, path: obfuscatedPath, queryParams: parsed?.parameters }) request[symbols.segment] = segment return request } /** * Wrap the emit method. We're doing a special wrapper instead of using * `tracer.bindEmitter` because we want to do some logic based on certain * events. * * @param {Agent} agent New Relic agent * @param {string} host host of outbound request * @param {TraceSegment} segment outbound http segment * @param {http.IncomingMessage} request actual http outbound request */ function instrumentRequestEmit(agent, host, segment, request) { shimmer.wrapMethod(request, 'request.emit', 'emit', function wrapEmit(emit) { const context = agent.tracer.getContext() const newContext = context.enterSegment({ segment }) return function wrappedRequestEmit(evnt, arg) { const transaction = agent.tracer.getTransaction() if (evnt === 'error') { segment.end() handleError({ transaction, req: request, error: arg }) } else if (evnt === 'response') { handleResponse({ agent, segment, transaction, host, res: arg }) } return agent.tracer.runInContext({ handler: emit, context: newContext, thisArg: this, args: arguments }) } }) _makeNonEnumerable(request, 'emit') } /** * Notices the given error if there is no listener for the `error` event on the * request object. * * @param {object} params to function * @param {Transaction} params.transaction active transaction * @param {object} params.req http.ClientRequest * @param {Error} params.error If provided, unhandled error that occurred during request * @returns {boolean} True if the error will be collected by New Relic. */ function handleError({ transaction, req, error }) { if (req.listenerCount('error') > 0) { logger.trace(error, 'Not capturing outbound error because user has already handled it.') return false } logger.trace(error, 'Captured outbound error on behalf of the user.') transaction.agent.errors.add(transaction, error) return true } /** * Ties the response object to the request segment. * * @param {object} params to function * @param {Agent} params.agent agent instance * @param {TraceSegment} params.segment active segment * @param {Transaction} params.transaction active transaction * @param {string} params.host hostname of the HTTP request * @param {object} params.res http.ServerResponse */ function handleResponse({ agent, segment, transaction, host, res }) { // Add response attributes for spans segment.addSpanAttribute('http.statusCode', res.statusCode) segment.addSpanAttribute('http.statusText', res.statusMessage) // Again a custom emit wrapper because we want to watch for the `end` event. shimmer.wrapMethod(res, 'response', 'emit', function wrapEmit(emit) { const context = agent.tracer.getContext() const newContext = context.enterSegment({ segment }) return function wrappedResponseEmit(evnt) { if (evnt === 'end') { segment.end() } return agent.tracer.runInContext({ handler: emit, context: newContext, thisArg: this, args: arguments }) } }) _makeNonEnumerable(res, 'emit') } /** * Makes a property non-enumerable * * @param {object} obj object that contains property that needs to be non-enumerable * @param {string} prop property to make non-enumerable */ function _makeNonEnumerable(obj, prop) { try { const desc = Object.getOwnPropertyDescriptor(obj, prop) desc.enumerable = false Object.defineProperty(obj, prop, desc) } catch (e) { logger.debug(e, 'Failed to make %s non enumerable.', prop) } }