Skip to content

Commit 5796574

Browse files
authored
chore: Record subscriber usage metric (#3626)
1 parent 572c3a1 commit 5796574

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+818
-110
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: 47 additions & 6 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.
@@ -15,6 +18,13 @@ const dc = require('node:diagnostics_channel')
1518
* @property {object} logger An agent logger instance.
1619
* @property {string} packageName The package name being instrumented.
1720
* This is what a developer would provide to the `require` function.
21+
* @property {boolean} [skipUsageMetricRecording=false] When set to `true`, the
22+
* instrumentation will not attempt to record the usage metric. This is useful
23+
* when the module being instrumented is also being instrumented via the
24+
* Orchestrion based subscriber system. It is much cheaper to record the metric
25+
* via Orchestrion based subscribers than through this direct diagnostics
26+
* channel method (Orchestrion provides the module version, whereas we have
27+
* to perform expensive operations here to get the same information).
1828
*/
1929

2030
/**
@@ -34,19 +44,25 @@ const dc = require('node:diagnostics_channel')
3444
* This is the same string one would pass to the `require` function.
3545
*/
3646
class Subscriber {
47+
#usageMetricRecorded = false
48+
3749
/**
3850
* @param {SubscriberParams} params to function
3951
*/
40-
constructor({ agent, logger, packageName }) {
52+
constructor({ agent, logger, packageName, skipUsageMetricRecording = false }) {
4153
this.agent = agent
4254
this.logger = logger.child({ component: `${packageName}-subscriber` })
4355
this.config = agent.config
4456
this.id = packageName
57+
58+
if (skipUsageMetricRecording === true) {
59+
this.#usageMetricRecorded = true
60+
}
4561
}
4662

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

7490
subscribe() {
7591
for (let index = 0; index < this.channels.length; index++) {
92+
const chan = this.channels[index]
7693
const { hook, channel } = this.channels[index]
7794
const boundHook = hook.bind(this)
78-
dc.subscribe(channel, boundHook)
79-
this.channels[index].boundHook = boundHook
95+
chan.boundHook = boundHook
96+
chan.eventHandler = (message, name) => {
97+
this.#supportability()
98+
boundHook(message, name)
99+
}
100+
dc.subscribe(channel, chan.eventHandler)
80101
}
81102
}
82103

83104
unsubscribe() {
84105
for (let index = 0; index < this.channels.length; index++) {
85-
const { channel, boundHook } = this.channels[index]
86-
dc.unsubscribe(channel, boundHook)
106+
const { channel, eventHandler } = this.channels[index]
107+
dc.unsubscribe(channel, eventHandler)
108+
}
109+
}
110+
111+
/**
112+
* Since this class subscribes to diagnostics channels natively published by
113+
* target modules, we do not get the package metadata that Orchestrion
114+
* provides in its channel events. So we have to try and find the package
115+
* manifest and get the version out of it in order to record our
116+
* supportability metric.
117+
*/
118+
#supportability() {
119+
if (this.#usageMetricRecorded === true) {
120+
return
87121
}
122+
const version = resolvePackageVersion(this.id)
123+
recordSupportabilityMetric({
124+
agent: this.agent,
125+
moduleName: this.id,
126+
moduleVersion: version
127+
})
128+
this.#usageMetricRecorded = true
88129
}
89130
}
90131

lib/subscribers/fastify/index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const MiddlewareWrapper = require('../middleware-wrapper')
1010

1111
class FastifyInitialization extends DcBase {
1212
constructor({ agent, logger }) {
13-
super({ agent, logger, packageName: 'fastify' })
13+
super({ agent, logger, packageName: 'fastify', skipUsageMetricRecording: true })
1414
this.channels = [
1515
{ channel: initChannel, hook: this.handler }
1616
]
@@ -24,7 +24,10 @@ class FastifyInitialization extends DcBase {
2424
return
2525
}
2626

27-
routeOptions.handler = self.wrapper.wrap({ handler: routeOptions.handler, route: routeOptions.path })
27+
routeOptions.handler = self.wrapper.wrap({
28+
handler: routeOptions.handler,
29+
route: routeOptions.path
30+
})
2831
})
2932
}
3033
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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+
if (metric.callCount === 0) {
30+
metric.incrementCallCount()
31+
}
32+
33+
metric = agent.metrics.getOrCreateMetric(
34+
`${SUBSCRIBER_USED}/${moduleName}`
35+
)
36+
if (metric.callCount === 0) {
37+
metric.incrementCallCount()
38+
}
39+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
const dcFuncFrame = /^\s*at Channel\.publish/
14+
const modPathReg = /at .+ \((.+):\d+:\d+\)/
15+
16+
module.exports = resolveModuleVersion
17+
18+
/**
19+
* Given a module name, attempt to read the version string from its
20+
* associated package manifest. If the module is a built-in, or one that has
21+
* been bundled with Node.js (e.g. `undici`), a package manifest will not be
22+
* available. In this case, the string "unknown" will be returned.
23+
*
24+
* This version resolver assumes that it will be invoked through our
25+
* diagnostics channel subscriber instrumentations. That is, it expects the
26+
* call tree to be similar to:
27+
*
28+
* 1. some-module.function()
29+
* 2. diagnostics_channel.publish()
30+
* 3. subscriber.handler()
31+
*
32+
* @param {string} moduleSpecifier What would be passed to `resolve()`.
33+
* @param {object} [deps] Optional dependencies.
34+
* @param {object} [deps.logger] Agent logger instance.
35+
*
36+
* @returns {string} The version string from the package manifest or "unknown".
37+
*/
38+
function resolveModuleVersion(moduleSpecifier, { logger = defaultLogger } = {}) {
39+
let pkgPath
40+
// We'd prefer to use `require.resolve(moduleSpecifier)` here, but it gets
41+
// a bit confused when there are non-standard module directories in play.
42+
// Once we are able to refactor our "on require" metric recording to
43+
// utilize `module.registerHooks`, we should be able to eliminate this
44+
// slow algorithm.
45+
const err = Error()
46+
const stack = err.stack.split('\n')
47+
do {
48+
stack.shift()
49+
} while (dcFuncFrame.test(stack[0]) === false && stack.length > 0)
50+
const matches = modPathReg.exec(stack[1])
51+
pkgPath = matches?.[1]
52+
53+
if (!pkgPath) {
54+
logger.warn(
55+
{ moduleSpecifier },
56+
'Could not resolve module path. Possibly a built-in or Node.js bundled module.'
57+
)
58+
return 'unknown'
59+
}
60+
61+
const cwd = process.cwd()
62+
let reachedCwd = false
63+
let pkg
64+
let base = path.dirname(pkgPath)
65+
do {
66+
try {
67+
pkgPath = path.join(base, 'package.json')
68+
pkg = require(pkgPath)
69+
} catch {
70+
base = path.resolve(path.join(base, '..'))
71+
if (base === cwd) {
72+
reachedCwd = true
73+
} else if (reachedCwd === true) {
74+
// We reached the supposed app root, attempted to load a manifest
75+
// file in that location, and still couldn't find one. So we give up.
76+
pkg = {}
77+
}
78+
}
79+
} while (!pkg)
80+
81+
const version = pkg.version ?? 'unknown'
82+
logger.trace({ moduleSpecifier, version }, 'Resolved package version.')
83+
return version
84+
}

lib/tracking-packages.js

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,7 @@ const trackingPkgs = [
1919
'fancy-log',
2020
'knex',
2121
'loglevel',
22-
'npmlog',
23-
// These packages are required for the tracking metrics
24-
// for @langchain/core to be created. Ideally, these
25-
// should not be here.
26-
// TODO: will be addressed in https://github.com/newrelic/node-newrelic/issues/3575
27-
'@langchain/core/prompts',
28-
'@langchain/core/tools',
29-
'@langchain/core/runnables',
30-
'@langchain/core/vectorstores',
22+
'npmlog'
3123
]
3224

3325
module.exports = trackingPkgs

test/lib/agent_helper.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,19 @@ helper.makeGetRequest = (url, options, callback) => {
436436
*/
437437
helper.makeGetRequestAsync = util.promisify(helper.makeGetRequest)
438438

439+
helper.asyncHttpCall = function (url, options) {
440+
return new Promise((resolve, reject) => {
441+
helper.makeRequest(url, options || {}, callback)
442+
443+
function callback (error, incomingMessage, body) {
444+
if (error) {
445+
return reject(error)
446+
}
447+
resolve({ response: incomingMessage, body })
448+
}
449+
})
450+
}
451+
439452
helper.makeRequest = (url, options, callback) => {
440453
if (!options || typeof options === 'function') {
441454
callback = options

0 commit comments

Comments
 (0)