Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
075c9ab
feat(kafka): add MessageBroker/Kafka/Cluster/{id}/Topic/{topic} metrics
shashank-reddy-nr Jun 22, 2026
5e0d138
refactor(kafka): address code review feedback on cluster metrics
shashank-reddy-nr Jun 22, 2026
43b2579
refactor(kafka): move cluster-id caches into ConstructorSubscriber
shashank-reddy-nr Jun 22, 2026
3699056
fix(lint): add JSDoc param descriptions and fix arrow/arg-newline lin…
shashank-reddy-nr Jun 23, 2026
ad861fa
test(kafka): add unit tests for auth mechanism compatibility in Const…
shashank-reddy-nr Jun 23, 2026
3fa461c
fix(kafka): enforce TTL on cluster-id cache and prevent TTL bypass
shashank-reddy-nr Jun 26, 2026
0255bfc
chore(kafka): increase cluster ID cache TTL from 30 min to 1 hour
shashank-reddy-nr Jun 28, 2026
77d8f65
fix(kafka): prevent admin client socket leak on cluster-ID fetch timeout
shashank-reddy-nr Jun 28, 2026
08eea64
fix(kafkajs): fix lint and versioned CI failures for cluster-ID metrics
shashank-reddy-nr Jun 30, 2026
7dfb67a
feat: gate Kafka cluster metrics behind opt-in feature flag
shashank-reddy-nr Jul 3, 2026
d52141a
test: register kafka_cluster_metrics in feature flag test used list
shashank-reddy-nr Jul 3, 2026
8fa0ddc
test(kafkajs): enable kafka_cluster_metrics flag in unit and versione…
shashank-reddy-nr Jul 4, 2026
fc2f52e
test(kafkajs): add unit tests for record-cluster-produce-metrics and …
shashank-reddy-nr Jul 4, 2026
30e6ea3
ci: retrigger for flaky cpu.test.js integration failure
shashank-reddy-nr Jul 4, 2026
690ca74
test(kafkajs): cover #refreshAndRecordProduceMetrics and eachBatch cl…
shashank-reddy-nr Jul 4, 2026
720daec
test(kafkajs): fix lint errors in cluster-metrics unit tests
shashank-reddy-nr Jul 4, 2026
3b84613
perf(kafkajs): optimize cluster ID capture to avoid extra AdminClient…
shashank-reddy-nr Aug 5, 2026
d1adeed
fix(kafkajs): guard cluster produce metrics against malformed send() …
shashank-reddy-nr Aug 5, 2026
f933db5
fix(kafkajs): align cluster metric name with Nodes metric shape
shashank-reddy-nr Aug 7, 2026
a4db3b4
fix(kafkajs): force metadata fetch before reading clusterId in versio…
shashank-reddy-nr Aug 17, 2026
008c578
ci: retrigger for flaky action-download rate limit (503/429 on jwalto…
shashank-reddy-nr Aug 17, 2026
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
1 change: 1 addition & 0 deletions lib/feature_flags.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ exports.prerelease = {
reverse_naming_rules: false,
unresolved_promise_cleanup: true,
kafkajs_instrumentation: false,
kafka_cluster_metrics: false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future eyes. The spec states be gated behind an opt-in configuration kafka.metrics.cluster.metrics.enabled (disabled by default). This is a feature flag, not configuration. it should live in lib/config/default.js

undici_error_tracking: true
}

Expand Down
66 changes: 51 additions & 15 deletions lib/subscribers/kafkajs/client-constructor.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are the changes in this file not part of the exported class? Are the two caches really necessary? What is the expected size of these caches in a typical, or extreme, deployment scenario?

@shashank-reddy-nr shashank-reddy-nr Jun 18, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @jsumners-nr, I'm new to this repo and leaned heavily on AI assistance to implement this, so I apologize upfront for anything that doesn't follow conventions here.

The two caches serve different lifecycles:

  • cluster-id-cache.js is a module-level map (brokers string → cluster UUID). It's intentionally process-global because the cluster UUID is the same for every producer/consumer connecting to the same Kafka cluster - it makes no sense to refetch it per client instance.
  • _clusterIdByInstance doesn't exist here - the only per-instance storage is via the existing kafkaCtx symbol already set on each client/consumer.

In a typical deployment you'd have 1–3 distinct Kafka clusters, so the cache holds 1–3 entries. In an extreme multi-tenant scenario (many distinct broker strings) it could grow unbounded - that's a fair concern. Happy to add a size cap or TTL if you'd prefer.

Open to moving the fetch logic inside the class if that's the convention - I'll follow your guidance. Can you please guide me?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will only be one instance of ConstructorSubscriber for the duration of an application's lifecycle. It can manage any required caches.

If it is possible for the cache to grow infinitely, then a size limited LRU cache should be used. We do not currently include such cache functionality. So if this is really a necessary feature, we will need to implement lib/lru-map-cache.js.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest commit (3b6e628): the two maps are now #clusterIdCache and #clusterIdInFlight private fields on ConstructorSubscriber itself.

On LRU vs hard cap: I opted for a simpler MAX_CACHE_SIZE = 128 constant rather than a full LRU implementation. The rationale is that each cache entry represents one distinct broker list (i.e. one Kafka cluster). In practice, a single Node.js process connects to a small number of clusters — typically 1–3, rarely more than 10. 128 is already generous for any realistic deployment. If the cap is ever hit, new broker-sets simply do not get cached (we silently skip the cache.set) but still fetch the cluster ID on first call and return it — so the metric still fires, we just do not avoid the re-fetch for that broker-set.

If you would prefer a true LRU, I am happy to implement lib/lru-map-cache.js — just let me know and I will add it as part of this PR.

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const { kafkaCtx } = require('#agentlib/symbols.js')
const recordDataMetrics = require('./utils/record-data-metrics.js')
const recordLinkingMetrics = require('./utils/record-linking-metrics.js')
const recordMethodMetric = require('./utils/record-method-metric.js')
const recordClusterProduceMetrics = require('./utils/record-cluster-produce-metrics.js')
const readClusterId = require('./utils/read-cluster-id.js')

const CONSUMER_METHODS = [
'commitOffsets',
Expand Down Expand Up @@ -76,15 +78,19 @@ module.exports = class ConstructorSubscriber extends Subscriber {
end(data, ctx) {
const self = this
const { arguments: args, self: client } = data
client[kafkaCtx] = { brokers: args[0].brokers ?? ['none'] }
const brokers = args[0]?.brokers
client[kafkaCtx] = {
brokers: typeof brokers === 'function' ? [] : (brokers ?? ['none'])
}

const origConsumer = client.consumer
client.consumer = function nrConsumer(...args) {
const consumer = origConsumer.apply(client, args)
consumer[kafkaCtx] = client[kafkaCtx]
consumer[kafkaCtx].clientId = args[0]?.clientId ?? client[kafkaCtx]?.clientId ?? null

consumer.on(consumer.events.REQUEST, function nrListener(data) {
consumer[kafkaCtx].clientId = data?.payload?.clientId
consumer[kafkaCtx].clientId ??= data?.payload?.clientId
})
for (const method of CONSUMER_METHODS) {
self.#wrapConsumerMethod(consumer, method)
Expand Down Expand Up @@ -174,6 +180,8 @@ module.exports = class ConstructorSubscriber extends Subscriber {
'Not recording consumer function %s, not in a transaction',
methodName
)
const [data] = args
self.#refreshAndRecordProduceMetrics(instance, batch, data)
return orig.apply(instance, args)
}

Expand All @@ -196,24 +204,38 @@ module.exports = class ConstructorSubscriber extends Subscriber {
brokers: instance[kafkaCtx].brokers,
topic
})
self.#insertProduceDTHeaders(ctx, batch, data)
self.#refreshAndRecordProduceMetrics(instance, batch, data)

if (batch === false) {
for (const msg of data.messages) {
return self.agent.tracer.runInContext({ handler: orig, context: ctx, full: true, thisArg: instance, args })
}
}

#insertProduceDTHeaders(ctx, batch, data) {
if (batch === false) {
for (const msg of data.messages) {
const headers = msg.headers ?? {}
this.insertDTHeaders({ ctx, headers, useMqNames: true })
msg.headers = headers
}
} else {
for (const topicMessage of data.topicMessages) {
for (const msg of topicMessage.messages) {
const headers = msg.headers ?? {}
self.insertDTHeaders({ ctx, headers, useMqNames: true })
this.insertDTHeaders({ ctx, headers, useMqNames: true })
msg.headers = headers
}
} else {
for (const topicMessage of data.topicMessages) {
for (const msg of topicMessage.messages) {
const headers = msg.headers ?? {}
self.insertDTHeaders({ ctx, headers, useMqNames: true })
msg.headers = headers
}
}
}
}
}

return self.agent.tracer.runInContext({ handler: orig, context: ctx, full: true, thisArg: instance, args })
#refreshAndRecordProduceMetrics(instance, batch, data) {
if (!this.agent.config.feature_flag.kafka_cluster_metrics) {
return
}
const clusterId = readClusterId(instance)
if (clusterId) {
recordClusterProduceMetrics(this.agent.metrics, clusterId, batch, data)
}
}

Expand Down Expand Up @@ -285,6 +307,8 @@ module.exports = class ConstructorSubscriber extends Subscriber {
recordDataMetrics({
tx: ctx.transaction,
kafkaCtx: instance[kafkaCtx],
clusterId: self.agent.config.feature_flag.kafka_cluster_metrics ? readClusterId(instance) : undefined,
agentMetrics: self.agent.metrics,
data
})

Expand Down Expand Up @@ -335,12 +359,24 @@ module.exports = class ConstructorSubscriber extends Subscriber {
const eachBatch = args[0].eachBatch
args[0].eachBatch = function nrWrappedEachBatch() {
recordMethodMetric({ agent: self.agent, name: 'eachBatch' })
const { batch } = arguments[0]
recordLinkingMetrics({
brokers: instance[kafkaCtx].brokers,
agent: self.agent,
topic: arguments[0].batch.topic,
topic: batch.topic,
producer: false
})
// Emit one cluster Consume metric per message in the batch (opt-in only).
if (self.agent.config.feature_flag.kafka_cluster_metrics) {
const clusterId = readClusterId(instance)
if (clusterId) {
self.agent.metrics
.getOrCreateMetric(
`MessageBroker/Kafka/Cluster/${clusterId}/Consume/${batch.topic}`
)
.incrementCallCount(batch.messages.length)
}
}
return eachBatch.apply(instance, arguments)
}
return self.agent.tracer.runInContext({ handler: orig, context: ctx, full: true, thisArg: instance, args })
Expand Down
32 changes: 32 additions & 0 deletions lib/subscribers/kafkajs/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,37 @@ module.exports = {
className: 'Client'
}
}]
}, {
// kafkajs builds a brand new internal `Cluster` on every call to
// `.producer()` — it isn't reachable from the top-level client, so we
// have to capture it right where kafkajs itself creates it.
path: './kafkajs/producer-cluster-capture.js',
instrumentations: [{
module: {
name: modName,
filePath: 'src/producer/index.js',
versionRange: '>=2.0.0'
},
channelName: 'nr_producerClusterCapture',
functionQuery: {
expressionName: 'exports',
kind: 'Sync'
}
}]
}, {
// Same as above, for `.consumer()`.
path: './kafkajs/consumer-cluster-capture.js',
instrumentations: [{
module: {
name: modName,
filePath: 'src/consumer/index.js',
versionRange: '>=2.0.0'
},
channelName: 'nr_consumerClusterCapture',
functionQuery: {
expressionName: 'exports',
kind: 'Sync'
}
}]
}]
}
41 changes: 41 additions & 0 deletions lib/subscribers/kafkajs/consumer-cluster-capture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const Subscriber = require('../base.js')
const { kafkaCluster } = require('#agentlib/symbols.js')

/**
* Same capture as `producer-cluster-capture.js`, but for the internal
* consumer factory (`kafkajs/src/consumer/index.js`) — kafkajs also
* constructs a brand new `Cluster` instance on every call to
* `Kafka.prototype.consumer()`.
*
* @type {ConsumerClusterCaptureSubscriber}
*/
module.exports = class ConsumerClusterCaptureSubscriber extends Subscriber {
constructor({ agent, logger }) {
super({ agent, logger, channelName: 'nr_consumerClusterCapture', packageName: 'kafkajs' })
this.requireActiveTx = false
this.events = ['end']
}

get enabled() {
if (this.agent.config.feature_flag.kafkajs_instrumentation === false) {
return false
}

return super.enabled
}

end(data, ctx) {
const cluster = data?.arguments?.[0]?.cluster
if (cluster && data.result) {
data.result[kafkaCluster] = cluster
}
return ctx
}
}
44 changes: 44 additions & 0 deletions lib/subscribers/kafkajs/producer-cluster-capture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const Subscriber = require('../base.js')
const { kafkaCluster } = require('#agentlib/symbols.js')

/**
* kafkajs constructs a brand new internal `Cluster` instance on every call
* to `Kafka.prototype.producer()` — it is not reachable from the top-level
* `Kafka` client. This subscriber intercepts the internal producer factory
* (`kafkajs/src/producer/index.js`) at the point where kafkajs itself
* passes the freshly created `cluster` in, and stashes a reference to it on
* the returned producer instance so `read-cluster-id.js` can read the
* cluster id straight off it later — no admin connection, no cache needed.
*
* @type {ProducerClusterCaptureSubscriber}
*/
module.exports = class ProducerClusterCaptureSubscriber extends Subscriber {
constructor({ agent, logger }) {
super({ agent, logger, channelName: 'nr_producerClusterCapture', packageName: 'kafkajs' })
this.requireActiveTx = false
this.events = ['end']
}

get enabled() {
if (this.agent.config.feature_flag.kafkajs_instrumentation === false) {
return false
}

return super.enabled
}

end(data, ctx) {
const cluster = data?.arguments?.[0]?.cluster
if (cluster && data.result) {
data.result[kafkaCluster] = cluster
}
return ctx
}
}
31 changes: 31 additions & 0 deletions lib/subscribers/kafkajs/utils/read-cluster-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

const { kafkaCluster } = require('#agentlib/symbols.js')

/**
* Reads the Kafka cluster id off a producer/consumer instance. The `Cluster`
* reference is captured at producer()/consumer() creation time (see
* `producer-cluster-capture.js` / `consumer-cluster-capture.js`) and stashed
* on the instance via the `kafkaCluster` symbol. kafkajs keeps
* `cluster.brokerPool.metadata` current on its own as part of normal
* operation, so this is a synchronous, in-memory, best-effort read — no
* network call, no cache.
*
* @param {object} instance Producer or consumer client instance.
* @returns {string|undefined} The cluster id, or `undefined` if not yet
* available (e.g. a producer's very first send, before kafkajs has fetched
* any metadata).
*/
module.exports = function readClusterId(instance) {
try {
const clusterId = instance?.[kafkaCluster]?.brokerPool?.metadata?.clusterId
return typeof clusterId === 'string' && clusterId !== '' ? clusterId : undefined
} catch {
return undefined
}
}
41 changes: 41 additions & 0 deletions lib/subscribers/kafkajs/utils/record-cluster-produce-metrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2026 New Relic Corporation. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

/**
* Records MessageBroker/Kafka/Cluster cluster-level produce metrics.
* For send() records one metric for the single topic.
* For sendBatch() records one metric per distinct topic in the batch.
*
* @param {object} metrics The agent metrics aggregator.
* @param {string} clusterId Kafka cluster UUID.
* @param {boolean} batch Whether this is a sendBatch call.
* @param {object} data The send/sendBatch arguments object.
*/
module.exports = function recordClusterProduceMetrics(metrics, clusterId, batch, data) {
if (batch === false) {
if (!Array.isArray(data?.messages)) {
return
}
metrics
.getOrCreateMetric(`MessageBroker/Kafka/Cluster/${clusterId}/Produce/${data.topic}`)
.incrementCallCount(data.messages.length)
} else {
if (!Array.isArray(data?.topicMessages)) {
return
}
for (const topicMessage of data.topicMessages) {
if (!Array.isArray(topicMessage?.messages)) {
continue
}
metrics
.getOrCreateMetric(
`MessageBroker/Kafka/Cluster/${clusterId}/Produce/${topicMessage.topic}`
)
.incrementCallCount(topicMessage.messages.length)
}
}
}
11 changes: 10 additions & 1 deletion lib/subscribers/kafkajs/utils/record-data-metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ const { DESTINATIONS } = require('#agentlib/config/attribute-filter.js')
* @param {object} params.kafkaCtx The local context store we add to the
* consumer client.
* @param {Transaction} params.tx The current transaction.
* @param {string} [params.clusterId] Kafka cluster id, if resolved and cluster metrics are enabled.
* @param {object} params.agentMetrics Agent-level metrics aggregator (for cluster metrics).
*/
module.exports = function recordDataMetrics({ data, kafkaCtx, tx }) {
module.exports = function recordDataMetrics({ data, kafkaCtx, tx, clusterId, agentMetrics }) {
if (!tx) {
return
}
Expand All @@ -45,4 +47,11 @@ module.exports = function recordDataMetrics({ data, kafkaCtx, tx }) {
kafkaCtx.clientId
)
}
if (clusterId && agentMetrics) {
agentMetrics
.getOrCreateMetric(
`MessageBroker/Kafka/Cluster/${clusterId}/Consume/${topic}`
)
.incrementCallCount()
}
}
1 change: 1 addition & 0 deletions lib/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
databaseName: Symbol('databaseName'),
disableDT: Symbol('Disable distributed tracing'), // description for backwards compatibility
executorContext: Symbol('executorContext'),
kafkaCluster: Symbol('kafkaCluster'),
kafkaCtx: Symbol('kafkaCtx'),
name: Symbol('name'),
onceExecuted: Symbol('onceExecuted'),
Expand Down
1 change: 1 addition & 0 deletions test/unit/feature_flag.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const used = [
'aws_bedrock_instrumentation',
'langchain_instrumentation',
'kafkajs_instrumentation',
'kafka_cluster_metrics',
'undici_error_tracking'
]

Expand Down
Loading
Loading