diff --git a/lib/context-manager/async-local-context-manager.js b/lib/context-manager/async-local-context-manager.js index 4bbef7190e..4b587c52dc 100644 --- a/lib/context-manager/async-local-context-manager.js +++ b/lib/context-manager/async-local-context-manager.js @@ -24,6 +24,16 @@ class AsyncLocalContextManager { this._asyncLocalStorage = new AsyncLocalStorage() } + /** + * The underlying local storage object. + * + * @see https://nodejs.org/api/async_context.html#class-asynclocalstorage + * @returns {AsyncLocalStorage} + */ + get store() { + return this._asyncLocalStorage + } + /** * Get the currently active context. * diff --git a/lib/core-instrumentation.js b/lib/core-instrumentation.js index 7c04bdba2b..517f044cdd 100644 --- a/lib/core-instrumentation.js +++ b/lib/core-instrumentation.js @@ -47,9 +47,5 @@ module.exports = { timers: { type: InstrumentationDescriptor.TYPE_GENERIC, file: 'timers.js' - }, - zlib: { - type: InstrumentationDescriptor.TYPE_GENERIC, - file: 'zlib.js' } } diff --git a/lib/instrumentation/core/zlib.js b/lib/instrumentation/core/zlib.js deleted file mode 100644 index 0527495faa..0000000000 --- a/lib/instrumentation/core/zlib.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2020 New Relic Corporation. All rights reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -'use strict' - -const recorder = require('../../metrics/recorders/generic') -const { RecorderSpec } = require('../../shim/specs') - -module.exports = initialize - -const methods = ['deflate', 'deflateRaw', 'gzip', 'gunzip', 'inflate', 'inflateRaw', 'unzip'] - -function initialize(agent, zlib, moduleName, shim) { - shim.record(zlib, methods, recordZLib) - - function recordZLib(shim, fn, name) { - return new RecorderSpec({ name: `zlib.${name}`, callback: shim.LAST, recorder }) - } -} diff --git a/lib/subscriber-configs.js b/lib/subscriber-configs.js index b52599868f..4a90e8b2ea 100644 --- a/lib/subscriber-configs.js +++ b/lib/subscriber-configs.js @@ -45,7 +45,10 @@ const subscribers = { ...require('./subscribers/redis-client/config'), ...require('./subscribers/undici/config'), ...require('./subscribers/when/config'), - ...require('./subscribers/winston/config') + ...require('./subscribers/winston/config'), + + // Node.js Core modules: + ...require('./subscribers/core/zlib/config') } module.exports = subscribers diff --git a/lib/subscribers/core/zlib/config.js b/lib/subscribers/core/zlib/config.js new file mode 100644 index 0000000000..8bb11c7b9b --- /dev/null +++ b/lib/subscribers/core/zlib/config.js @@ -0,0 +1,15 @@ +/* + * Copyright 2026 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +module.exports = { + zlib: [ + { + path: './core/zlib/tc.js', + instrumentations: [] + } + ] +} diff --git a/lib/subscribers/core/zlib/tc.js b/lib/subscribers/core/zlib/tc.js new file mode 100644 index 0000000000..be1fdad33d --- /dev/null +++ b/lib/subscribers/core/zlib/tc.js @@ -0,0 +1,55 @@ +/* + * Copyright 2026 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +const zlib = require('node:zlib') +// eslint-disable-next-line n/no-unsupported-features/node-builtins +const { tracingChannel } = require('node:diagnostics_channel') + +const { + TracingChannelSubscription, + TracingChannelSubscriber +} = require('#agentlib/subscribers/tracing-channel-subscriber.js') +const { wrapMethod } = require('#agentlib/subscribers/wrap-method.js') + +const methods = [ + 'deflate', + 'deflateRaw', + 'gzip', + 'gunzip', + 'inflate', + 'inflateRaw', + 'unzip' +] +const subscriptions = [] +for (const method of methods) { + // First we need to monkey-patch the module methods to generating + // tracing channel events. + const chanName = `nr_core:zlib:${method}` + wrapMethod({ + module: zlib, + methodName: method, + wrapper(originalMethod, methodName) { + const chan = tracingChannel(chanName) + const data = { segmentName: `zlib.${methodName}` } + return function wrappedMethod(...args) { + chan.traceCallback(originalMethod, -1, data, this, ...args) + } + } + }) + + // Now we can create a subscription for the channel we created. + const sub = new TracingChannelSubscription({ channel: chanName }) + subscriptions.push(sub) +} + +class ZlibSubscriber extends TracingChannelSubscriber { + constructor({ agent, logger }) { + super({ agent, logger, packageName: 'zlib', subs: subscriptions }) + } +} + +module.exports = ZlibSubscriber diff --git a/lib/subscribers/subscriber.js b/lib/subscribers/subscriber.js new file mode 100644 index 0000000000..47481e4a59 --- /dev/null +++ b/lib/subscribers/subscriber.js @@ -0,0 +1,124 @@ +/* + * Copyright 2026 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +/** + * This is an interface class. It defines the methods each subclass _must_ + * implement and override in order for the diagnostics channel or tracing + * channel subscriptions to work. + * + * @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} id An alias for `packageName`. + * + * @private + * @interface + */ +class Subscriber { + #agent + #config + #logger + #packageName + + constructor({ agent, logger, packageName }) { + this.#agent = agent + this.#config = agent.config + this.#logger = logger.child({ component: `${packageName}-subscriber` }) + this.#packageName = packageName + } + + get [Symbol.toStringTag]() { + return 'Subscriber' + } + + get agent() { + return this.#agent + } + + get config() { + return this.#config + } + + get id() { + return this.#packageName + } + + get logger() { + return this.#logger + } + + get packageName() { + return this.#packageName + } + + /** + * Indicates if the subscription should be enabled or not. The most likely + * scenario is that an implementation will consult the agent configuration + * to determine the result. + * + * @returns {boolean} Subscriber is enabled or not. + */ + get enabled() { + throw Error('enabled is not implemented on class: ' + this.constructor.name) + } + + /** + * Implementations should utilize subclass specific configuration or logic + * to enable the subscriber. This is basically a start-up lifecycle hook + * that the implementation can use to perform necessary actions, e.g. + * creating an asynchronous context and binding it to an appropriate channel. + * + * @returns {void | Function | boolean} Result of the enablement. Not likely + * to be used. + */ + enable() { + throw Error('enable is not implemented on class: ' + this.constructor.name) + } + + /** + * The inverse of the `enable` method. It's basically an agent shutdown + * lifecycle hook. Any clean up logic required as a result of the work + * performed in the `enable` method should be hosted here. + * + * @returns {void | boolean} Result of the disablement. Not likely to be + * used. + */ + disable() { + throw Error('disable is not implemented on class: ' + this.constructor.name) + } + + /** + * Classes must implement this method. It is expected to read some + * configuration data, specific to the subclass, and utilize it to + * perform the channel subscriptions. + * + * @returns {void} + * + * @example A basic "Diagnostics Channel" based method. + * const dc = require('node:diagnostics_channel') + * for (const sub of this.#subscriptions) { + * dc.subscribe(sub.channelName, sub.hook.bind(this)) + * } + */ + subscribe() { + throw Error('subscribe is not implemented on class: ' + this.constructor.name) + } + + /** + * The inverse of the `subscribe` method. This should iterate through the + * subscribed channels and issue any unsubscribe and clean-up logic for them. + * + * @returns {void} + */ + unsubscribe() { + throw Error('unsubscribe is not implemented on class: ' + this.constructor.name) + } +} + +module.exports = Subscriber diff --git a/lib/subscribers/tracing-channel-subscriber.js b/lib/subscribers/tracing-channel-subscriber.js new file mode 100644 index 0000000000..2f9b7db561 --- /dev/null +++ b/lib/subscribers/tracing-channel-subscriber.js @@ -0,0 +1,281 @@ +/* + * Copyright 2026 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +/* eslint-disable n/no-unsupported-features/node-builtins */ + +const dc = require('node:diagnostics_channel') +const Subscriber = require('./subscriber.js') + +/** + * A `TracingChannelSubscription` is an object that provides the channel name + * and event handlers for a tracing channel. It provides an easy to validate + * object with access to necessary event handlers. + */ +class TracingChannelSubscription { + #channel + #start + #end + #asyncStart + #asyncEnd + #error + + /** + * Create a new instance. All event handlers are optional. Any event handler + * not provided will be registered to a no-operation function. + * + * @param {object} params Constructor parameters object. + * @param {string} params.channel The name of the tracing channel to monitor. + * @param {Function} [params.start] The callback to invoke on start events. + * @param {Function} [params.end] The callback to invoke on end events. + * @param {Function} [params.asyncStart] The callback to invoke on asyncStart events. + * @param {Function} [params.asyncEnd] The callback to invoke on asyncEnd events. + * @param {Function} [params.error] The callback to invoke on error events. + */ + constructor ({ channel, start, end, asyncStart, asyncEnd, error }) { + this.#channel = channel + + // It's annoying that we have to do it this way. If we were to use + // `Object.defineProperties` then we wouldn't get IDE/editor support + // for the methods. + this.#start = start ?? noop + this.#end = end ?? noop + this.#asyncStart = asyncStart ?? noop + this.#asyncEnd = asyncEnd ?? noop + this.#error = error ?? noop + + function noop() {} + } + + get [Symbol.toStringTag]() { + return 'TracingChannelSubscription' + } + + /** + * The name of the channel this subscription targets. It should be the + * fully qualified channel name, sans any event name or `tracing:` prefix. + * + * @example + * const chan = diagnosticsChannel.tracingChannel('foo:bar:baz') + * // name = `tracing:foo:bar:baz` + * const sub = new TracingChannelSubscription({ channel: 'foo:bar:baz' }) + * console.log(sub.channelName) // "foo:bar:baz" + * + * @returns {string} The tracing channel name. + */ + get channelName() { + return this.#channel + } + + /** + * Function to handle `start` events. + * + * @returns {Function} Start event handler. + */ + get start() { + return this.#start + } + + /** + * Function to handle `end` events. + * + * @returns {Function} End event handler. + */ + get end() { + return this.#end + } + + /** + * Function to handle `asyncStart` events. + * + * @returns {Function} Async start event handler. + */ + get asyncStart() { + return this.#asyncStart + } + + /** + * Function to handle `asyncEnd` events. + * + * @returns {Function} Async end event handler. + */ + get asyncEnd() { + return this.#asyncEnd + } + + /** + * Function to handle `error` events. + * + * @returns {Function} Error event handler. + */ + get error() { + return this.#error + } +} + +/** + * A `TracingChannelSubscriber` is used to interact with libraries that publish + * [Tracing Channel]{@link https://nodejs.org/docs/latest/api/diagnostics_channel.html#class-tracingchannel} + * instances. A tracing channel (TC) is a collection of diagnostics channels, + * where each channel corresponds to a specific event in the lifecycle of a + * traced operation. + * + * `TracingChannelSubscriber` names segments according to the `segmentName` + * field present in the event's data object. For example, the second parameter + * of `channel.traceSync` is a data context object. That object should + * contain a `segmentName` field to provide the name for the segment. If it + * is not present, the value `unknown` will be used. Similarly, if using + * `channel.traceCallback`, a segment name may be provided via `callbackName` + * in the same data object. + * + * To be clear: this is meant to be used with libraries that publish their own + * channels. Not with libraries that we have dynamically patched with injected + * tracing channels. For those cases, use the class exported from `./base.js`, + * or one of the subclasses of it. However, it may also be used for modules + * where we have performed traditional monkey-patching without rewriting + * the module's code via tools like Orchestrion-js. + */ +class TracingChannelSubscriber extends Subscriber { + #tcSubs = [] + #registeredSubs = [] + #events = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] + + constructor({ agent, logger, packageName, subs = [] }) { + super({ agent, logger, packageName }) + this.subscriptions = subs + } + + get [Symbol.toStringTag]() { + return 'TracingChannelSubscriber' + } + + /** + * Define the object that contains the subscription channel name and + * event callbacks. + * + * @param {TracingChannelSubscription[]} tcSubs An object with event listeners + * and the channel name. + */ + set subscriptions(tcSubs) { + const validated = [] + for (const sub of tcSubs) { + if (Object.prototype.toString.call(sub) !== '[object TracingChannelSubscription]') { + this.logger.warn('attempted to set subscriptions with an invalid object') + return + } + validated.push(sub) + } + Array.prototype.push.apply(this.#tcSubs, validated) + } + + /** + * Whether the instance is enabled or not. This is accomplished by matching + * the key name exported from the instrumentations `config.js` with the + * `packageName` provided at construction. + * + * @returns {boolean} `true` for an enabled subscriber. + */ + get enabled() { + return this.config.instrumentation[this.id].enabled === true + } + + createSegment({ name, ctx }) { + const parent = ctx.segment + const segment = this.agent.tracer.createSegment({ + name, + parent, + transaction: ctx.transaction + }) + + if (segment) { + segment.start() + return ctx.enterSegment({ segment }) + } + return ctx + } + + enable() { + for (const sub of this.#tcSubs) { + // We are not attempting to create a new tracing channel here. The + // `tracingChannel` function will return the existing channel object, + // which we need in order to bind storage hook functions to. + const chan = dc.tracingChannel(sub.channelName) + + chan.start.bindStore( + this.agent.tracer.asyncStore, + (data) => { + const ctx = this.agent.tracer.getContext() + if (ctx?.transaction?.isActive() !== true) { + return ctx + } + const segmentName = data.segmentName || 'unknown' + return this.createSegment({ name: segmentName, ctx }) + } + ) + + chan.asyncStart.bindStore( + this.agent.tracer.asyncStore, + (data) => { + const ctx = this.agent.tracer.getContext() + if (ctx?.transaction?.isActive() !== true) { + return ctx + } + + const name = `Callback: ${data.callbackName || ''}` + return this.createSegment({ name, ctx }) + } + ) + } + } + + disable() { + return true + } + + subscribe() { + for (const sub of this.#tcSubs) { + const listener = {} + for (const event of this.#events) { + listener[event] = isEndEvent(event) === true + ? endHandler(this, sub[event]) + : basicHandler(this, sub[event]) + } + + const chan = dc.tracingChannel(sub.channelName) + chan.subscribe(listener) + this.#registeredSubs.push([chan, listener]) + } + } + + unsubscribe() { + for (const [chan, listener] of this.#registeredSubs) { + chan.unsubscribe(listener) + } + } +} + +function isEndEvent(event) { + return ['end', 'asyncEnd'].includes(event) +} + +function basicHandler(instance, subHandler) { + return function diagChanEventHandler(...args) { + subHandler.apply(instance, args) + } +} + +function endHandler(instance, subHandler) { + return function diagChanEndEventHandler(...args) { + subHandler.apply(instance, args) + const ctx = instance.agent.tracer.getContext() + ctx?.segment?.touch() + } +} + +module.exports = { + TracingChannelSubscription, + TracingChannelSubscriber +} diff --git a/lib/subscribers/wrap-method.js b/lib/subscribers/wrap-method.js new file mode 100644 index 0000000000..13f8610302 --- /dev/null +++ b/lib/subscribers/wrap-method.js @@ -0,0 +1,126 @@ +/* + * Copyright 2026 New Relic Corporation. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +'use strict' + +module.exports = { wrapMethod, wrapMethods } + +const { original, unwrap } = require('#agentlib/symbols.js') +const defaultLogger = require('#agentlib/logger.js').child({ component: 'wrapMethod' }) + +/** + * A callback function used to actually perform the wrapping of a method. It + * will be provided the original function and the function's name. + * + * @typedef {Function} MethodWrapper + * @param {Function} originalMethod The original method that is being wrapped. + * @param {string} methodName The name of the method that is being wrapped. + * @returns {Function} Newly wrapped method. + */ + +/** + * Utility for wrapping object method. Provides automatic guardrails for + * missing or already wrapped method. + * + * @example Wrapping a single method. + * const foo = require('foo') + * wrapMethod({ + * module: foo, + * methodName: 'bar', + * wrapper: function (originalMethod, methodName) { + * return function wrappedMethod(...args) { + * args[0] = 'we changed stuff' + * return originalMethod.apply(foo, args) + * } + * } + * }) + * + * @example Wrapping multiple methods. + * const foo = require('foo') + * const toWrap = ['bar', 'baz'] + * for (const method of toWrap) { + * wrapMethod({ + * module: foo, + * methodName: method, + * wrapper: function (o, m) { + * return function wm(...args) { + * args[0] = `wrapped ${m}` + * return originalMethod.apply(foo, args) + * } + * } + * }) + * } + * + * @param {object} params Function parameters. + * @param {object} params.module The module that holds the reference to the + * method that needs to be wrapped. + * @param {string} params.methodName The name of the method to wrap. + * @param {MethodWrapper} params.wrapper A function that will be the actual + * wrapper. + * @param {AgentLogger} [params.logger] A logger instance. + */ +function wrapMethod ({ + module, + methodName, + wrapper, + logger = defaultLogger +} = {}) { + const originalMethod = module[methodName] + + if (!originalMethod) { + logger.trace('"%s" method is not defined on the provided module.', methodName) + return + } + + if (originalMethod[unwrap]) { + logger.trace('"%s" is already wrapped. Not wrapping again.') + return + } + + const wrappedMethod = wrapper(originalMethod, methodName) + for (const [key, value] of Object.entries(originalMethod)) { + // Sometimes a function is decorated with additional properties. + // So we must copy those properties over to our wrapped function. + wrappedMethod[key] = value + } + wrappedMethod[original] = originalMethod + wrappedMethod[unwrap] = function unwrap () { + module[methodName] = originalMethod + logger.trace('Removed wrapper from method "%s".', methodName) + } + + module[methodName] = wrappedMethod + logger.trace('Wrapped method "%s".', methodName) +} + +/** + * Convenience function for wrapping multiple methods in one invocation. + * If a provided method does not exist on the provided module, a log will + * be issued but no error will be thrown. + * + * @see wrapMethod + * + * @param {object} params Function parameters. + * @param {object} params.module Object that holds the references to the + * methods. + * @param {string[]} params.methodNames Names of the methods to wrap. + * @param {MethodWrapper} params.wrapper Function to wrap the individual methods + * with. + * @param {AgentLogger} [params.logger] Logger instance. + */ +function wrapMethods({ + module, + methodNames, + wrapper, + logger = defaultLogger +} = {}) { + for (const methodName of methodNames) { + if (!module[methodName]) { + logger.debug('Cannot wrap "%s" because it does not exist on the object.') + continue + } + wrapMethod({ module, methodName, wrapper, logger }) + } +} diff --git a/lib/transaction/tracer/index.js b/lib/transaction/tracer/index.js index 4470499743..83d1e794e7 100644 --- a/lib/transaction/tracer/index.js +++ b/lib/transaction/tracer/index.js @@ -47,6 +47,20 @@ Tracer.prototype.wrapFunctionFirst = wrapFunctionFirst Tracer.prototype.wrapSyncFunction = wrapSyncFunction Tracer.prototype.wrapCallback = wrapCallback +Object.defineProperties(Tracer.prototype, { + /** + * Retrieve the AsyncLocalStorage instance. + * + * @see https://nodejs.org/api/async_context.html#class-asynclocalstorage + * @returns {AsyncLocalStorage} + */ + asyncStore: { + get () { + return this._contextManager.store + } + } +}) + function getContext() { return this._contextManager.getContext() }