Skip to content

Commit a76cd7f

Browse files
committed
chore: Record subscriber usage metric
1 parent a84fa74 commit a76cd7f

File tree

9 files changed

+365
-8
lines changed

9 files changed

+365
-8
lines changed

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/subscribers/base.js

Lines changed: 12 additions & 0 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)
@@ -60,6 +62,8 @@ const ArrayPrototypeSplice = makeCall(Array.prototype.splice)
6062
* -1 means last argument.
6163
*/
6264
class Subscriber {
65+
#usageMetricRecorded = false
66+
6367
/**
6468
* @param {SubscriberParams} params the subscriber constructor params
6569
*/
@@ -211,6 +215,14 @@ class Subscriber {
211215
* @returns {Context} either new context or existing
212216
*/
213217
handler(data, ctx) {
218+
if (this.#usageMetricRecorded === false) {
219+
recordSupportabilityMetric({
220+
agent: this.agent,
221+
moduleName: this.packageName,
222+
moduleVersion: data.moduleVersion
223+
})
224+
this.#usageMetricRecorded = true
225+
}
214226
return ctx
215227
}
216228

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 tracing 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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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 {
11+
FEATURES: {
12+
INSTRUMENTATION: { SUBSCRIBER_USED }
13+
}
14+
} = require('#agentlib/metrics/names.js')
15+
16+
function recordSupportabilityMetric({
17+
agent,
18+
moduleName,
19+
moduleVersion = 'unknown'
20+
} = {}) {
21+
const metric = agent.metrics.getOrCreateMetric(
22+
`${SUBSCRIBER_USED}/${moduleName}/${moduleVersion}`
23+
)
24+
metric.incrementCallCount()
25+
}
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/unit/lib/subscribers/base.test.js

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

185+
test('handler should record supportability metric on first invocation', async (t) => {
186+
const plan = tspl(t, { plan: 3 })
187+
const { agent, subscriber } = t.nr
188+
const name = 'test-segment'
189+
const metricName = 'Supportability/Features/Instrumentation/SubscriberUsed/test-package/1.0.0'
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[metricName].callCount, 1)
204+
205+
subscriber.channel.start.runStores(event, () => {
206+
plan.equal(metrics[metricName].callCount, 1)
207+
plan.equal(invocations, 2)
208+
})
209+
})
210+
})
211+
212+
await plan.completed
213+
})
214+
185215
test('should call handler in start if transaction is active and create a new segment', async (t) => {
186216
const plan = tspl(t, { plan: 4 })
187217
const { agent, subscriber } = t.nr
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 test = require('node:test')
9+
const dc = require('node:diagnostics_channel')
10+
11+
const loggerMock = require('../../mocks/logger')
12+
const helper = require('#testlib/agent_helper.js')
13+
const Subscriber = require('#agentlib/subscribers/dc-base.js')
14+
15+
test.beforeEach((ctx) => {
16+
const agent = helper.loadMockedAgent()
17+
const logger = loggerMock()
18+
const subscriber = new Subscriber({
19+
agent,
20+
logger,
21+
packageName: 'test-package'
22+
})
23+
ctx.nr = { agent, subscriber }
24+
})
25+
26+
test.afterEach((ctx) => {
27+
const { subscriber } = ctx.nr
28+
subscriber.disable()
29+
subscriber.unsubscribe()
30+
helper.unloadAgent(ctx.nr.agent)
31+
})
32+
33+
test('records supportability metric on first usage', (t) => {
34+
t.plan(3)
35+
const { agent, subscriber } = t.nr
36+
37+
let invocations = 0
38+
const metricName = 'Supportability/Features/Instrumentation/SubscriberUsed/test-package/unknown'
39+
const chan = dc.channel('test.channel')
40+
subscriber.channels = [
41+
{ channel: 'test.channel', hook: handler }
42+
]
43+
subscriber.subscribe()
44+
45+
chan.publish({ foo: 'foo' })
46+
47+
function handler () {
48+
invocations += 1
49+
t.assert.equal(agent.metrics._metrics.unscoped[metricName].callCount, 1)
50+
51+
if (invocations === 1) {
52+
chan.publish({ bar: 'bar' })
53+
const cachedChan = subscriber.channels[0]
54+
const keys = Object.keys(cachedChan).sort()
55+
t.assert.deepStrictEqual(
56+
keys,
57+
['boundHook', 'channel', 'eventHandler', 'hook'],
58+
'attaches required properties to cached channel'
59+
)
60+
}
61+
}
62+
})

0 commit comments

Comments
 (0)