Skip to content

Commit b3cba72

Browse files
committed
chore: Record subscriber usage metric
1 parent ef1b611 commit b3cba72

29 files changed

+553
-83
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ nr-security-home
2222
# benchmark results
2323
benchmark_results
2424
bin/.env
25+
26+
# Local developer resources:
27+
local/

lib/metrics/names.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ const FEATURES = {
353353
SOURCE_MAPS: `${SUPPORTABILITY.FEATURES}/EnableSourceMaps`,
354354
CERTIFICATES: SUPPORTABILITY.FEATURES + '/Certificates',
355355
INSTRUMENTATION: {
356-
ON_REQUIRE: SUPPORTABILITY.FEATURES + '/Instrumentation/OnRequire'
356+
ON_REQUIRE: SUPPORTABILITY.FEATURES + '/Instrumentation/OnRequire',
357+
SUBSCRIBER_USED: SUPPORTABILITY.FEATURES + '/Instrumentation/SubscriberUsed'
357358
}
358359
}
359360

lib/shimmer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ const shimmer = (module.exports = {
306306
registerHooks(agent) {
307307
// add the packages from the subscriber based instrumentation
308308
// this is only added to add tracking metrics
309-
pkgsToHook.push(...Object.keys(subscriptions), ...trackingPkgs)
309+
Array.prototype.push.apply(pkgsToHook, trackingPkgs)
310310
this._ritm = new Hook(pkgsToHook, function onHook(exports, name, basedir) {
311311
return _postLoad(agent, exports, name, basedir)
312312
})

lib/subscribers/base.js

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
// eslint-disable-next-line n/no-unsupported-features/node-builtins
88
const { tracingChannel } = require('node:diagnostics_channel')
99
const cat = require('#agentlib/util/cat.js')
10+
const recordSupportabilityMetric = require('./record-supportability-metric.js')
11+
1012
// Used for the `traceCallback` work.
1113
// This can be removed when we add true support into orchestrion
1214
const makeCall = (fn) => (...args) => fn.call(...args)
@@ -28,38 +30,45 @@ const ArrayPrototypeSplice = makeCall(Array.prototype.splice)
2830

2931
/**
3032
* @property {object} agent A New Relic Node.js agent instance.
31-
* @property {object} logger An agent logger instance.
33+
* @property {TracingChannel} channel The tracing channel instance this subscriber will be monitoring.
34+
* @property {string} channelName A unique name for the diagnostics channel
35+
* that will be registered.
3236
* @property {object} config The agent configuration object.
37+
* @property {string} id A unique identifier for the subscriber, combining the prefix, package
38+
* name, and channel name.
39+
* @property {object} logger An agent logger instance.
3340
* @property {string} packageName The name of the module being instrumented.
3441
* This is the same string one would pass to the `require` function.
35-
* @property {string} channelName A unique name for the diagnostics channel
36-
* that will be registered.
42+
* @property {AsyncLocalStorage} store The async local storage instance used for context management.
43+
* @property {number} [callback=null] Position of callback if it needs to be wrapped for instrumentation.
44+
* -1 means last argument.
3745
* @property {string[]} [events=[]] Set of tracing channel event names to
3846
* register handlers for. For any name in the set, a corresponding method
3947
* must exist on the subscriber instance. The method will be passed the
4048
* event object. Possible event names are `start`, `end`, `asyncStart`,
4149
* `asyncEnd`, and `error`.
42-
*
43-
* See {@link https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel}
44-
* @property {boolean} [opaque=false] If true, any children segments will not be created.
50+
* See {@link https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel}
4551
* @property {boolean} [internal=false] If true, any children segments from the same library
4652
* will not be created.
53+
* @property {boolean} [opaque=false] If true, any children segments will not be created.
4754
* @property {string} [prefix='orchestrion:'] String to prepend to diagnostics
4855
* channel event names. This provides a namespace for the events we are
4956
* injecting into a module.
50-
* @property {boolean} [requireActiveTx=true] If true, the subscriber will only handle events
51-
* when there is an active transaction.
5257
* @property {boolean} [propagateContext=false] If true, it will bind `asyncStart` to the store
5358
* and re-propagate the active context. It will also attach the `transaction` to the event in
5459
* `start.bindStore`. This is used for functions that queue async code and context is lost.
55-
* @property {string} id A unique identifier for the subscriber, combining the prefix, package
56-
* name, and channel name.
57-
* @property {TracingChannel} channel The tracing channel instance this subscriber will be monitoring.
58-
* @property {AsyncLocalStorage} store The async local storage instance used for context management.
59-
* @property {number} [callback=null] Position of callback if it needs to be wrapped for instrumentation.
60-
* -1 means last argument.
60+
* @property {boolean} [requireActiveTx=true] If true, the subscriber will only handle events
61+
* when there is an active transaction.
62+
* @property {object} [targetModuleMeta] Defines the target module's name and
63+
* version string, i.e. is an object `{ name, version }`. This is only necessary
64+
* when target instrumentation can surface an unexpected name for the
65+
* `packageName` property. For example, `express` uses multiple modules to
66+
* compose its core functionality. We want to track things under the `express`
67+
* name, but `packageName` will be set to `router` is most cases.
6168
*/
6269
class Subscriber {
70+
#usageMetricRecorded = false
71+
6372
/**
6473
* @param {SubscriberParams} params the subscriber constructor params
6574
*/
@@ -234,6 +243,15 @@ class Subscriber {
234243
* @returns {Context} The context after processing the event
235244
*/
236245
const handler = (data) => {
246+
if (this.#usageMetricRecorded === false) {
247+
recordSupportabilityMetric({
248+
agent: this.agent,
249+
moduleName: this.packageName,
250+
moduleVersion: data.moduleVersion
251+
})
252+
this.#usageMetricRecorded = true
253+
}
254+
237255
// only wrap the callback if a subscriber has a callback property defined
238256
if (this.callback !== null) {
239257
this.traceCallback(this.callback, data)

lib/subscribers/dc-base.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
*/
55

66
'use strict'
7+
78
// eslint-disable-next-line n/no-unsupported-features/node-builtins
89
const dc = require('node:diagnostics_channel')
10+
const recordSupportabilityMetric = require('./record-supportability-metric.js')
11+
const resolvePackageVersion = require('./resolve-package-version.js')
912

1013
/**
1114
* The baseline parameters available to all subscribers.
@@ -34,6 +37,8 @@ const dc = require('node:diagnostics_channel')
3437
* This is the same string one would pass to the `require` function.
3538
*/
3639
class Subscriber {
40+
#usageMetricRecorded = false
41+
3742
/**
3843
* @param {SubscriberParams} params to function
3944
*/
@@ -46,7 +51,7 @@ class Subscriber {
4651

4752
set channels(channels) {
4853
if (!Array.isArray(channels)) {
49-
throw new Error('channels must be a collection of with propertiesof channel and hook')
54+
throw new Error('channels must be a collection of objects with properties channel and hook')
5055
}
5156
this._channels = channels
5257
}
@@ -73,18 +78,43 @@ class Subscriber {
7378

7479
subscribe() {
7580
for (let index = 0; index < this.channels.length; index++) {
81+
const chan = this.channels[index]
7682
const { hook, channel } = this.channels[index]
7783
const boundHook = hook.bind(this)
78-
dc.subscribe(channel, boundHook)
79-
this.channels[index].boundHook = boundHook
84+
chan.boundHook = boundHook
85+
chan.eventHandler = (message, name) => {
86+
this.#supportability()
87+
boundHook(message, name)
88+
}
89+
dc.subscribe(channel, chan.eventHandler)
8090
}
8191
}
8292

8393
unsubscribe() {
8494
for (let index = 0; index < this.channels.length; index++) {
85-
const { channel, boundHook } = this.channels[index]
86-
dc.unsubscribe(channel, boundHook)
95+
const { channel, eventHandler } = this.channels[index]
96+
dc.unsubscribe(channel, eventHandler)
97+
}
98+
}
99+
100+
/**
101+
* Since this class subscribes to diagnostics channels natively published by
102+
* target modules, we do not get the package metadata that Orchestrion
103+
* provides in its channel events. So we have to try and find the package
104+
* manifest and get the version out of it in order to record our
105+
* supportability metric.
106+
*/
107+
#supportability() {
108+
if (this.#usageMetricRecorded === true) {
109+
return
87110
}
111+
const version = resolvePackageVersion(this.id)
112+
recordSupportabilityMetric({
113+
agent: this.agent,
114+
moduleName: this.id,
115+
moduleVersion: version
116+
})
117+
this.#usageMetricRecorded = true
88118
}
89119
}
90120

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2025 New Relic Corporation. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
'use strict'
7+
8+
module.exports = recordSupportabilityMetric
9+
10+
const semver = require('semver')
11+
const {
12+
FEATURES: {
13+
INSTRUMENTATION: { SUBSCRIBER_USED }
14+
}
15+
} = require('#agentlib/metrics/names.js')
16+
17+
function recordSupportabilityMetric({
18+
agent,
19+
moduleName,
20+
moduleVersion = 'unknown'
21+
} = {}) {
22+
const major = moduleVersion === 'unknown'
23+
? semver.major(process.version)
24+
: semver.major(moduleVersion)
25+
26+
let metric = agent.metrics.getOrCreateMetric(
27+
`${SUBSCRIBER_USED}/${moduleName}/${major}`
28+
)
29+
metric.incrementCallCount()
30+
31+
metric = agent.metrics.getOrCreateMetric(
32+
`${SUBSCRIBER_USED}/${moduleName}`
33+
)
34+
metric.incrementCallCount()
35+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright 2025 New Relic Corporation. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
'use strict'
7+
8+
const path = require('node:path')
9+
const defaultLogger = require('#agentlib/logger.js').child({
10+
component: 'resolve-module-version'
11+
})
12+
13+
module.exports = resolveModuleVersion
14+
15+
/**
16+
* Given a module name, attempt to read the version string from its
17+
* associated package manifest. If the module is a built-in, or one that has
18+
* been bundled with Node.js (e.g. `undici`), a package manifest will not be
19+
* available. In this case, the string "unknown" will be returned.
20+
*
21+
* @param {string} moduleSpecifier What would be passed to `resolve()`.
22+
* @param {object} [deps] Optional dependencies.
23+
* @param {object} [deps.logger] Agent logger instance.
24+
* @param {Function} [deps.req] Node.js require function.
25+
*
26+
* @returns {string} The version string from the package manifest or "unknown".
27+
*/
28+
function resolveModuleVersion(moduleSpecifier, {
29+
logger = defaultLogger,
30+
req = require
31+
} = {}) {
32+
let pkgPath
33+
try {
34+
pkgPath = req.resolve(moduleSpecifier)
35+
} catch {
36+
logger.warn(
37+
{ moduleSpecifier },
38+
'Could not resolve module path. Possibly a built-in or Node.js bundled module.'
39+
)
40+
return 'unknown'
41+
}
42+
43+
const cwd = process.cwd()
44+
let reachedCwd = false
45+
let pkg
46+
let base = path.dirname(pkgPath)
47+
do {
48+
try {
49+
pkgPath = path.join(base, 'package.json')
50+
pkg = req(pkgPath)
51+
} catch {
52+
base = path.resolve(path.join(base, '..'))
53+
if (base === cwd) {
54+
reachedCwd = true
55+
} else if (reachedCwd === true) {
56+
// We reached the supposed app root, attempted to load a manifest
57+
// file in that location, and still couldn't find one. So we give up.
58+
pkg = {}
59+
}
60+
}
61+
} while (!pkg)
62+
63+
const version = pkg.version ?? 'unknown'
64+
logger.trace({ moduleSpecifier, version }, 'Resolved package version.')
65+
return version
66+
}

test/lib/custom-assertions/assert-pkg-tracking-metrics.js

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,18 @@
44
*/
55

66
'use strict'
7-
const NAMES = require('#agentlib/metrics/names.js')
8-
const assertMetrics = require('./assert-metrics')
7+
98
const semver = require('semver')
9+
const assertMetrics = require('./assert-metrics')
10+
11+
const {
12+
FEATURES: {
13+
INSTRUMENTATION: {
14+
SUBSCRIBER_USED,
15+
ON_REQUIRE
16+
}
17+
}
18+
} = require('#agentlib/metrics/names.js')
1019

1120
/**
1221
* assertion to verify tracking metrics for a given
@@ -16,19 +25,29 @@ const semver = require('semver')
1625
* @param {string} params.pkg name of package
1726
* @param {string} params.version version of package
1827
* @param {Agent} params.agent agent instance
28+
* @param {boolean} params.subscriberType When true, the metrics are expected
29+
* to have been generated from a subscriber based instrumentation. Otherwise,
30+
* the metrics are expected to be generated from a shimmer based
31+
* instrumentation.
1932
* @param {object} [deps] Injected dependencies.
2033
* @param {object} [deps.assert] Assertion library to use.
2134
*/
2235
module.exports = function assertPackageMetrics(
23-
{ pkg, version, agent },
36+
{ pkg, version, agent, subscriberType = false },
2437
{ assert = require('node:assert') } = {}
2538
) {
26-
const metrics = [
27-
[{ name: `${NAMES.FEATURES.INSTRUMENTATION.ON_REQUIRE}/${pkg}` }]
28-
]
39+
const metrics = []
40+
const prefix = subscriberType === true
41+
? `${SUBSCRIBER_USED}/${pkg}`
42+
: `${ON_REQUIRE}/${pkg}`
2943

44+
metrics.push([{ name: prefix }])
3045
if (version) {
31-
metrics.push([{ name: `${NAMES.FEATURES.INSTRUMENTATION.ON_REQUIRE}/${pkg}/Version/${semver.major(version)}` }])
46+
const major = semver.major(version)
47+
const suffix = subscriberType === true
48+
? `/${major}`
49+
: `/Version/${major}`
50+
metrics.push([{ name: `${prefix}${suffix}` }])
3251
}
3352

3453
assertMetrics(agent.metrics, metrics, false, false, { assert })

test/unit/lib/subscribers/base.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,38 @@ test('should subscribe/unsubscribe to specific events on channel', (t) => {
182182
assert.equal(subscriber.subscriptions, null)
183183
})
184184

185+
test('handler should record supportability metrics on first invocation', async (t) => {
186+
const plan = tspl(t, { plan: 5 })
187+
const { agent, subscriber } = t.nr
188+
const name = 'test-segment'
189+
const metricNameBase = 'Supportability/Features/Instrumentation/SubscriberUsed/test-package'
190+
subscriber.enable()
191+
192+
const handler = subscriber.handler
193+
let invocations = 0
194+
subscriber.handler = (data, ctx) => {
195+
invocations += 1
196+
return handler.call(subscriber, data, ctx)
197+
}
198+
199+
helper.runInTransaction(agent, () => {
200+
const event = { name, moduleVersion: '1.0.0' }
201+
subscriber.channel.start.runStores(event, () => {
202+
const metrics = agent.metrics._metrics.unscoped
203+
plan.equal(metrics[metricNameBase].callCount, 1)
204+
plan.equal(metrics[`${metricNameBase}/1`].callCount, 1)
205+
206+
subscriber.channel.start.runStores(event, () => {
207+
plan.equal(metrics[metricNameBase].callCount, 1)
208+
plan.equal(metrics[`${metricNameBase}/1`].callCount, 1)
209+
plan.equal(invocations, 2)
210+
})
211+
})
212+
})
213+
214+
await plan.completed
215+
})
216+
185217
test('should call handler in start if transaction is active and create a new segment', async (t) => {
186218
const plan = tspl(t, { plan: 4 })
187219
const { agent, subscriber } = t.nr

0 commit comments

Comments
 (0)