Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
40 changes: 35 additions & 5 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 Down Expand Up @@ -34,6 +37,8 @@ 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
*/
Expand All @@ -46,7 +51,7 @@ class Subscriber {

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 +78,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
35 changes: 35 additions & 0 deletions lib/subscribers/record-supportability-metric.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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}`
)
metric.incrementCallCount()

metric = agent.metrics.getOrCreateMetric(
`${SUBSCRIBER_USED}/${moduleName}`
)
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
}
51 changes: 43 additions & 8 deletions test/lib/custom-assertions/assert-pkg-tracking-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@
*/

'use strict'
const NAMES = require('#agentlib/metrics/names.js')
const assertMetrics = require('./assert-metrics')

const semver = require('semver')
const assertMetrics = require('./assert-metrics')

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

/**
* assertion to verify tracking metrics for a given
Expand All @@ -16,20 +25,46 @@ const semver = require('semver')
* @param {string} params.pkg name of package
* @param {string} params.version version of package
* @param {Agent} params.agent agent instance
* @param {boolean} params.subscriberType When true, the metrics are expected
* to have been generated from a subscriber based instrumentation. Otherwise,
* the metrics are expected to be generated from a shimmer based
* instrumentation.
* @param {object} [deps] Injected dependencies.
* @param {object} [deps.assert] Assertion library to use.
*/
module.exports = function assertPackageMetrics(
{ pkg, version, agent },
{ pkg, version, agent, subscriberType = false },
{ assert = require('node:assert') } = {}
) {
const metrics = [
[{ name: `${NAMES.FEATURES.INSTRUMENTATION.ON_REQUIRE}/${pkg}` }]
]
const metrics = []
const prefix = subscriberType === true
? `${SUBSCRIBER_USED}/${pkg}`
: `${ON_REQUIRE}/${pkg}`

metrics.push([{ name: prefix }])
if (version) {
metrics.push([{ name: `${NAMES.FEATURES.INSTRUMENTATION.ON_REQUIRE}/${pkg}/Version/${semver.major(version)}` }])
const major = semver.major(version)
const suffix = subscriberType === true
? `/${major}`
: `/Version/${major}`
metrics.push([{ name: `${prefix}${suffix}` }])
}

assertMetrics(agent.metrics, metrics, false, false, { assert })
try {
assertMetrics(agent.metrics, metrics, false, false, { assert })
} catch {
const expected = metrics.flat().map((m) => m.name)
const foundMetrics = Object.keys(agent.metrics._metrics.unscoped).filter(
(k) => k.toLowerCase().startsWith('supportability')
)
const msg = '\nExpected supportability metrics:\n' +
JSON.stringify(expected, null, 2) +
'\nBut only present supportability metrics:\n' +
JSON.stringify(foundMetrics, null, 2)
if (typeof assert.fail === 'function') {
assert.fail(msg)
} else {
throw Error(msg)
}
}
}
Loading
Loading