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
15e62a3
feat(kafka): emit messaging.kafka.cluster.id unconditionally across k…
shashank-reddy-nr Jul 13, 2026
193777e
feat(instrumentation/aiokafka): add messaging.kafka.cluster.id to pro…
shashank-reddy-nr Jul 14, 2026
1fe9ae1
fix(instrumentation/aiokafka): move start wrappers inline to fix pyri…
shashank-reddy-nr Jul 14, 2026
e4c312b
fix(instrumentation/aiokafka): move _patch_cluster_id_capture to __in…
shashank-reddy-nr Jul 14, 2026
e1ceb1b
fix(instrumentation/aiokafka): correct changelog entry for PR 4727
shashank-reddy-nr Jul 14, 2026
f26d610
fix(instrumentation/kafka-python): source cluster id from client meta…
shashank-reddy-nr Jul 15, 2026
d61b428
fix: place PR 4727 changelog fragment in root .changelog/ for all thr…
shashank-reddy-nr Jul 15, 2026
a419ba5
fix(instrumentation/kafka-python): keep test helper under pylint too-…
shashank-reddy-nr Jul 15, 2026
eefa7a5
refactor(kafka): rename cluster-id constant to _MESSAGING_KAFKA_CLUST…
shashank-reddy-nr Jul 15, 2026
f441b9b
confluent-kafka: replace background-thread cache with per-instance li…
shashank-reddy-nr Jul 27, 2026
909a79e
confluent-kafka: remove per-instance cluster_id cache from _extract_c…
shashank-reddy-nr Jul 27, 2026
2632f87
confluent-kafka: skip list_topics() on consumers to avoid librdkafka …
shashank-reddy-nr Jul 31, 2026
905fe25
test(confluent-kafka): add @staticmethod to test_consumer_does_not_ca…
shashank-reddy-nr Jul 31, 2026
c1d8ae5
fix(confluent-kafka): move pylint disable inside class body to suppre…
shashank-reddy-nr Jul 31, 2026
ab9849e
fix(aiokafka): replace update_metadata monkey-patch with MetadataRequ…
shashank-reddy-nr Aug 1, 2026
a55cef3
fix(aiokafka): resolve pyright and pylint errors in MetadataRequest c…
shashank-reddy-nr Aug 1, 2026
0f747c4
style(aiokafka): remove docstrings from instrumentation functions and…
shashank-reddy-nr Aug 1, 2026
2fb2695
fix(confluent-kafka): allow consumers to call list_topics() as cache …
shashank-reddy-nr Aug 1, 2026
ccf030d
fix(aiokafka): restore type: ignore for wrapt import, apply ruff format
shashank-reddy-nr Aug 1, 2026
cccda08
fix: sort wrapt import in aiokafka instrumentation
shashank-reddy-nr Aug 2, 2026
c0fbf81
Merge branch 'main' into feature/kafka-cluster-id
shashank-reddy-nr Aug 3, 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 .changelog/4727.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-instrumentation-aiokafka`, `opentelemetry-instrumentation-confluent-kafka`, `opentelemetry-instrumentation-kafka-python`: emit `messaging.kafka.cluster.id` on producer and consumer spans
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ async def produce():

from __future__ import annotations

import time
from inspect import iscoroutinefunction
from typing import TYPE_CHECKING, Collection
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Collection, cast

import aiokafka
from wrapt import (
Expand Down Expand Up @@ -131,6 +132,74 @@ class UninstrumentKwargs(TypedDict, total=False):
pass


_CLUSTER_ID_FAILURE_BACKOFF_SECS = 300 # 5 minutes

try:
from aiokafka.protocol.metadata import (
MetadataRequest_v5 as _MetadataRequestV5,
)
except ImportError:
_MetadataRequestV5 = None


async def _fetch_and_cache_cluster_id(
client: aiokafka.AIOKafkaClient,
) -> None:
if getattr(client, "_otel_cluster_id", None):
return

failure_time: float | None = getattr(
client, "_otel_cluster_id_failure_time", None
)
if (
failure_time is not None
and time.monotonic() - failure_time < _CLUSTER_ID_FAILURE_BACKOFF_SECS
):
return

if _MetadataRequestV5 is None:
return

try:
node_id: int | None = cast(int | None, client.get_random_node())
if node_id is None:
await client.force_metadata_update()
node_id = cast(int | None, client.get_random_node())
if node_id is None:
return
response: object = await cast(Any, client).send(
node_id,
_MetadataRequestV5(topics=[], allow_auto_topic_creation=False),
)
cluster_id: str = getattr(response, "cluster_id", "") or ""
if cluster_id:
client._otel_cluster_id = cluster_id # type: ignore[attr-defined]
else:
client._otel_cluster_id_failure_time = time.monotonic() # type: ignore[attr-defined]
except Exception: # pylint: disable=broad-except
client._otel_cluster_id_failure_time = time.monotonic() # type: ignore[attr-defined]


async def _start_producer_wrapper(
func: Callable[..., Awaitable[None]],
instance: aiokafka.AIOKafkaProducer,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> None:
await func(*args, **kwargs)
await _fetch_and_cache_cluster_id(instance.client)

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.

Something is odd with aiokafka tests after wrapping start.



async def _start_consumer_wrapper(
func: Callable[..., Awaitable[None]],
instance: aiokafka.AIOKafkaConsumer,
args: tuple[Any, ...],
kwargs: dict[str, Any],
) -> None:
await func(*args, **kwargs)
await _fetch_and_cache_cluster_id(instance._client)


class AIOKafkaInstrumentor(BaseInstrumentor):
"""An instrumentor for kafka module
See `BaseInstrumentor`
Expand Down Expand Up @@ -165,6 +234,16 @@ def _instrument(self, **kwargs: Unpack[InstrumentKwargs]):
schema_url=Schemas.V1_27_0.value,
)

wrap_function_wrapper(
aiokafka.AIOKafkaProducer,
"start",
_start_producer_wrapper,
)
wrap_function_wrapper(
aiokafka.AIOKafkaConsumer,
"start",
_start_consumer_wrapper,
)
wrap_function_wrapper(
aiokafka.AIOKafkaProducer,
"send",
Expand All @@ -182,6 +261,8 @@ def _instrument(self, **kwargs: Unpack[InstrumentKwargs]):
)

def _uninstrument(self, **kwargs: Unpack[UninstrumentKwargs]):
unwrap(aiokafka.AIOKafkaProducer, "start")
unwrap(aiokafka.AIOKafkaConsumer, "start")
unwrap(aiokafka.AIOKafkaProducer, "send")
unwrap(aiokafka.AIOKafkaConsumer, "getone")
unwrap(aiokafka.AIOKafkaConsumer, "getmany")
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ async def __call__(

_LOG = getLogger(__name__)

# TODO(semconv #3819): once generated in opentelemetry-semantic-conventions,
# use messaging_attributes.MESSAGING_KAFKA_CLUSTER_ID instead of this literal.
_MESSAGING_KAFKA_CLUSTER_ID = "messaging.kafka.cluster.id"

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.

I understand that the attribute has already been added to the semantic conventions, but it hasn’t been released yet. Could we wait for the release before using it here? I’ll raise this at the next SIG meeting to get guidance on whether instrumentations should mix attributes from different semantic convention versions without the opt-in gate.

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.

Yes, we can wait until the next semantic-conventions release. I recently learned that if an attribute hasn't been added to semantic-conventions yet, we should add an opt-in experimental attribute instead. Once the attribute is present and released in sem-conv, we can directly replace the current TODO with messaging_attributes.MESSAGING_KAFKA_CLUSTER_ID.



def _extract_bootstrap_servers(
client: aiokafka.AIOKafkaClient,
Expand All @@ -90,6 +94,13 @@ def _extract_client_id(client: aiokafka.AIOKafkaClient) -> str:
return client._client_id


def _extract_cluster_id_from_client(
client: aiokafka.AIOKafkaClient,
) -> str | None:
cluster_id: str | None = getattr(client, "_otel_cluster_id", None)
return cluster_id if cluster_id else None


def _extract_consumer_group(
consumer: aiokafka.AIOKafkaConsumer,
) -> str | None:
Expand Down Expand Up @@ -237,6 +248,7 @@ def _enrich_base_span(
topic: str,
partition: int | None,
key: str | None,
cluster_id: str | None = None,
) -> None:
span.set_attribute(
messaging_attributes.MESSAGING_SYSTEM,
Expand All @@ -259,6 +271,9 @@ def _enrich_base_span(
messaging_attributes.MESSAGING_KAFKA_MESSAGE_KEY, key
)

if cluster_id is not None:
span.set_attribute(_MESSAGING_KAFKA_CLUSTER_ID, cluster_id)


def _enrich_send_span(
span: Span,
Expand All @@ -268,6 +283,7 @@ def _enrich_send_span(
topic: str,
partition: int | None,
key: str | None,
cluster_id: str | None = None,
) -> None:
if not span.is_recording():
return
Expand All @@ -279,6 +295,7 @@ def _enrich_send_span(
topic=topic,
partition=partition,
key=key,
cluster_id=cluster_id,
)

span.set_attribute(messaging_attributes.MESSAGING_OPERATION_NAME, "send")
Expand All @@ -298,6 +315,7 @@ def _enrich_getone_span(
partition: int | None,
key: str | None,
offset: int,
cluster_id: str | None = None,
) -> None:
if not span.is_recording():
return
Expand All @@ -309,6 +327,7 @@ def _enrich_getone_span(
topic=topic,
partition=partition,
key=key,
cluster_id=cluster_id,
)

if consumer_group is not None:
Expand Down Expand Up @@ -344,6 +363,7 @@ def _enrich_getmany_poll_span(
client_id: str,
consumer_group: str | None,
message_count: int,
cluster_id: str | None = None,
) -> None:
if not span.is_recording():
return
Expand All @@ -357,6 +377,9 @@ def _enrich_getmany_poll_span(
)
span.set_attribute(messaging_attributes.MESSAGING_CLIENT_ID, client_id)

if cluster_id is not None:
span.set_attribute(_MESSAGING_KAFKA_CLUSTER_ID, cluster_id)

if consumer_group is not None:
span.set_attribute(
messaging_attributes.MESSAGING_CONSUMER_GROUP_NAME, consumer_group
Expand Down Expand Up @@ -384,6 +407,7 @@ def _enrich_getmany_topic_span(
topic: str,
partition: int,
message_count: int,
cluster_id: str | None = None,
) -> None:
if not span.is_recording():
return
Expand All @@ -395,6 +419,7 @@ def _enrich_getmany_topic_span(
topic=topic,
partition=partition,
key=None,
cluster_id=cluster_id,
)

if consumer_group is not None:
Expand All @@ -420,7 +445,8 @@ def _get_span_name(operation: str, topic: str):


def _wrap_send( # type: ignore[reportUnusedFunction]
tracer: Tracer, async_produce_hook: ProduceHookT | None
tracer: Tracer,
async_produce_hook: ProduceHookT | None,
) -> Callable[..., Awaitable[asyncio.Future[RecordMetadata]]]:
async def _traced_send(
func: AIOKafkaSendProto,
Expand All @@ -439,6 +465,7 @@ async def _traced_send(
client_id = _extract_client_id(instance.client)
key = _deserialize_key(_extract_send_key(args, kwargs))
partition = await _extract_send_partition(instance, args, kwargs)
cluster_id = _extract_cluster_id_from_client(instance.client)
span_name = _get_span_name("send", topic)
with tracer.start_as_current_span(
span_name, kind=trace.SpanKind.PRODUCER
Expand All @@ -450,6 +477,7 @@ async def _traced_send(
topic=topic,
partition=partition,
key=key,
cluster_id=cluster_id,
)
propagate.inject(
headers,
Expand All @@ -461,8 +489,13 @@ async def _traced_send(
await async_produce_hook(span, args, kwargs)
except Exception as hook_exception: # pylint: disable=W0703
_LOG.exception(hook_exception)

return await func(*args, **kwargs)
result = await func(*args, **kwargs)
# After send(), broker has responded — refresh cluster ID in case
# metadata was not yet populated before the send started.
cluster_id = _extract_cluster_id_from_client(instance.client)
if cluster_id is not None and span.is_recording():
span.set_attribute(_MESSAGING_KAFKA_CLUSTER_ID, cluster_id)
return result

return _traced_send

Expand All @@ -475,6 +508,7 @@ async def _create_consumer_span(
bootstrap_servers: str | list[str],
client_id: str,
consumer_group: str | None,
cluster_id: str | None,
args: tuple[aiokafka.TopicPartition, ...],
kwargs: dict[str, Any],
) -> trace.Span:
Expand All @@ -495,6 +529,7 @@ async def _create_consumer_span(
partition=record.partition,
key=_deserialize_key(record.key),
offset=record.offset,
cluster_id=cluster_id,
)
try:
if async_consume_hook is not None:
Expand All @@ -508,7 +543,8 @@ async def _create_consumer_span(


def _wrap_getone( # type: ignore[reportUnusedFunction]
tracer: Tracer, async_consume_hook: ConsumeHookT | None
tracer: Tracer,
async_consume_hook: ConsumeHookT | None,
) -> Callable[..., Awaitable[aiokafka.ConsumerRecord[object, object]]]:
async def _traced_getone(
func: AIOKafkaGetOneProto,
Expand All @@ -522,6 +558,7 @@ async def _traced_getone(
bootstrap_servers = _extract_bootstrap_servers(instance._client)
client_id = _extract_client_id(instance._client)
consumer_group = _extract_consumer_group(instance)
cluster_id = _extract_cluster_id_from_client(instance._client)

extracted_context = propagate.extract(
record.headers, getter=_aiokafka_getter
Expand All @@ -534,6 +571,7 @@ async def _traced_getone(
bootstrap_servers,
client_id,
consumer_group,
cluster_id,
args,
kwargs,
)
Expand All @@ -543,7 +581,8 @@ async def _traced_getone(


def _wrap_getmany( # type: ignore[reportUnusedFunction]
tracer: Tracer, async_consume_hook: ConsumeHookT | None
tracer: Tracer,
async_consume_hook: ConsumeHookT | None,
) -> Callable[
...,
Awaitable[
Expand All @@ -567,6 +606,7 @@ async def _traced_getmany(
bootstrap_servers = _extract_bootstrap_servers(instance._client)
client_id = _extract_client_id(instance._client)
consumer_group = _extract_consumer_group(instance)
cluster_id = _extract_cluster_id_from_client(instance._client)

span_name = _get_span_name(
"receive",
Expand All @@ -581,6 +621,7 @@ async def _traced_getmany(
client_id=client_id,
consumer_group=consumer_group,
message_count=sum(len(r) for r in records.values()),
cluster_id=cluster_id,
)

for topic, topic_records in records.items():
Expand All @@ -596,6 +637,7 @@ async def _traced_getmany(
topic=topic.topic,
partition=topic.partition,
message_count=len(topic_records),
cluster_id=cluster_id,
)

for record in topic_records:
Expand All @@ -610,6 +652,7 @@ async def _traced_getmany(
bootstrap_servers,
client_id,
consumer_group,
cluster_id,
args,
kwargs,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ def test_instrument_api(self) -> None:
instrumentation = AIOKafkaInstrumentor()

instrumentation.instrument()
self.assertTrue(
isinstance(AIOKafkaProducer.start, BoundFunctionWrapper)
)
self.assertTrue(
isinstance(AIOKafkaConsumer.start, BoundFunctionWrapper)
)
self.assertTrue(
isinstance(AIOKafkaProducer.send, BoundFunctionWrapper)
)
Expand All @@ -41,6 +47,12 @@ def test_instrument_api(self) -> None:
)

instrumentation.uninstrument()
self.assertFalse(
isinstance(AIOKafkaProducer.start, BoundFunctionWrapper)
)
self.assertFalse(
isinstance(AIOKafkaConsumer.start, BoundFunctionWrapper)
)
self.assertFalse(
isinstance(AIOKafkaProducer.send, BoundFunctionWrapper)
)
Expand Down
Loading
Loading