Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ nr-security-home
# benchmark results
benchmark_results
bin/.env

# Local developer resources:
local/
3 changes: 2 additions & 1 deletion lib/metrics/names.js
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ const FEATURES = {
SOURCE_MAPS: `${SUPPORTABILITY.FEATURES}/EnableSourceMaps`,
CERTIFICATES: SUPPORTABILITY.FEATURES + '/Certificates',
INSTRUMENTATION: {
ON_REQUIRE: SUPPORTABILITY.FEATURES + '/Instrumentation/OnRequire'
ON_REQUIRE: SUPPORTABILITY.FEATURES + '/Instrumentation/OnRequire',
SUBSCRIBER_USED: SUPPORTABILITY.FEATURES + '/Instrumentation/SubscriberUsed'
}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/shimmer.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ const shimmer = (module.exports = {
registerHooks(agent) {
// add the packages from the subscriber based instrumentation
// this is only added to add tracking metrics
pkgsToHook.push(...Object.keys(subscriptions), ...trackingPkgs)
Array.prototype.push.apply(pkgsToHook, trackingPkgs)
this._ritm = new Hook(pkgsToHook, function onHook(exports, name, basedir) {
return _postLoad(agent, exports, name, basedir)
})
Expand Down
46 changes: 32 additions & 14 deletions lib/subscribers/base.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
// eslint-disable-next-line n/no-unsupported-features/node-builtins
const { tracingChannel } = require('node:diagnostics_channel')
const cat = require('#agentlib/util/cat.js')
const recordSupportabilityMetric = require('./record-supportability-metric.js')

// Used for the `traceCallback` work.
// This can be removed when we add true support into orchestrion
const makeCall = (fn) => (...args) => fn.call(...args)
Expand All @@ -28,38 +30,45 @@ const ArrayPrototypeSplice = makeCall(Array.prototype.splice)

/**
* @property {object} agent A New Relic Node.js agent instance.
* @property {object} logger An agent logger instance.
* @property {TracingChannel} channel The tracing channel instance this subscriber will be monitoring.
* @property {string} channelName A unique name for the diagnostics channel
* that will be registered.
* @property {object} config The agent configuration object.
* @property {string} id A unique identifier for the subscriber, combining the prefix, package
* name, and channel name.
* @property {object} logger An agent logger instance.
* @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 {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.
* @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.
* See {@link https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel}
* @property {boolean} [internal=false] If true, any children segments from the same library
* will not be created.
* @property {boolean} [opaque=false] If true, any children segments 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.
* @property {boolean} [requireActiveTx=true] If true, the subscriber will only handle events
* when there is an active transaction.
* @property {object} [targetModuleMeta] Defines the target module's name and
* version string, i.e. is an object `{ name, version }`. This is only necessary
* when target instrumentation can surface an unexpected name for the
* `packageName` property. For example, `express` uses multiple modules to
* compose its core functionality. We want to track things under the `express`
* name, but `packageName` will be set to `router` is most cases.
*/
class Subscriber {
#usageMetricRecorded = false

/**
* @param {SubscriberParams} params the subscriber constructor params
*/
Expand Down Expand Up @@ -234,6 +243,15 @@ class Subscriber {
* @returns {Context} The context after processing the event
*/
const handler = (data) => {
if (this.#usageMetricRecorded === false) {
recordSupportabilityMetric({
agent: this.agent,
moduleName: this.packageName,
moduleVersion: data.moduleVersion
})
this.#usageMetricRecorded = true
}

// only wrap the callback if a subscriber has a callback property defined
if (this.callback !== null) {
this.traceCallback(this.callback, data)
Expand Down
53 changes: 47 additions & 6 deletions lib/subscribers/dc-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
*/

'use strict'

// eslint-disable-next-line n/no-unsupported-features/node-builtins
const dc = require('node:diagnostics_channel')
const recordSupportabilityMetric = require('./record-supportability-metric.js')
const resolvePackageVersion = require('./resolve-package-version.js')

/**
* The baseline parameters available to all subscribers.
Expand All @@ -15,6 +18,13 @@ const dc = require('node:diagnostics_channel')
* @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 {boolean} [skipUsageMetricRecording=false] When set to `true`, the
* instrumentation will not attempt to record the usage metric. This is useful
* when the module being instrumented is also being instrumented via the
* Orchestrion based subscriber system. It is much cheaper to record the metric
* via Orchestrion based subscribers than through this direct diagnostics
* channel method (Orchestrion provides the module version, whereas we have
* to perform expensive operations here to get the same information).
*/

/**
Expand All @@ -34,19 +44,25 @@ const dc = require('node:diagnostics_channel')
* This is the same string one would pass to the `require` function.
*/
class Subscriber {
#usageMetricRecorded = false

/**
* @param {SubscriberParams} params to function
*/
constructor({ agent, logger, packageName }) {
constructor({ agent, logger, packageName, skipUsageMetricRecording = false }) {
this.agent = agent
this.logger = logger.child({ component: `${packageName}-subscriber` })
this.config = agent.config
this.id = packageName

if (skipUsageMetricRecording === true) {
this.#usageMetricRecorded = true
}
}

set channels(channels) {
if (!Array.isArray(channels)) {
throw new Error('channels must be a collection of with propertiesof channel and hook')
throw new Error('channels must be a collection of objects with properties channel and hook')
}
this._channels = channels
}
Expand All @@ -73,18 +89,43 @@ class Subscriber {

subscribe() {
for (let index = 0; index < this.channels.length; index++) {
const chan = this.channels[index]
const { hook, channel } = this.channels[index]
const boundHook = hook.bind(this)
dc.subscribe(channel, boundHook)
this.channels[index].boundHook = boundHook
chan.boundHook = boundHook
chan.eventHandler = (message, name) => {
this.#supportability()
boundHook(message, name)
}
dc.subscribe(channel, chan.eventHandler)
}
}

unsubscribe() {
for (let index = 0; index < this.channels.length; index++) {
const { channel, boundHook } = this.channels[index]
dc.unsubscribe(channel, boundHook)
const { channel, eventHandler } = this.channels[index]
dc.unsubscribe(channel, eventHandler)
}
}

/**
* Since this class subscribes to diagnostics channels natively published by
* target modules, we do not get the package metadata that Orchestrion
* provides in its channel events. So we have to try and find the package
* manifest and get the version out of it in order to record our
* supportability metric.
*/
#supportability() {
if (this.#usageMetricRecorded === true) {
return
}
const version = resolvePackageVersion(this.id)
recordSupportabilityMetric({
agent: this.agent,
moduleName: this.id,
moduleVersion: version
})
this.#usageMetricRecorded = true
}
}

Expand Down
7 changes: 5 additions & 2 deletions lib/subscribers/fastify/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const MiddlewareWrapper = require('../middleware-wrapper')

class FastifyInitialization extends DcBase {
constructor({ agent, logger }) {
super({ agent, logger, packageName: 'fastify' })
super({ agent, logger, packageName: 'fastify', skipUsageMetricRecording: true })
this.channels = [
{ channel: initChannel, hook: this.handler }
]
Expand All @@ -24,7 +24,10 @@ class FastifyInitialization extends DcBase {
return
}

routeOptions.handler = self.wrapper.wrap({ handler: routeOptions.handler, route: routeOptions.path })
routeOptions.handler = self.wrapper.wrap({
handler: routeOptions.handler,
route: routeOptions.path
})
})
}
}
Expand Down
39 changes: 39 additions & 0 deletions lib/subscribers/record-supportability-metric.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2025 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

module.exports = recordSupportabilityMetric

const semver = require('semver')
const {
FEATURES: {
INSTRUMENTATION: { SUBSCRIBER_USED }
}
} = require('#agentlib/metrics/names.js')

function recordSupportabilityMetric({
agent,
moduleName,
moduleVersion = 'unknown'
} = {}) {
const major = moduleVersion === 'unknown'
? semver.major(process.version)
: semver.major(moduleVersion)

let metric = agent.metrics.getOrCreateMetric(
`${SUBSCRIBER_USED}/${moduleName}/${major}`
)
if (metric.callCount === 0) {
metric.incrementCallCount()
}

metric = agent.metrics.getOrCreateMetric(
`${SUBSCRIBER_USED}/${moduleName}`
)
if (metric.callCount === 0) {
metric.incrementCallCount()
}
}
84 changes: 84 additions & 0 deletions lib/subscribers/resolve-package-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2025 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const path = require('node:path')
const defaultLogger = require('#agentlib/logger.js').child({
component: 'resolve-module-version'
})

const dcFuncFrame = /^\s*at Channel\.publish/
const modPathReg = /at .+ \((.+):\d+:\d+\)/

module.exports = resolveModuleVersion

/**
* Given a module name, attempt to read the version string from its
* associated package manifest. If the module is a built-in, or one that has
* been bundled with Node.js (e.g. `undici`), a package manifest will not be
* available. In this case, the string "unknown" will be returned.
*
* This version resolver assumes that it will be invoked through our
* diagnostics channel subscriber instrumentations. That is, it expects the
* call tree to be similar to:
*
* 1. some-module.function()
* 2. diagnostics_channel.publish()
* 3. subscriber.handler()
*
* @param {string} moduleSpecifier What would be passed to `resolve()`.
* @param {object} [deps] Optional dependencies.
* @param {object} [deps.logger] Agent logger instance.
*
* @returns {string} The version string from the package manifest or "unknown".
*/
function resolveModuleVersion(moduleSpecifier, { logger = defaultLogger } = {}) {
let pkgPath
// We'd prefer to use `require.resolve(moduleSpecifier)` here, but it gets
// a bit confused when there are non-standard module directories in play.
// Once we are able to refactor our "on require" metric recording to
// utilize `module.registerHooks`, we should be able to eliminate this
// slow algorithm.
const err = Error()
const stack = err.stack.split('\n')
do {
stack.shift()
} while (dcFuncFrame.test(stack[0]) === false && stack.length > 0)
const matches = modPathReg.exec(stack[1])
pkgPath = matches?.[1]

if (!pkgPath) {
logger.warn(
{ moduleSpecifier },
'Could not resolve module path. Possibly a built-in or Node.js bundled module.'
)
return 'unknown'
}

const cwd = process.cwd()
let reachedCwd = false
let pkg
let base = path.dirname(pkgPath)
do {
try {
pkgPath = path.join(base, 'package.json')
pkg = require(pkgPath)
} catch {
base = path.resolve(path.join(base, '..'))
if (base === cwd) {
reachedCwd = true
} else if (reachedCwd === true) {
// We reached the supposed app root, attempted to load a manifest
// file in that location, and still couldn't find one. So we give up.
pkg = {}
}
}
} while (!pkg)

const version = pkg.version ?? 'unknown'
logger.trace({ moduleSpecifier, version }, 'Resolved package version.')
return version
}
10 changes: 1 addition & 9 deletions lib/tracking-packages.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,7 @@ const trackingPkgs = [
'fancy-log',
'knex',
'loglevel',
'npmlog',
// These packages are required for the tracking metrics
// for @langchain/core to be created. Ideally, these
// should not be here.
// TODO: will be addressed in https://github.com/newrelic/node-newrelic/issues/3575
'@langchain/core/prompts',
'@langchain/core/tools',
'@langchain/core/runnables',
'@langchain/core/vectorstores',
'npmlog'
]

module.exports = trackingPkgs
13 changes: 13 additions & 0 deletions test/lib/agent_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,19 @@ helper.makeGetRequest = (url, options, callback) => {
*/
helper.makeGetRequestAsync = util.promisify(helper.makeGetRequest)

helper.asyncHttpCall = function (url, options) {
return new Promise((resolve, reject) => {
helper.makeRequest(url, options || {}, callback)

function callback (error, incomingMessage, body) {
if (error) {
return reject(error)
}
resolve({ response: incomingMessage, body })
}
})
}

helper.makeRequest = (url, options, callback) => {
if (!options || typeof options === 'function') {
callback = options
Expand Down
Loading
Loading