newrelic
Version:
New Relic agent
871 lines (788 loc) • 26.9 kB
JavaScript
/*
* Copyright 2020 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
'use strict'
var copy = require('../util/copy')
var genericRecorder = require('../metrics/recorders/generic')
var logger = require('../logger').child({component: 'MessageShim'})
var messageTransactionRecorder = require('../metrics/recorders/message-transaction')
var props = require('../util/properties')
var TransactionShim = require('./transaction-shim')
var Shim = require('./shim') // For Shim.defineProperty
var util = require('util')
var ATTR_DESTS = require('../config/attribute-filter').DESTINATIONS
/**
* Enumeration of well-known message brokers.
*
* @readonly
* @memberof MessageShim
* @enum {string}
*/
const LIBRARY_NAMES = {
IRONMQ: 'IronMQ',
KAFKA: 'Kafka',
RABBITMQ: 'RabbitMQ',
SNS: 'SNS',
SQS: 'SQS'
}
/**
* Mapping of well-known message brokers to their distributed tracing transport
* type.
*
* @private
* @readonly
* @enum {string}
*/
const LIBRARY_TRANSPORT_TYPES = {
AMQP: TransactionShim.TRANSPORT_TYPES.AMQP,
IronMQ: TransactionShim.TRANSPORT_TYPES.IRONMQ,
Kafka: TransactionShim.TRANSPORT_TYPES.KAFKA,
RabbitMQ: TransactionShim.TRANSPORT_TYPES.AMQP
}
/**
* Enumeration of possible message broker destination types.
*
* @readonly
* @memberof MessageShim
* @enum {string}
*/
const DESTINATION_TYPES = {
EXCHANGE: 'Exchange',
QUEUE: 'Queue',
TOPIC: 'Topic'
}
/**
* Constructs a shim specialized for instrumenting message brokers.
*
* @constructor
* @extends TransactionShim
* @classdesc
* Used for instrumenting message broker client libraries.
*
* @param {Agent} agent
* The agent this shim will use.
*
* @param {string} moduleName
* The name of the module being instrumented.
*
* @param {string} resolvedName
* The full path to the loaded module.
*
* @see Shim
* @see TransactionShim
*/
function MessageShim(agent, moduleName, resolvedName) {
TransactionShim.call(this, agent, moduleName, resolvedName)
this._logger = logger.child({module: moduleName})
this._metrics = null
this._transportType = TransactionShim.TRANSPORT_TYPES.UNKNOWN
}
module.exports = MessageShim
util.inherits(MessageShim, TransactionShim)
// Add constants on the shim for message broker libraries.
MessageShim.LIBRARY_NAMES = LIBRARY_NAMES
Object.keys(LIBRARY_NAMES).forEach(function defineLibraryEnum(libName) {
Shim.defineProperty(MessageShim, libName, LIBRARY_NAMES[libName])
Shim.defineProperty(MessageShim.prototype, libName, LIBRARY_NAMES[libName])
})
// Add constants to the shim for message broker destination types.
MessageShim.DESTINATION_TYPES = DESTINATION_TYPES
Object.keys(DESTINATION_TYPES).forEach(function defineTypesEnum(type) {
Shim.defineProperty(MessageShim, type, DESTINATION_TYPES[type])
Shim.defineProperty(MessageShim.prototype, type, DESTINATION_TYPES[type])
})
MessageShim.prototype.setLibrary = setLibrary
MessageShim.prototype.recordProduce = recordProduce
MessageShim.prototype.recordConsume = recordConsume
MessageShim.prototype.recordPurgeQueue = recordPurgeQueue
MessageShim.prototype.recordSubscribedConsume = recordSubscribedConsume
// -------------------------------------------------------------------------- //
/**
* @callback MessageFunction
*
* @summary
* Used for determining information about a message either being produced or
* consumed.
*
* @param {MessageShim} shim
* The shim this function was handed to.
*
* @param {Function} func
* The produce method or message consumer.
*
* @param {string} name
* The name of the producer or consumer.
*
* @param {Array.<*>} args
* The arguments being passed into the produce method or consumer.
*
* @return {MessageSpec} The specification for the message being produced or
* consumed.
*
* @see MessageShim#recordProduce
* @see MessageShim#recordConsume
*/
/**
* @callback MessageHandlerFunction
*
* @summary
* A function that is used to extract properties from a consumed message. This
* method is handed the results of a consume call. If the consume used a
* callback, then this method will receive the arguments to the callback. If
* the consume used a promise, then this method will receive the resolved
* value.
*
* @param {MessageShim} shim
* The shim this function was handed to.
*
* @param {Function} func
* The produce method or message consumer.
*
* @param {string} name
* The name of the producer or consumer.
*
* @param {Array|*} args
* Either the arguments for the consumer callback function or the result of
* the resolved consume promise, depending on the mode of the instrumented
* method.
*
* @return {MessageSpec} The extracted properties of the consumed message.
*
* @see MessageShim#recordConsume
*/
/**
* @callback MessageConsumerWrapperFunction
*
* @summary
* Function that is used to wrap message consumer functions. Used along side
* the MessageShim#recordSubscribedConsume API method.
*
* @param {MessageShim} shim
* The shim this function was handed to.
*
* @param {Function} consumer
* The message consumer to wrap.
*
* @param {string} name
* The name of the consumer method.
*
* @param {string} queue
* The name of the queue this consumer is being subscribed to.
*
* @return {Function} The consumer method, possibly wrapped.
*
* @see MessageShim#recordSubscribedConsume
* @see MessageShim#recordConsume
*/
/**
* @interface MessageSpec
* @extends RecorderSpec
*
* @description
* The specification for a message being produced or consumed.
*
* @property {string} destinationName
* The name of the exchange or queue the message is being produced to or
* consumed from.
*
* @property {MessageShim.DESTINATION_TYPES} [destinationType=null]
* The type of the destination. Defaults to `shim.EXCHANGE`.
*
* @property {Object} [headers=null]
* A reference to the message headers. On produce, more headers will be added
* to this object which should be sent along with the message. On consume,
* cross-application headers will be read from this object.
*
* @property {string} [routingKey=null]
* The routing key for the message. If provided on consume, the routing key
* will be added to the transaction attributes as `message.routingKey`.
*
* @property {string} [queue=null]
* The name of the queue the message was consumed from. If provided on
* consume, the queue name will be added to the transaction attributes as
* `message.queueName`.
*
* @property {string} [parameters.correlation_id]
* In AMQP, this should be the correlation Id of the message, if it has one.
*
* @property {string} [parameters.reply_to]
* In AMQP, this should be the name of the queue to reply to, if the message
* has one.
*
* @property {MessageHandlerFunction} [messageHandler]
* An optional function to extract message properties from a consumed message.
* This method is only used in the consume case to pull data from the
* retrieved message.
*
* @see RecorderSpec
* @see MessageShim#recordProduce
* @see MessageShim#recordConsume
* @see MessageShim.DESTINATION_TYPES
*/
/**
* @interface MessageSubscribeSpec
* @extends MessageSpec
*
* @description
* Specification for message subscriber methods. That is, methods which
* register a consumer to start receiving messages.
*
* @property {number} consumer
* The index of the consumer in the method's arguments. Note that if the
* consumer and callback indexes point to the same argument, the argument will
* be wrapped as a consumer.
*
* @property {MessageHandlerFunction} messageHandler
* A function to extract message properties from a consumed message.
* This method is only used in the consume case to pull data from the
* retrieved message. Its return value is combined with the `MessageSubscribeSpec`
* to fully describe the consumed message.
*
* @see MessageSpec
* @see MessageConsumerWrapperFunction
* @see MessageShim#recordSubscribedConsume
*/
// -------------------------------------------------------------------------- //
/**
* Sets the vendor of the message broker being instrumented.
*
* This is used to generate the names for metrics and segments. If a string is
* passed, metric names will be generated using that.
*
* @memberof MessageShim.prototype
*
* @param {MessageShim.LIBRARY_NAMES|string} library
* The name of the message broker library. Use one of the well-known constants
* listed in {@link MessageShim.LIBRARY_NAMES} if available for the library.
*
* @see MessageShim.LIBRARY_NAMES
*/
function setLibrary(library) {
this._metrics = {
PREFIX: 'MessageBroker/',
LIBRARY: library,
PRODUCE: 'Produce/',
CONSUME: 'Consume/',
PURGE: 'Purge/',
NAMED: 'Named/',
TEMP: 'Temp'
}
if (LIBRARY_TRANSPORT_TYPES[library]) {
this._transportType = LIBRARY_TRANSPORT_TYPES[library]
}
this._logger = this._logger.child({library: library})
this.logger.trace({metrics: this._metrics}, 'Library metric names set')
}
/**
* Wraps the given properties as message producing methods to be recorded.
*
* - `recordProduce(nodule, properties, recordNamer)`
* - `recordProduce(func, recordNamer)`
*
* The resulting wrapped methods will record their executions using the messaging
* `PRODUCE` metric.
*
* @memberof MessageShim.prototype
*
* @param {Object|Function} nodule
* The source for the properties to wrap, or a single function to wrap.
*
* @param {string|Array.<string>} [properties]
* One or more properties to wrap. If omitted, the `nodule` parameter is
* assumed to be the function to wrap.
*
* @param {MessageFunction} recordNamer
* A function which specifies details of the message.
*
* @return {Object|Function} The first parameter to this function, after
* wrapping it or its properties.
*
* @see Shim#wrap
* @see Shim#record
* @see MessageSpec
* @see MessageFunction
*/
function recordProduce(nodule, properties, recordNamer) {
if (this.isFunction(properties)) {
// recordProduce(func, recordNamer)
recordNamer = properties
properties = null
}
return this.record(nodule, properties, function recordProd(shim) {
var msgDesc = recordNamer.apply(this, arguments)
if (!msgDesc) {
return null
}
var name = _nameMessageSegment(shim, msgDesc, shim._metrics.PRODUCE)
if (!shim.agent.config.message_tracer.segment_parameters.enabled) {
delete msgDesc.parameters
} else if (msgDesc.routingKey) {
msgDesc.parameters = shim.setDefaults(msgDesc.parameters, {
routing_key: msgDesc.routingKey
})
}
return {
name: name,
promise: msgDesc.promise || false,
callback: msgDesc.callback || null,
recorder: genericRecorder,
inContext: function generateCATHeaders() {
if (msgDesc.headers) {
shim.insertCATRequestHeaders(msgDesc.headers, true)
}
},
parameters: msgDesc.parameters || null,
opaque: msgDesc.opaque || false
}
})
}
/**
* Wraps the given properties as message consumers to be recorded.
*
* - `recordConsume(nodule, properties, spec)`
* - `recordConsume(func, spec)`
*
* The resulting wrapped methods will record their executions using the messaging
* `CONSUME` metric, possibly also starting a message transaction. Note that
* this should wrap the message _consumer_, to record methods which subscribe
* consumers see {@link MessageShim#recordSubscribedConsume}
*
* @memberof MessageShim.prototype
*
* @param {Object|Function} nodule
* The source for the properties to wrap, or a single function to wrap.
*
* @param {string|Array.<string>} [properties]
* One or more properties to wrap. If omitted, the `nodule` parameter is
* assumed to be the function to wrap.
*
* @param {MessageSpec|MessageFunction} spec
* The spec for the method or a function which returns the details of the
* method.
*
* @return {Object|Function} The first parameter to this function, after
* wrapping it or its properties.
*
* @see Shim#wrap
* @see Shim#record
* @see MessageShim#recordSubscribedConsume
* @see MessageSpec
* @see MessageFunction
*/
function recordConsume(nodule, properties, spec) {
if (this.isObject(properties) && !this.isArray(properties)) {
// recordConsume(func, spec)
spec = properties
properties = null
}
var DEFAULT_SPEC = {
destinationName: null,
promise: false,
callback: null,
messageHandler: null
}
if (!this.isFunction(spec)) {
spec = this.setDefaults(spec, DEFAULT_SPEC)
}
return this.wrap(nodule, properties, function wrapConsume(shim, fn, fnName) {
if (!shim.isFunction(fn)) {
shim.logger.debug('Not wrapping %s (%s) as consume', fn, fnName)
return fn
}
return function consumeRecorder() {
var parent = shim.getSegment()
if (!parent || !parent.transaction.isActive()) {
shim.logger.trace('Not recording consume, no active transaction.')
return fn.apply(this, arguments)
}
// Process the message args.
var args = shim.argsToArray.apply(shim, arguments)
var msgDesc = null
if (shim.isFunction(spec)) {
msgDesc = spec.call(this, shim, fn, fnName, args)
shim.setDefaults(msgDesc, DEFAULT_SPEC)
} else {
msgDesc = {
destinationName: null,
callback: spec.callback,
promise: spec.promise,
messageHandler: spec.messageHandler
}
var destIdx = shim.normalizeIndex(args.length, spec.destinationName)
if (destIdx !== null) {
msgDesc.destinationName = args[destIdx]
}
}
// Make the segment if we can.
if (!msgDesc) {
shim.logger.trace('Not recording consume, no message descriptor.')
return fn.apply(this, args)
}
const name = _nameMessageSegment(shim, msgDesc, shim._metrics.CONSUME)
// Adds details needed by createSegment when used with a spec
msgDesc.name = name
msgDesc.recorder = genericRecorder
msgDesc.parent = parent
var segment = shim.createSegment(msgDesc)
var getParams = shim.agent.config.message_tracer.segment_parameters.enabled
var resHandler = shim.isFunction(msgDesc.messageHandler)
? msgDesc.messageHandler : null
var cbIdx = shim.normalizeIndex(args.length, msgDesc.callback)
if (cbIdx !== null) {
shim.bindCallbackSegment(args, cbIdx, segment)
// If we have a callback and a results handler, then wrap the callback so
// we can call the results handler and get the message properties.
if (resHandler) {
shim.wrap(args, cbIdx, function wrapCb(shim, cb, cbName) {
if (shim.isFunction(cb)) {
return function cbWrapper() {
var cbArgs = shim.argsToArray.apply(shim, arguments)
var msgProps = resHandler.call(this, shim, cb, cbName, cbArgs)
if (getParams && msgProps && msgProps.parameters) {
shim.copySegmentParameters(segment, msgProps.parameters)
}
return cb.apply(this, arguments)
}
}
})
}
}
// Call the method in the context of our segment.
var ret = shim.applySegment(fn, segment, true, this, args)
// Intercept the promise to handle the result.
if (resHandler && ret && msgDesc.promise && shim.isPromise(ret)) {
ret = ret.then(function interceptValue(res) {
var msgProps = resHandler.call(this, shim, fn, fnName, res)
if (getParams && msgProps && msgProps.parameters) {
shim.copySegmentParameters(segment, msgProps.parameters)
}
return res
})
}
return ret
}
})
}
/**
* Wraps the given properties as queue purging methods.
*
* - `recordPurgeQueue(nodule, properties, spec)`
* - `recordPurgeQueue(func, spec)`
*
* @memberof MessageShim.prototype
*
* @param {Object|Function} nodule
* The source for the properties to wrap, or a single function to wrap.
*
* @param {string|Array.<string>} [properties]
* One or more properties to wrap. If omitted, the `nodule` parameter is
* assumed to be the function to wrap.
*
* @param {RecorderSpec} spec
* The specification for this queue purge method's interface.
*
* @param {string} spec.queue
* The name of the queue being purged.
*
* @return {Object|Function} The first parameter to this function, after
* wrapping it or its properties.
*
* @see Shim#wrap
* @see Shim#record
* @see RecorderSpec
*/
function recordPurgeQueue(nodule, properties, spec) {
if (!nodule) {
this.logger.debug('Not wrapping non-existent nodule.')
return nodule
}
// Sort out the parameters.
if (!this.isString(properties) && !this.isArray(properties)) {
// recordPurgeQueue(nodule, spec)
spec = properties
properties = null
}
// Fill the spec with defaults.
var specIsFunction = this.isFunction(spec)
if (!specIsFunction) {
spec = this.setDefaults(spec, {
queue: null,
callback: null,
promise: false,
internal: false
})
}
return this.record(nodule, properties, function purgeRecorder(shim, fn, name, args) {
var descriptor = spec
if (specIsFunction) {
descriptor = spec.apply(this, arguments)
}
var queue = descriptor.queue
if (shim.isNumber(queue)) {
var queueIdx = shim.normalizeIndex(args.length, descriptor.queue)
queue = args[queueIdx]
}
return {
name: _nameMessageSegment(shim, {
destinationType: shim.QUEUE,
destinationName: queue
}, shim._metrics.PURGE),
recorder: genericRecorder,
callback: descriptor.callback,
promise: descriptor.promise,
internal: descriptor.internal
}
})
}
/**
* Wraps the given properties as message subscription methods.
*
* - `recordSubscribedConsume(nodule, properties, spec)`
* - `recordSubscribedConsume(func, spec)`
*
* Message subscriber methods are ones used to register a message consumer with
* the message library. See {@link MessageShim#recordConsume} for recording
* the consumer itself.
*
* Note that unlike most `shim.recordX` methods, this method will call the
* `spec.wrapper` method even if no transaction is active.
*
* @memberof MessageShim.prototype
*
* @param {Object|Function} nodule
* The source for the properties to wrap, or a single function to wrap.
*
* @param {string|Array.<string>} [properties]
* One or more properties to wrap. If omitted, the `nodule` parameter is
* assumed to be the function to wrap.
*
* @param {MessageSubscribeSpec} spec
* The specification for this subscription method's interface.
*
* @return {Object|Function} The first parameter to this function, after
* wrapping it or its properties.
*
* @see Shim#wrap
* @see Shim#record
* @see MessageShim#recordConsume
* @see MessageSubscribeSpec
*/
function recordSubscribedConsume(nodule, properties, spec) {
if (!nodule) {
this.logger.debug('Not wrapping non-existent nodule.')
return nodule
}
// Sort out the parameters.
if (this.isObject(properties) && !this.isArray(properties)) {
// recordSubscribedConsume(nodule, spec)
spec = properties
properties = null
}
// Fill the spec with defaults.
spec = this.setDefaults(spec, {
name: null,
destinationName: null,
destinationType: null,
consumer: null,
callback: null,
messageHandler: null,
promise: false
})
// Make sure our spec has what we need.
if (!this.isFunction(spec.messageHandler)) {
this.logger.debug('spec.messageHandler should be a function')
return nodule
} else if (!this.isNumber(spec.consumer)) {
this.logger.debug('spec.consumer is required for recordSubscribedConsume')
return nodule
}
var destNameIsArg = this.isNumber(spec.destinationName)
// Must wrap the subscribe method independently to ensure that we can wrap
// the consumer regardless of transaction state.
var wrapped = this.wrap(nodule, properties, function wrapSubscribe(shim, fn) {
if (!shim.isFunction(fn)) {
return fn
}
return function wrappedSubscribe() {
var args = shim.argsToArray.apply(shim, arguments)
var queueIdx = shim.normalizeIndex(args.length, spec.queue)
var consumerIdx = shim.normalizeIndex(args.length, spec.consumer)
var queue = queueIdx === null ? null : args[queueIdx]
var destName = null
if (destNameIsArg) {
var destNameIdx = shim.normalizeIndex(args.length, spec.destinationName)
if (destNameIdx !== null) {
destName = args[destNameIdx]
}
}
if (consumerIdx !== null) {
args[consumerIdx] = shim.wrap(
args[consumerIdx],
makeWrapConsumer(queue, destName)
)
}
return fn.apply(this, args)
}
})
// Wrap the subscriber with segment creation.
return this.record(wrapped, properties, function recordSubscribe(shim, fn, name, args) {
// Make sure the specified consumer and callback indexes do not overlap.
// This could happen for instance if the function signature is
// `fn(consumer [, callback])` and specified as `consumer: shim.FIRST`,
// `callback: shim.LAST`.
var consumerIdx = shim.normalizeIndex(args.length, spec.consumer)
var cbIdx = shim.normalizeIndex(args.length, spec.callback)
if (cbIdx === consumerIdx) {
cbIdx = null
}
return {
name: spec.name || name,
callback: cbIdx,
promise: spec.promise,
stream: false,
internal: false
}
})
function makeWrapConsumer(queue, destinationName) {
var msgDescDefaults = copy.shallow(spec)
if (destNameIsArg && destinationName != null) {
msgDescDefaults.destinationName = destinationName
}
if (queue != null) {
msgDescDefaults.queue = queue
}
return function wrapConsumer(shim, consumer, cName) {
if (!shim.isFunction(consumer)) {
return consumer
}
return shim.bindCreateTransaction(function createConsumeTrans() {
// If there is no transaction or we're in a pre-existing transaction,
// then don't do anything. Note that the latter should never happen.
var args = shim.argsToArray.apply(shim, arguments)
var tx = shim.tracer.getTransaction()
if (!tx || tx.baseSegment) {
shim.logger.debug({transaction: !!tx}, 'Failed to start message transaction.')
return consumer.apply(this, args)
}
var msgDesc = spec.messageHandler.call(this, shim, consumer, cName, args)
// If message could not be handled, immediately kill this transaction.
if (!msgDesc) {
shim.logger.debug('No description for message, cancelling transaction.')
tx.setForceIgnore(true)
tx.end()
return consumer.apply(this, args)
}
// Derive the transaction name.
shim.setDefaults(msgDesc, msgDescDefaults)
var txName = _nameMessageTransaction(shim, msgDesc)
tx.setPartialName(txName)
tx.baseSegment = shim.createSegment({
name: tx.getFullName(),
recorder: messageTransactionRecorder
})
// Add would-be baseSegment attributes to transaction trace
for (var key in msgDesc.parameters) {
if (props.hasOwn(msgDesc.parameters, key)) {
tx.trace.attributes.addAttribute(
ATTR_DESTS.NONE,
'message.parameters.' + key,
msgDesc.parameters[key])
tx.baseSegment.attributes.addAttribute(
ATTR_DESTS.NONE,
'message.parameters.' + key,
msgDesc.parameters[key]
)
}
}
// If we have a routing key, add it to the transaction. Note that it is
// camel cased here, but snake cased in the segment parameters.
if (!shim.agent.config.high_security) {
if (msgDesc.routingKey) {
tx.trace.attributes.addAttribute(
ATTR_DESTS.TRANS_COMMON,
'message.routingKey',
msgDesc.routingKey
)
tx.baseSegment.addSpanAttribute(
'message.routingKey',
msgDesc.routingKey
)
}
if (shim.isString(msgDesc.queue)) {
tx.trace.attributes.addAttribute(
ATTR_DESTS.TRANS_COMMON,
'message.queueName',
msgDesc.queue
)
tx.baseSegment.addSpanAttribute(
'message.queueName',
msgDesc.queue
)
}
}
if (msgDesc.headers) {
shim.handleCATHeaders(msgDesc.headers, tx.baseSegment, shim._transportType)
}
shim.logger.trace('Started message transaction %s named %s', tx.id, txName)
// Execute the original function and attempt to hook in the transaction
// finish.
var ret = null
try {
ret = shim.applySegment(consumer, tx.baseSegment, true, this, args)
} finally {
if (shim.isPromise(ret)) {
shim.logger.trace('Got a promise, attaching tx %s ending to promise', tx.id)
ret = shim.interceptPromise(ret, endTransaction)
} else if (!tx.handledExternally) {
// We have no way of knowing when this transaction ended! ABORT!
shim.logger.trace('Immediately ending message tx %s', tx.id)
setImmediate(endTransaction)
}
}
return ret
function endTransaction() {
tx.finalizeName(null) // Use existing partial name.
tx.end()
}
}, {
type: shim.MESSAGE,
nest: true
})
}
}
}
// -------------------------------------------------------------------------- //
/**
* Constructs a message segment name from the given message descriptor.
*
* @private
*
* @param {MessageShim} shim - The shim the segment will be constructed by.
* @param {MessageSpec} msgDesc - The message descriptor.
* @param {string} action - Produce or consume?
*
* @return {string} The generated name of the message segment.
*/
function _nameMessageSegment(shim, msgDesc, action) {
var name =
shim._metrics.PREFIX + shim._metrics.LIBRARY + '/' +
(msgDesc.destinationType || shim.EXCHANGE) + '/' + action
if (msgDesc.destinationName) {
name += shim._metrics.NAMED + msgDesc.destinationName
} else {
name += shim._metrics.TEMP
}
return name
}
function _nameMessageTransaction(shim, msgDesc) {
var name =
shim._metrics.LIBRARY + '/' +
(msgDesc.destinationType || shim.EXCHANGE) + '/'
if (msgDesc.destinationName) {
name += shim._metrics.NAMED + msgDesc.destinationName
} else {
name += shim._metrics.TEMP
}
return name
}