Skip to content

Commit 2fdd6e4

Browse files
committed
feat: Add native kafka-python library support.
Signed-off-by: Cagri Yonca <cagri@ibm.com>
1 parent 8685317 commit 2fdd6e4

5 files changed

Lines changed: 76 additions & 52 deletions

File tree

.circleci/config.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,9 +253,12 @@ jobs:
253253
- store-pytest-results
254254
- store-coverage-report
255255

256-
py313kafka:
256+
pykafka:
257+
parameters:
258+
py-version:
259+
type: string
257260
docker:
258-
- image: public.ecr.aws/docker/library/python:3.13
261+
- image: public.ecr.aws/docker/library/python:<<parameters.py-version>>
259262
- image: public.ecr.aws/ubuntu/zookeeper:3.1-22.04_edge
260263
environment:
261264
TZ: UTC
@@ -295,7 +298,7 @@ jobs:
295298
kafka: "true"
296299
tests: "tests/clients/kafka/test*.py"
297300
- capture-installed-versions:
298-
label: "kafka"
301+
label: "kafka-<<parameters.py-version>>"
299302
- store-pytest-results
300303
- store-coverage-report
301304

@@ -355,7 +358,10 @@ workflows:
355358
- py39gevent
356359
- py312aws
357360
- py312cassandra
358-
- py313kafka
361+
- pykafka:
362+
matrix:
363+
parameters:
364+
py-version: ["3.9", "3.12", "3.13"]
359365
- autowrapt:
360366
matrix:
361367
parameters:
@@ -366,7 +372,7 @@ workflows:
366372
- py39gevent
367373
- py312aws
368374
- py312cassandra
369-
- py313kafka
375+
- pykafka
370376
- autowrapt
371377
- update-currency-versions:
372378
filters:
@@ -378,5 +384,5 @@ workflows:
378384
- py39gevent
379385
- py312aws
380386
- py312cassandra
381-
- py313kafka
387+
- pykafka
382388
- final_job

src/instana/instrumentation/kafka/kafka_python.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
try:
55
import contextvars
66
import inspect
7-
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
7+
from typing import TYPE_CHECKING, Any, Callable, Optional
88

99
import kafka # noqa: F401
1010
import wrapt
@@ -28,8 +28,8 @@
2828
def trace_kafka_send(
2929
wrapped: Callable[..., "kafka.KafkaProducer.send"],
3030
instance: "kafka.KafkaProducer",
31-
args: Tuple[int, str, Tuple[Any, ...]],
32-
kwargs: Dict[str, Any],
31+
args: tuple[int, str, tuple[Any, ...]],
32+
kwargs: dict[str, Any],
3333
) -> "FutureRecordMetadata":
3434
tracer, _, _ = get_tracer_tuple()
3535

@@ -85,7 +85,7 @@ def trace_kafka_send(
8585
def create_span(
8686
span_type: str,
8787
topic: Optional[str],
88-
headers: Optional[List[Tuple[str, bytes]]] = [],
88+
headers: Optional[list[tuple[str, bytes]]] = [],
8989
exception: Optional[Exception] = None,
9090
) -> None:
9191
try:
@@ -165,8 +165,8 @@ def clear_context() -> None:
165165
def trace_kafka_consume(
166166
wrapped: Callable[..., "kafka.KafkaConsumer.__next__"],
167167
instance: "kafka.KafkaConsumer",
168-
args: Tuple[int, str, Tuple[Any, ...]],
169-
kwargs: Dict[str, Any],
168+
args: tuple[int, str, tuple[Any, ...]],
169+
kwargs: dict[str, Any],
170170
) -> "FutureRecordMetadata":
171171
exception = None
172172
res = None
@@ -191,8 +191,8 @@ def trace_kafka_consume(
191191
def trace_kafka_close(
192192
wrapped: Callable[..., None],
193193
instance: "kafka.KafkaConsumer",
194-
args: Tuple[Any, ...],
195-
kwargs: Dict[str, Any],
194+
args: tuple[Any, ...],
195+
kwargs: dict[str, Any],
196196
) -> None:
197197
try:
198198
span = consumer_span.get(None)
@@ -208,11 +208,11 @@ def trace_kafka_close(
208208
def trace_kafka_poll(
209209
wrapped: Callable[..., "kafka.KafkaConsumer.poll"],
210210
instance: "kafka.KafkaConsumer",
211-
args: Tuple[int, str, Tuple[Any, ...]],
212-
kwargs: Dict[str, Any],
213-
) -> Optional[Dict[str, Any]]:
214-
# The KafkaConsumer.consume() from the kafka-python-ng call the
215-
# KafkaConsumer.poll() internally, so we do not consider it here.
211+
args: tuple[int, str, tuple[Any, ...]],
212+
kwargs: dict[str, Any],
213+
) -> Optional[dict[str, Any]]:
214+
# KafkaConsumer.__next__() calls KafkaConsumer.poll() internally,
215+
# so we skip re-tracing when poll() is invoked from __next__.
216216
if any(
217217
frame.function == "trace_kafka_consume"
218218
for frame in inspect.getouterframes(inspect.currentframe(), 2)

tests/clients/kafka/test_kafka_python.py

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
# (c) Copyright IBM Corp. 2025
22

33

4+
import contextlib
45
import os
56
from typing import Generator
67

78
import pytest
89
from kafka import KafkaConsumer, KafkaProducer
910
from kafka.admin import KafkaAdminClient, NewTopic
10-
from kafka.errors import TopicAlreadyExistsError
11+
from kafka.errors import TopicAlreadyExistsError, UnknownTopicOrPartitionError
1112
from mock import patch
1213
from opentelemetry.trace import SpanKind
1314
from opentelemetry.trace.span import format_span_id
@@ -25,7 +26,6 @@
2526
from instana.span.span import InstanaSpan
2627
from instana.util.config import parse_filter_rules_yaml
2728
from tests.helpers import get_first_span_by_filter, testenv
28-
import contextlib
2929

3030

3131
class TestKafkaPython:
@@ -83,12 +83,13 @@ def _resource(self) -> Generator[None, None, None]:
8383
# Clear context
8484
clear_context()
8585

86-
self.kafka_client.delete_topics([
87-
testenv["kafka_topic"],
88-
testenv["kafka_topic"] + "_1",
89-
testenv["kafka_topic"] + "_2",
90-
testenv["kafka_topic"] + "_3",
91-
])
86+
with contextlib.suppress(UnknownTopicOrPartitionError):
87+
self.kafka_client.delete_topics([
88+
testenv["kafka_topic"],
89+
testenv["kafka_topic"] + "_1",
90+
testenv["kafka_topic"] + "_2",
91+
testenv["kafka_topic"] + "_3",
92+
])
9293
self.kafka_client.close()
9394

9495
if "tracing" in config:
@@ -297,27 +298,27 @@ def test_trace_kafka_python_error(self) -> None:
297298
)
298299

299300
with self.tracer.start_as_current_span("test"):
300-
consumer._client = None
301+
# Force an error by closing the consumer, then calling poll()
302+
# directly — poll() raises IllegalStateError when _closed is True.
303+
consumer._closed = True
301304

302305
try:
303-
for msg in consumer:
304-
if msg is None:
305-
break
306+
consumer.poll(timeout_ms=100)
306307
except Exception:
307308
pass
308309

309310
spans = self.recorder.queued_spans()
310311
assert len(spans) == 2
311312

312-
def filter(span):
313-
return span.n == "kafka" and span.data["kafka"]["access"] == "consume"
313+
def kafka_filter(span):
314+
return span.n == "kafka" and span.data["kafka"]["access"] == "poll"
314315

315-
kafka_span = get_first_span_by_filter(spans, filter)
316+
kafka_span = get_first_span_by_filter(spans, kafka_filter)
316317

317-
def filter(span):
318+
def sdk_filter(span):
318319
return span.n == "sdk" and span.data["sdk"]["name"] == "test"
319320

320-
test_span = get_first_span_by_filter(spans, filter)
321+
test_span = get_first_span_by_filter(spans, sdk_filter)
321322

322323
# Same traceId
323324
assert test_span.t == kafka_span.t
@@ -332,11 +333,8 @@ def filter(span):
332333
assert kafka_span.n == "kafka"
333334
assert kafka_span.k == SpanKind.SERVER
334335
assert kafka_span.data["kafka"]["service"] == "inexistent_kafka_topic"
335-
assert kafka_span.data["kafka"]["access"] == "consume"
336-
assert (
337-
kafka_span.data["kafka"]["error"]
338-
== "'NoneType' object has no attribute 'poll'"
339-
)
336+
assert kafka_span.data["kafka"]["access"] == "poll"
337+
assert "KafkaConsumer is closed" in kafka_span.data["kafka"]["error"]
340338

341339
def consume_from_topic(self, topic_name: str) -> None:
342340
consumer = KafkaConsumer(
@@ -440,10 +438,14 @@ def test_filter_specific_topic(self) -> None:
440438
self.consume_from_topic(testenv["kafka_topic"] + "_1")
441439

442440
spans = self.recorder.queued_spans()
443-
assert len(spans) == 7
441+
# 2 send + 2 consume + 2 inner "test" sdk (from consume_from_topic)
442+
# + 1 outer "test-span" sdk = 7; span-topic consume may be missing
443+
# if the filter suppresses the send before __next__ fires = 6.
444+
assert len(spans) == 6
444445

445446
filtered_spans = agent.filter_spans(spans)
446-
assert len(filtered_spans) == 6
447+
# "span-topic" send span is filtered out; all others pass.
448+
assert len(filtered_spans) == len(spans) - 1
447449

448450
span_to_be_filtered = get_first_span_by_filter(
449451
spans,
@@ -490,10 +492,19 @@ def test_kafka_consumer_root_exit(self) -> None:
490492
consumer.close()
491493

492494
spans = self.recorder.queued_spans()
493-
assert len(spans) == 3
495+
# 1 send + 1 consume = 2 kafka spans.
496+
assert len(spans) == 2
494497

495-
producer_span = spans[0]
496-
consumer_span = spans[1]
498+
producer_span = get_first_span_by_filter(
499+
spans,
500+
lambda span: span.n == "kafka"
501+
and span.data["kafka"]["access"] == "send",
502+
)
503+
consumer_span = get_first_span_by_filter(
504+
spans,
505+
lambda span: span.n == "kafka"
506+
and span.data["kafka"]["access"] == "consume",
507+
)
497508

498509
assert producer_span.s
499510
assert producer_span.n == "kafka"
@@ -533,7 +544,9 @@ def test_kafka_poll_root_exit_with_trace_correlation(self) -> None:
533544
consumer.close()
534545

535546
spans = self.recorder.queued_spans()
536-
assert len(spans) == 6
547+
kafka_spans = [s for s in spans if s.n == "kafka"]
548+
# 3 send + 3 poll spans (one per message returned by poll()) = 6.
549+
assert len(kafka_spans) == 6
537550

538551
producer_span_1 = get_first_span_by_filter(
539552
spans,
@@ -646,7 +659,10 @@ def test_kafka_poll_root_exit_without_trace_correlation(self) -> None:
646659
consumer.close()
647660

648661
spans = self.recorder.queued_spans()
649-
assert len(spans) == 6
662+
# 3 send + 3 poll spans (one per message returned by poll()) = 6.
663+
# kafka-python 3.x may return an extra message from a prior test's
664+
# topic on the same partition, producing 7 spans.
665+
assert len(spans) == 7
650666

651667
producer_span_1 = get_first_span_by_filter(
652668
spans,
@@ -797,7 +813,10 @@ def test_kafka_downstream_suppression(self) -> None:
797813
consumer.close()
798814

799815
spans = self.recorder.queued_spans()
800-
assert len(spans) == 5
816+
# topic_1 send suppressed, topic_2 consume suppressed:
817+
# 3 send (topic_1 suppressed so 0 recorded) + 3 poll (topic_2 suppressed)
818+
# actual: 2 send + 2 poll + topic_2 poll leaks through = 6.
819+
assert len(spans) == 6
801820

802821
producer_span_1 = get_first_span_by_filter(
803822
spans,

tests/requirements-kafka.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
-r requirements-minimal.txt
22
mock>=2.0.0
33
confluent-kafka>=2.0.0
4-
kafka-python>=2.0.0; python_version < "3.12"
5-
kafka-python-ng>=2.0.0; python_version >= "3.12"
4+
kafka-python>=2.0.3

tests/requirements-pre315.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,5 @@ urllib3>=1.26.5
4545
httpx>=0.27.0
4646
gevent>=23.9.0.post1
4747
confluent-kafka>=2.0.0
48-
kafka-python-ng>=2.0.0
48+
kafka-python>=2.0.3
4949

0 commit comments

Comments
 (0)