Skip to content

Commit 455ec7b

Browse files
feat(kafka): add MessageBroker/Kafka/Cluster/{id}/Topic/{topic} metrics
Records per-cluster Kafka metrics (MessageBroker/Kafka/Cluster/{clusterId}/Topic/{topic}/Produce and MessageBroker/Kafka/Cluster/{clusterId}/Topic/{topic}/Consume) to let customers track throughput broken out by Kafka cluster, not just by topic. The cluster ID is fetched once per unique broker set via a background AdminClient call; the hot produce/consume path has no additional overhead. The feature is always-on and best-effort — it does not inject anything into Kafka wire headers and does not add span or custom attributes. Assisted-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 949339a commit 455ec7b

7 files changed

Lines changed: 544 additions & 39 deletions

File tree

newrelic/hooks/messagebroker_confluentkafka.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
# limitations under the License.
1414
import logging
1515
import sys
16+
import threading
17+
import weakref
1618

1719
from newrelic.api.application import application_instance
1820
from newrelic.api.error_trace import wrap_error_trace
@@ -33,6 +35,57 @@
3335
HEARTBEAT_SESSION_TIMEOUT = "MessageBroker/Kafka/Heartbeat/SessionTimeout"
3436
HEARTBEAT_POLL_TIMEOUT = "MessageBroker/Kafka/Heartbeat/PollTimeout"
3537

38+
KAFKA_CLUSTER_METRIC_PRODUCE = "MessageBroker/Kafka/Cluster/{0}/Topic/{1}/Produce"
39+
KAFKA_CLUSTER_METRIC_CONSUME = "MessageBroker/Kafka/Cluster/{0}/Topic/{1}/Consume"
40+
41+
42+
_nr_cluster_id_cache = {}
43+
_nr_cluster_id_cache_lock = threading.Lock()
44+
45+
46+
def _fetch_cluster_id(instance):
47+
servers = getattr(instance, "_nr_bootstrap_servers", None)
48+
# Sort so that equivalent broker sets with different orderings share the same key.
49+
cache_key = ",".join(sorted(servers)) if servers else None
50+
51+
if cache_key:
52+
with _nr_cluster_id_cache_lock:
53+
cached = _nr_cluster_id_cache.get(cache_key)
54+
if cached:
55+
instance._nr_cluster_id = cached
56+
return
57+
if cached is not None:
58+
return
59+
_nr_cluster_id_cache[cache_key] = ""
60+
61+
# Hold only a weak reference so the thread closure does not extend the
62+
# lifetime of a Producer/Consumer that the caller has already abandoned.
63+
instance_ref = weakref.ref(instance)
64+
65+
def _run():
66+
inst = instance_ref()
67+
if inst is None:
68+
# Instance was GC'd before the thread ran; clean up sentinel and exit.
69+
if cache_key:
70+
with _nr_cluster_id_cache_lock:
71+
_nr_cluster_id_cache.pop(cache_key, None)
72+
return
73+
try:
74+
meta = inst.list_topics(timeout=5)
75+
cluster_id = getattr(meta, "cluster_id", None)
76+
if cluster_id:
77+
inst._nr_cluster_id = cluster_id
78+
if cache_key:
79+
with _nr_cluster_id_cache_lock:
80+
_nr_cluster_id_cache[cache_key] = cluster_id
81+
except Exception as e:
82+
_logger.debug("NR Kafka cluster ID fetch failed", exc_info=True)
83+
if cache_key:
84+
with _nr_cluster_id_cache_lock:
85+
_nr_cluster_id_cache.pop(cache_key, None)
86+
87+
threading.Thread(target=_run, daemon=True, name="NR-Kafka-ClusterId").start()
88+
3689

3790
def wrap_Producer_produce(wrapped, instance, args, kwargs):
3891
transaction = current_transaction()
@@ -63,6 +116,17 @@ def wrap_Producer_produce(wrapped, instance, args, kwargs):
63116
for server_name in instance._nr_bootstrap_servers:
64117
transaction.record_custom_metric(f"MessageBroker/Kafka/Nodes/{server_name}/Produce/{topic}", 1)
65118

119+
cluster_id = getattr(instance, "_nr_cluster_id", None)
120+
if not cluster_id and hasattr(instance, "_nr_bootstrap_servers"):
121+
_cache_key = ",".join(sorted(instance._nr_bootstrap_servers))
122+
cluster_id = _nr_cluster_id_cache.get(_cache_key) or None
123+
if cluster_id:
124+
instance._nr_cluster_id = cluster_id # cache on instance for future calls
125+
if cluster_id:
126+
transaction.record_custom_metric(
127+
KAFKA_CLUSTER_METRIC_PRODUCE.format(cluster_id, topic), 1
128+
)
129+
66130
with MessageTrace(
67131
library="Kafka", operation="Produce", destination_type="Topic", destination_name=topic, source=wrapped
68132
):
@@ -171,6 +235,16 @@ def wrap_Consumer_poll(wrapped, instance, args, kwargs):
171235
transaction.record_custom_metric(
172236
f"MessageBroker/Kafka/Nodes/{server_name}/Consume/{destination_name}", 1
173237
)
238+
cluster_id = getattr(instance, "_nr_cluster_id", None)
239+
if not cluster_id and hasattr(instance, "_nr_bootstrap_servers"):
240+
_cache_key = ",".join(sorted(instance._nr_bootstrap_servers))
241+
cluster_id = _nr_cluster_id_cache.get(_cache_key) or None
242+
if cluster_id:
243+
instance._nr_cluster_id = cluster_id
244+
if cluster_id:
245+
transaction.record_custom_metric(
246+
KAFKA_CLUSTER_METRIC_CONSUME.format(cluster_id, destination_name), 1
247+
)
174248
transaction.add_messagebroker_info("Confluent-Kafka", get_package_version("confluent-kafka"))
175249

176250
return record
@@ -213,6 +287,16 @@ def wrap_SerializingProducer_init(wrapped, instance, args, kwargs):
213287
if hasattr(instance, "_value_serializer") and callable(instance._value_serializer):
214288
instance._value_serializer = wrap_serializer("Serialization/Value", "MessageBroker")(instance._value_serializer)
215289

290+
try:
291+
conf = kwargs.get("conf") or (args[0] if args else {})
292+
servers = conf.get("bootstrap.servers") if isinstance(conf, dict) else None
293+
if servers:
294+
instance._nr_bootstrap_servers = servers.split(",")
295+
except Exception:
296+
pass
297+
298+
_fetch_cluster_id(instance)
299+
216300

217301
def wrap_DeserializingConsumer_init(wrapped, instance, args, kwargs):
218302
wrapped(*args, **kwargs)
@@ -223,6 +307,16 @@ def wrap_DeserializingConsumer_init(wrapped, instance, args, kwargs):
223307
if hasattr(instance, "_value_deserializer") and callable(instance._value_deserializer):
224308
instance._value_deserializer = wrap_serializer("Deserialization/Value", "Message")(instance._value_deserializer)
225309

310+
try:
311+
conf = kwargs.get("conf") or (args[0] if args else {})
312+
servers = conf.get("bootstrap.servers") if isinstance(conf, dict) else None
313+
if servers:
314+
instance._nr_bootstrap_servers = servers.split(",")
315+
except Exception:
316+
pass
317+
318+
_fetch_cluster_id(instance)
319+
226320

227321
def wrap_Producer_init(wrapped, instance, args, kwargs):
228322
wrapped(*args, **kwargs)
@@ -236,6 +330,8 @@ def wrap_Producer_init(wrapped, instance, args, kwargs):
236330
except Exception:
237331
pass
238332

333+
_fetch_cluster_id(instance)
334+
239335

240336
def wrap_Consumer_init(wrapped, instance, args, kwargs):
241337
wrapped(*args, **kwargs)
@@ -249,6 +345,8 @@ def wrap_Consumer_init(wrapped, instance, args, kwargs):
249345
except Exception:
250346
pass
251347

348+
_fetch_cluster_id(instance)
349+
252350

253351
def wrap_immutable_class(module, class_name):
254352
# Wrap immutable binary extension class with a mutable Python subclass

0 commit comments

Comments
 (0)