newrelic
Version:
New Relic agent
527 lines (443 loc) • 17.1 kB
JavaScript
/*
* Copyright 2020 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
'use strict'
const shimmer = require('../../shimmer')
const logger = require('../../logger').child({ component: 'http' })
const recordWeb = require('../../metrics/recorders/http')
const cat = require('../../util/cat')
const instrumentOutbound = require('./http-outbound')
const url = require('url')
const urltils = require('../../util/urltils')
const headerAttributes = require('../../header-attributes')
const headerProcessing = require('../../header-processing')
const synthetics = require('../../synthetics')
const NAMES = require('../../metrics/names')
const DESTS = require('../../config/attribute-filter').DESTINATIONS
const symbols = require('../../symbols')
// For incoming requests this instrumentation functions by wrapping
// `http.createServer` and `http.Server#addListener`. The former merely sets the
// agent dispatcher to 'http' and the latter wraps any event handlers bound to
// `request`.
//
// The `request` event listener wrapper creates a transaction proxy which will
// start a new transaction whenever a new request comes in. It also scans the
// headers of the incoming request looking for CAT and synthetics headers.
function wrapEmitWithTransaction(agent, emit, isHTTPS) {
const tracer = agent.tracer
const transport = isHTTPS ? 'HTTPS' : 'HTTP'
return tracer.transactionProxy(function wrappedHandler(evnt, request, response) {
const context = tracer.getContext()
const transaction = tracer.getTransaction()
if (!transaction) {
return emit.apply(this, arguments)
}
transaction.nameState.setPrefix(NAMES.NODEJS.PREFIX)
transaction.nameState.setDelimiter(NAMES.ACTION_DELIMITER)
// Store the transaction information on the request and response.
const txInfo = storeTxInfo(transaction, request, response)
if (request) {
initializeRequest(transaction, request)
}
// Create the transaction segment using the request URL for now. Once a
// better name can be determined this segment will be renamed to that.
const segment = tracer.createSegment({
name: request.url,
recorder: recordWeb,
parent: context.segment,
transaction
})
segment.start()
if (request.method != null) {
segment.addSpanAttribute('request.method', request.method)
}
if (txInfo) {
// Seed segment stack to enable parenting logic leveraged by
// web framework instrumentations.
txInfo.segmentStack.push(segment)
}
transaction.type = 'web'
transaction.baseSegment = segment
/* Needed for Connect and Express middleware that monkeypatch request
* and response via listeners.
*/
tracer.bindEmitter(request, segment)
tracer.bindEmitter(response, segment)
// the error tracer needs a URL for tracing, even though naming overwrites
transaction.parsedUrl = url.parse(request.url, true)
transaction.url = urltils.obfuscatePath(agent.config, transaction.parsedUrl.pathname)
transaction.verb = request.method
// URL is sent as an agent attribute with transaction events
transaction.trace.attributes.addAttribute(
DESTS.TRANS_EVENT | DESTS.ERROR_EVENT,
'request.uri',
transaction.url
)
segment.addSpanAttribute('request.uri', transaction.url)
transaction.port = parsePort(this)
// need to set any config-driven names early for RUM
logger.trace(
{ url: request.url, transaction: transaction.id },
'Applying user naming rules for RUM.'
)
transaction.applyUserNamingRules(request.url)
const queueTimeStamp = headerProcessing.getQueueTime(logger, request.headers)
if (queueTimeStamp) {
transaction.queueTime = Date.now() - queueTimeStamp
}
synthetics.assignHeadersToTransaction(agent.config, transaction, request.headers)
maybeAddDtCatHeaders({ transaction, request, transport, agent })
response.once('finish', instrumentedFinish.bind(response, segment, transaction))
response.once('close', instrumentedFinish.bind(response, segment, transaction))
const newContext = context.enterSegment({ segment })
return tracer.bindFunction(emit, newContext).apply(this, arguments)
})
}
/**
* Gets the port from the Server object
*
* @param {object} server http(s) server
* @returns {number|null} parsed port
*/
function parsePort(server) {
let serverPort = null
// store the port on which this transaction runs
if (server.address instanceof Function) {
const address = server.address()
if (address) {
serverPort = address.port
}
}
return serverPort
}
function maybeAddDtCatHeaders({ agent, request, transaction, transport }) {
if (agent.config.distributed_tracing.enabled) {
// Node http headers are automatically lowercase
transaction.acceptDistributedTraceHeaders(transport, request.headers)
} else if (agent.config.cross_application_tracer.enabled) {
const { id, transactionId } = cat.extractCatHeaders(request.headers)
const { externalId, externalTransaction } = cat.parseCatData(
id,
transactionId,
agent.config.encoding_key
)
cat.assignCatToTransaction(externalId, externalTransaction, transaction)
}
}
/**
* Adds instrumentation to response on finish/close.
* It will add `http.statusCode`, `http.statusText`
* to the transaction trace and span.
* It will also assign the response headers to the transaction
*
* @param {TraceSegment} segment active segment
* @param {Transaction} transaction active transaction
*/
function instrumentedFinish(segment, transaction) {
// Remove listeners so this doesn't get called twice.
this.removeListener('finish', instrumentedFinish)
this.removeListener('close', instrumentedFinish)
// Naming must happen before the segment and transaction are ended,
// because metrics recording depends on naming's side effects.
transaction.finalizeNameFromUri(transaction.parsedUrl, this.statusCode)
if (this) {
const { statusCode, statusMessage } = this
if (statusCode != null) {
const responseCode = String(statusCode)
if (/^\d+$/.test(responseCode)) {
transaction.trace.attributes.addAttribute(
DESTS.TRANS_COMMON,
'http.statusCode',
responseCode
)
segment.addSpanAttribute('http.statusCode', responseCode)
}
}
if (statusMessage !== undefined) {
transaction.trace.attributes.addAttribute(
DESTS.TRANS_COMMON,
'http.statusText',
statusMessage
)
segment.addSpanAttribute('http.statusText', statusMessage)
}
const headers = this.getHeaders()
if (headers) {
headerAttributes.collectResponseHeaders(headers, transaction)
}
}
// And we are done! End the segment and transaction.
segment.end()
transaction.end()
}
function storeTxInfo(transaction, request, response) {
if (!request || !response) {
logger.debug('Missing request or response object! Not storing transaction info.')
return
}
const txInfo = {
transaction,
segmentStack: [],
errorHandled: false,
error: null
}
request[symbols.transactionInfo] = response[symbols.transactionInfo] = txInfo
logger.trace('Stored transaction %s information on request and response', transaction.id)
return txInfo
}
function initializeRequest(transaction, request) {
headerAttributes.collectRequestHeaders(request.headers, transaction)
if (request.method != null) {
transaction.trace.attributes.addAttribute(DESTS.TRANS_COMMON, 'request.method', request.method)
transaction.nameState.setVerb(request.method)
}
}
function wrapResponseEnd(agent, proto) {
const tracer = agent.tracer
// On end, we must freeze the current name state to maintain the route that
// responded and also end the current segment (otherwise it may become truncated).
shimmer.wrapMethod(proto, 'Response.prototype', 'end', function wrapResEnd(end) {
if (typeof end !== 'function') {
logger.debug('Response#end is not a function?')
return end
}
return function wrappedResEnd() {
const txInfo = this && this[symbols.transactionInfo]
if (!txInfo) {
return end.apply(this, arguments)
}
if (!txInfo.transaction.isActive()) {
logger.trace('wrappedResEnd invoked for ended transaction implying multiple invocations.')
return end.apply(this, arguments)
}
// If an error happened, add it to the aggregator.
if (
txInfo.error &&
(!txInfo.errorHandled || urltils.isError(agent.config, this.statusCode))
) {
agent.errors.add(txInfo.transaction, txInfo.error)
}
// End all the segments leading up to and including this one.
for (let i = txInfo.segmentStack.length - 1; i >= 0; --i) {
txInfo.segmentStack[i].end()
}
const segment = tracer.getSegment()
if (segment) {
segment.end()
}
// Freeze the name state to prevent further changes.
txInfo.transaction.nameState.freeze()
return end.apply(this, arguments)
}
})
}
// CAT this won't be used unless CAT is enabled, see below where we actually do
// the shimmer stuff if you'd like to verify.
function wrapWriteHead(agent, writeHead) {
return function wrappedWriteHead() {
const transaction = agent.tracer.getTransaction()
if (!transaction) {
logger.trace('No transaction - not adding response CAT headers')
return writeHead.apply(this, arguments)
}
synthetics.assignHeadersToResponse(this, transaction)
if (!transaction.incomingCatId) {
logger.trace('No incoming CAT ID - not adding response CAT headers')
return writeHead.apply(this, arguments)
}
if (!agent.config.trusted_account_ids) {
logger.trace('No account IDs in config.trusted_account_ids - not adding response CAT headers')
return writeHead.apply(this, arguments)
}
if (!cat.isTrustedAccountId(transaction.incomingCatId, agent.config.trusted_account_ids)) {
return writeHead.apply(this, arguments)
}
// Not sure this could ever happen, but should guard against it anyway
// otherwise exception we blow up the user's app.
if (!agent.config.cross_process_id || !agent.config.encoding_key) {
logger.trace(
'Managed to have %s but not cross_process_id (%s) or encoding_key (%s) - %s',
'agent.config.trusted_account_ids',
agent.config.cross_process_id,
agent.config.encoding_key,
'not adding response CAT headers'
)
return writeHead.apply(this, arguments)
}
// -1 means no content length header was sent. We should only send this
// value in the appData if the header is set.
let contentLength = -1
const newHeaders = arguments[arguments.length - 1]
if (typeof newHeaders === 'object') {
contentLength = headerProcessing.getContentLengthFromHeaders(newHeaders)
}
const currentHeaders = this.getHeaders()
if (contentLength === -1 && currentHeaders) {
contentLength = headerProcessing.getContentLengthFromHeaders(currentHeaders)
}
// Stored on the tx so we can push a metric with this time instead of
// actual duration.
transaction.catResponseTime = transaction.timer.getDurationInMillis()
const { key, data } = cat.encodeAppData(agent.config, transaction, contentLength)
if (key && data) {
this.setHeader(key, data)
logger.trace('Added outbound response CAT headers in transaction %s', transaction.id)
}
return writeHead.apply(this, arguments)
}
}
// Taken from the Node code base, internal/url.js
function urlToOptions(_url) {
const options = {
protocol: _url.protocol,
hostname:
typeof _url.hostname === 'string' && _url.hostname.startsWith('[')
? _url.hostname.slice(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 = `${_url.username}:${_url.password}`
}
return options
}
/**
* http.request and http.get signatures vary. This function
* will parse the options and callback
*
* @param {*} input first arg of http.request and http.get
* @param {*} options request opts of callback
* @param {Function} cb if present it is the callback
* @returns {Array} [options, cb]
*/
function parseRequest(input, options, cb) {
// If the first argument is a URL, merge it into the options object.
// This code is copied from Node internals.
if (typeof input === 'string') {
const urlStr = input
input = urlToOptions(new URL(urlStr))
} else if (input.constructor && input.constructor.name === 'URL') {
input = urlToOptions(input)
} else {
cb = options
options = input
input = null
}
if (typeof options === 'function') {
cb = options
options = input || {}
} else {
options = Object.assign(input || {}, options)
}
return [options, cb]
}
function wrapRequest(agent, request) {
return function wrappedRequest(input, options, cb) {
;[options, cb] = parseRequest(input, options, cb)
const reqArgs = [options, cb]
// Don't pollute metrics and calls with NR connections
const internalOnly = options && options[symbols.offTheRecord]
if (internalOnly) {
delete options[symbols.offTheRecord]
}
// If this is not a request we're recording, exit early.
const transaction = agent.tracer.getTransaction()
if (!transaction || internalOnly) {
if (!internalOnly && logger.traceEnabled()) {
const logOpts = typeof options === 'string' ? url.parse(options) : options
logger.trace(
'No transaction, not recording external to %s:%s',
logOpts.hostname || logOpts.host,
logOpts.port
)
}
return request.apply(this, reqArgs)
}
const args = agent.tracer.slice(reqArgs)
const context = this
return instrumentOutbound(agent, options, function makeRequest(opts) {
args[0] = opts
return request.apply(context, args)
})
}
}
module.exports = function initialize(agent, http, moduleName) {
if (!http) {
logger.debug('Did not get http module, not instrumenting!')
return false
}
if (process.env.FUNCTIONS_WORKER_RUNTIME) {
logger.debug('In azure functions environment, disabling core http instrumentation in favor of @azure/functions')
return false
}
const IS_HTTPS = moduleName === 'https'
// FIXME: will this ever not be called?
shimmer.wrapMethod(http, 'http', 'createServer', function wrapMethod(createServer) {
return function setDispatcher(requestListener) {
agent.environment.setDispatcher('http')
return createServer.apply(this, arguments)
}
})
// It's not a great idea to monkeypatch EventEmitter methods given how hot
// they are, but this method is simple and works with all versions of node
// supported by the module.
shimmer.wrapMethod(
http.Server && http.Server.prototype,
'http.Server.prototype',
'emit',
function wrapEmit(emit) {
const txStarter = wrapEmitWithTransaction(agent, emit, IS_HTTPS)
return function wrappedEmit(evnt) {
if (evnt === 'request') {
return txStarter.apply(this, arguments)
}
return emit.apply(this, arguments)
}
}
)
wrapResponseEnd(agent, http.ServerResponse && http.ServerResponse.prototype)
// If CAT is enabled we'll wrap `writeHead` to inject our headers.
if (agent.config.cross_application_tracer.enabled) {
shimmer.wrapMethod(
http.ServerResponse && http.ServerResponse.prototype,
'http.ServerResponse.prototype',
'writeHead',
wrapWriteHead.bind(null, agent)
)
}
const agentProto = http && http.Agent && http.Agent.prototype
shimmer.wrapMethod(http, 'http', 'request', wrapRequest.bind(null, agent))
shimmer.wrapMethod(http, 'http', 'get', wrapRequest.bind(null, agent))
shimmer.wrapMethod(
agentProto,
'http.Agent.prototype',
'createConnection',
function wrapCreateConnection(original) {
return function wrappedCreateConnection() {
const context = agent.tracer.getContext()
if (!agent.getTransaction()) {
return original.apply(this, arguments)
}
const segment = agent.tracer.createSegment({
name: 'http.Agent#createConnection',
parent: context.segment,
transaction: context.transaction
})
const args = agent.tracer.slice(arguments)
const newContext = context.enterSegment({ segment })
if (typeof args[1] === 'function') {
args[1] = agent.tracer.bindFunction(args[1], newContext, true)
}
return agent.tracer.bindFunction(original, newContext, true).apply(this, args)
}
}
)
}