UNPKG

newrelic

Version:
397 lines (360 loc) 15 kB
/* * Copyright 2025 New Relic Corporation. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ 'use strict' // eslint-disable-next-line n/no-unsupported-features/node-builtins const { tracingChannel } = require('node:diagnostics_channel') const cat = require('#agentlib/util/cat.js') // Used for the `traceCallback` work. // This can be removed when we add true support into orchestrion const makeCall = (fn) => (...args) => fn.call(...args) const ArrayPrototypeAt = makeCall(Array.prototype.at) const ArrayPrototypeSplice = makeCall(Array.prototype.splice) // End temp work /** * The baseline parameters available to all subscribers. * * @typedef {object} SubscriberParams * @property {object} agent A New Relic Node.js agent instance. * @property {object} logger An agent logger instance. * @property {string} packageName The package name being instrumented. * This is what a developer would provide to the `require` function. * @property {string} channelName A unique name for the diagnostics channel * that will be created and monitored. */ /** * @property {object} agent A New Relic Node.js agent instance. * @property {object} logger An agent logger instance. * @property {object} config The agent configuration object. * @property {string} packageName The name of the module being instrumented. * This is the same string one would pass to the `require` function. * @property {string} channelName A unique name for the diagnostics channel * that will be registered. * @property {string[]} [events=[]] Set of tracing channel event names to * register handlers for. For any name in the set, a corresponding method * must exist on the subscriber instance. The method will be passed the * event object. Possible event names are `start`, `end`, `asyncStart`, * `asyncEnd`, and `error`. * * See {@link https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel} * @property {boolean} [opaque=false] If true, any children segments will not be created. * @property {boolean} [internal=false] If true, any children segments from the same library * will not be created. * @property {string} [prefix='orchestrion:'] String to prepend to diagnostics * channel event names. This provides a namespace for the events we are * injecting into a module. * @property {boolean} [requireActiveTx=true] If true, the subscriber will only handle events * when there is an active transaction. * @property {boolean} [propagateContext=false] If true, it will bind `asyncStart` to the store * and re-propagate the active context. It will also attach the `transaction` to the event in * `start.bindStore`. This is used for functions that queue async code and context is lost. * @property {string} id A unique identifier for the subscriber, combining the prefix, package * name, and channel name. * @property {TracingChannel} channel The tracing channel instance this subscriber will be monitoring. * @property {AsyncLocalStorage} store The async local storage instance used for context management. * @property {number} [callback=null] Position of callback if it needs to be wrapped for instrumentation. * -1 means last argument. */ class Subscriber { /** * @param {SubscriberParams} params the subscriber constructor params */ constructor({ agent, logger, packageName, channelName }) { this.agent = agent this.logger = logger.child({ component: `${packageName}-subscriber` }) this.config = agent.config this.packageName = packageName this.channelName = channelName this.events = [] this.opaque = false this.internal = false this.prefix = 'orchestrion:' this.requireActiveTx = true this.propagateContext = false this.id = `${this.prefix}${this.packageName}:${this.channelName}` this.channel = tracingChannel(this.id) this.store = agent.tracer._contextManager._asyncLocalStorage this.callback = null } shouldCreateSegment(parent) { return !(parent?.opaque || (this.internal && this.packageName === parent?.shimId) ) } /** * Note: This is a temporary patch until we can get the correct implementation * of `tracingChannel.traceCallback` into orchestrion-js. * * This will wrap a callback at a given position and reassign the callback argument to the wrapped one * * @param {number} position index of the callback, you can specify -1 to be the last * @param {object} context the event passed to the tracing channel hooks */ traceCallback(position, context) { this.logger.trace('Wrapping the callback at position %s', position) const { asyncStart, asyncEnd, error } = this.channel function wrappedCallback(err, res) { // assigning a boolean to the context so we know that the // `error`, `asyncStart`, and `asyncEnd` are coming from the wrapped callback context.callback = true if (err) { context.error = err error.publish(context) } else { context.result = res } // Using runStores here enables manual context failure recovery asyncStart.runStores(context, () => { try { if (callback) { const cbResult = Reflect.apply(callback, this, arguments) context.cbResult = cbResult return cbResult } } finally { asyncEnd.publish(context) } }) } const callback = ArrayPrototypeAt(context.arguments, position) if (typeof callback !== 'function') { this.logger.trace('Callback is not present, not wrapping') } else { ArrayPrototypeSplice(context.arguments, position, 1, wrappedCallback) } } /** * Wraps an event emitter and runs the wrap in the new context * If the event is `end` or `error`, it'll touch the active segment. * * @param {object} params to function * @param {Array} params.args arguments to function * @param {number} params.index index of argument to wrap * @param {string} [params.name] name of emit function, defaults to 'emit' * @param {Context} params.ctx context to bind wrapped emit to */ wrapEventEmitter({ args, index, name = 'emit', ctx }) { const orig = args[index][name] const self = this function wrapEmit(...emitArgs) { const ctx = self.agent.tracer.getContext() const [evnt] = emitArgs if (evnt === 'end' || evnt === 'error') { ctx?.segment?.touch() } return orig.apply(this, emitArgs) } args[index][name] = this.agent.tracer.bindFunction(wrapEmit, ctx, false) } /** * Creates a segment with a name, parent, transaction and optional recorder. * If the segment is successfully created, it will be started and added to the context. * @param {object} params - Parameters for creating the segment * @param {string} params.name - The name of the segment * @param {object} [params.recorder] - Optional recorder for the segment * @param {Context} params.ctx - The context containing the parent segment and transaction * @returns {Context} - The updated context with the new segment or existing context if segment creation fails */ createSegment({ name, recorder, ctx }) { const parent = ctx?.segment if (this.shouldCreateSegment(parent) === false) { this.logger.trace('Skipping segment creation for %s, %s(parent) is of the same package: %s and incoming segment is marked as internal', name, parent?.name, this.packageName) return ctx } const segment = this.agent.tracer.createSegment({ name, parent, recorder, transaction: ctx?.transaction, }) if (segment) { segment.opaque = this.opaque segment.shimId = this.packageName segment.start() this.logger.trace('Created segment %s, returning new context', name) this.addAttributes(segment) const newCtx = ctx.enterSegment({ segment }) return newCtx } else { this.logger.trace('Failed to create segment for %s, returning existing context', name) return ctx } } /** * By default this is a no-op, but can be overridden by subclasses * @param {Segment} segment - The segment to which attributes will be added * @returns {void} */ addAttributes(segment) { } /** * Not all subscribers need to change the context on an event. * This is defined on base to fulfill those use cases. * @param {object} data event passed to handler * @param {Context} ctx context passed to handler * @returns {Context} either new context or existing */ handler(data, ctx) { return ctx } /** * Checks if the subscriber is enabled based on the agent's configuration. * @returns {boolean} if subscriber is enabled */ get enabled() { return this.config.instrumentation[this.packageName].enabled === true } /** * Enables the subscriber by binding the store to the channel and setting up the handler. * If the subscriber requires an active transaction, it will check the context before passing the event to the handler. * @returns {void} The `bindStore` function with our handler. */ enable() { /** * Event handler for processing incoming events. * @param {object} data Event data * @returns {Context} The context after processing the event */ const handler = (data) => { // only wrap the callback if a subscriber has a callback property defined if (this.callback !== null) { this.traceCallback(this.callback, data) } const ctx = this.agent.tracer.getContext() if (this.requireActiveTx && !ctx?.transaction?.isActive()) { this.logger.trace('Not recording event for %s, transaction is not active', this.package) return ctx } const result = this.handler(data, ctx) // we cannot rely on the context manager to obtain the active segment // in the `asyncStart` and `asyncEnd` events. This is because other instrumented // functions are being executed at times. so we assign the active segment on the data // so it can be used later to properly touch the segment in `asyncStart` and `asyncEnd` if (this.callback !== null) { data.segment = result?.segment this.logger.trace('Adding segment %s to event context', data?.segment?.name) } // attach to event as it will be used to re-bind context in `asyncStart.bindStore` if (this.propagateContext) { data.transaction = result?.transaction } return result } this.channel.start.bindStore(this.store, handler) if (this.propagateContext) { this.channel.asyncStart.bindStore(this.store, (data) => { const { transaction, segment } = data const ctx = this.agent.tracer.getContext() if (!(transaction && segment)) { this.logger.trace('No active transaction/segment, returning existing context') return ctx } const newCtx = ctx.enterSegment({ transaction, segment }) return newCtx }) } } /** * Disables the subscriber by unbinding the store from the channel. */ disable() { this.channel.start.unbindStore(this.store) if (this.propagateContext) { this.channel.asyncStart.unbindStore(this.store) } } /** * This should only be used for callback based functions to touch the segment for the function * that implements a callback. * @param {object} data event passed to asyncStart hook */ asyncStart(data) { const ctx = this.agent.tracer.getContext() if (data.callback !== true || this.internal === true || (this.requireActiveTx && !ctx?.transaction?.isActive())) { this.logger.trace('Not touching parent in asyncStart for %s, transaction is not active? %s, segment is internal? %s, or no callback to bind', this.id, ctx?.transaction?.isActive(), this.internal) return } this.logger.trace('touching segment %s, in asyncStart', data?.segment?.name) data?.segment?.touch() } /** * Common handler for when async events end. * It gets the context and touches the segment if it exists. * @param {object} data event passed to asyncEnd hook */ asyncEnd(data) { const ctx = this.agent.tracer.getContext() if (this.internal === true) { this.logger.trace('asyncEnd occurring for %s internal event, not touching segment', this.id) return } if (data?.callback === true) { this.logger.trace('touching callback segment %s, in asyncEnd', data?.segment?.name) data?.segment?.touch() } else { this.logger.trace('touching segment %s, in asyncEnd', ctx?.segment?.name) ctx?.segment?.touch() } } end() { const ctx = this.agent.tracer.getContext() ctx?.segment?.touch() } /** * Handles injecting w3c tracecontext in outgoing headers. If DT is disabled, and CAT is enabled * it properly handles CAT. * * **Note**: This passes in the trace, segment and trace flags manually because this is called in the `start` * right before a function is bound to context but segment is created for the function. * * @param {object} params to function * @param {Context} params.ctx current context, not yet bound to context manager * @param {object} params.headers headers for outgoing call * @param {boolean} params.useMqNames flag to indicate use the MQ specific CAT header names * @returns {void} */ insertDTHeaders({ ctx, headers, useMqNames } = {}) { const crossAppTracingEnabled = this.config.cross_application_tracer.enabled const distributedTracingEnabled = this.config.distributed_tracing.enabled if (!distributedTracingEnabled && !crossAppTracingEnabled) { this.logger.trace('Distributed Tracing and CAT are both disabled, not adding headers.') return } if (!headers) { this.logger.debug('Missing headers object, not adding headers!') return } const tx = ctx?.transaction if (!tx?.isActive()) { this.logger.trace('No active transaction found, not adding headers.') return } if (distributedTracingEnabled) { // we have to pass in traceId, segment id, and hard code traceFlags to 1 // because we're inserting headers right before the original function is bound. const traceFlags = tx.isSampled() === true ? 1 : 0 tx.insertDistributedTraceHeaders(headers, null, { traceId: tx.traceId, spanId: ctx?.segment?.id, traceFlags }) } else { cat.addCatHeaders(this.config, tx, headers, useMqNames) } } /* * Subscribes to the events defined in the `events` array. */ subscribe() { this.subscriptions = this.events.reduce((events, curr) => { events[curr] = this[curr].bind(this) return events }, {}) this.channel.subscribe(this.subscriptions) } /** * Unsubscribes from the events defined in the `events` array.. */ unsubscribe() { this.channel.unsubscribe(this.subscriptions) this.subscriptions = null } } module.exports = Subscriber