Skip to content

Commit b640c26

Browse files
Data Streams live messages (DataDog#20512)
* Data Streams: log messages from Kafka * run lint
1 parent c718d15 commit b640c26

8 files changed

Lines changed: 471 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ tmp
9898
.python-version
9999

100100
# Common IDEs & editors
101+
.cursor/
101102
.idea/
102103
.vscode/
103104
*.iml
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
kafka_consumer check can retrieve messages from Kafka and log them.

kafka_consumer/datadog_checks/kafka_consumer/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,13 @@ def list_consumer_group_offsets(self, groups):
172172
offsets.append((response_offset_info.group_id, tpo))
173173
return offsets
174174

175+
def start_collecting_messages(self, start_offsets):
176+
self.open_consumer('datadog_live_messages')
177+
self._consumer.assign(start_offsets)
178+
179+
def get_next_message(self):
180+
return self._consumer.poll(timeout=1)
181+
175182
def describe_consumer_group(self, consumer_group):
176183
desc = self.kafka_client.describe_consumer_groups([consumer_group])[consumer_group].result()
177184
return desc.state.name

kafka_consumer/datadog_checks/kafka_consumer/config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ def __init__(self, init_config, instance, log) -> None:
8080
):
8181
self._tls_ca_cert = '/opt/datadog-agent/embedded/ssl/certs/cacert.pem'
8282

83+
# Data Streams live messages
84+
self.live_messages_configs = instance.get('live_messages_configs', [])
85+
8386
def validate_config(self):
8487
if not self._kafka_connect_str:
8588
raise ConfigurationError('`kafka_connect_str` is required')
@@ -124,6 +127,42 @@ def validate_config(self):
124127
)
125128

126129
self._validate_consumer_groups()
130+
self._validate_live_messages_configs()
131+
132+
def _validate_live_messages_configs(self):
133+
live_messages_configs = []
134+
for config in self.live_messages_configs:
135+
if 'id' not in config:
136+
self.log.debug('Data Streams live messages configuration has no ID')
137+
continue
138+
kafka = config.get('kafka', None)
139+
if not kafka:
140+
self.log.debug('Data Streams live messages configuration has no kafka configuration')
141+
continue
142+
if not (
143+
'cluster' in kafka
144+
and 'topic' in kafka
145+
and 'partition' in kafka
146+
and 'start_offset' in kafka
147+
and 'n_messages' in kafka
148+
):
149+
self.log.debug('Data Streams live messages configuration missing required kafka parameters.', kafka)
150+
continue
151+
# Only json format is supported for Data Streams live messages
152+
if kafka.get('value_format', '') == '':
153+
kafka['value_format'] = 'json'
154+
if kafka['value_format'] != 'json':
155+
self.log.debug(
156+
'Only json format is supported for Data Streams live messages, got %s', kafka['value_format']
157+
)
158+
if kafka.get('key_format', '') == '':
159+
kafka['key_format'] = 'json'
160+
if kafka['key_format'] != 'json':
161+
self.log.debug(
162+
'Only json format is supported for Data Streams live messages, got %s', kafka['key_format']
163+
)
164+
live_messages_configs.append(config)
165+
self.live_messages_configs = live_messages_configs
127166

128167
def _compile_regex(self, consumer_groups_regex, consumer_groups):
129168
# Turn the dict of regex dicts into a single string and compile

kafka_consumer/datadog_checks/kafka_consumer/kafka_consumer.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
from collections import defaultdict
66
from time import time
77

8+
from confluent_kafka import TopicPartition
9+
810
from datadog_checks.base import AgentCheck, is_affirmative
911
from datadog_checks.kafka_consumer.client import KafkaClient
1012
from datadog_checks.kafka_consumer.config import KafkaConfig
1113
from datadog_checks.kafka_consumer.constants import KAFKA_INTERNAL_TOPICS, OFFSET_INVALID
1214

1315
MAX_TIMESTAMPS = 1000
16+
SCHEMA_REGISTRY_MAGIC_BYTE = 0x00
17+
DATA_STREAMS_MESSAGES_CACHE_KEY = 'get_messages_cache'
1418

1519

1620
class KafkaCheck(AgentCheck):
@@ -96,6 +100,7 @@ def check(self, _):
96100
)
97101
if self.config._close_admin_client:
98102
self.client.close_admin_client()
103+
self.data_streams_live_message(highwater_offsets or {}, cluster_id)
99104

100105
def get_consumer_offsets(self):
101106
# {(consumer_group, topic, partition): offset}
@@ -175,6 +180,30 @@ def _load_broker_timestamps(self, persistent_cache_key):
175180
self.log.warning('Could not read broker timestamps from cache: %s', str(e))
176181
return broker_timestamps
177182

183+
def _messages_have_been_retrieved(self, config_id):
184+
"""Check if messages have been retrieved for the given config ID."""
185+
try:
186+
content = self.read_persistent_cache(DATA_STREAMS_MESSAGES_CACHE_KEY)
187+
if content:
188+
config_ids = set(content.split(","))
189+
return config_id in config_ids
190+
except Exception as e:
191+
self.log.warning('Could not read persistent cache: %s', str(e))
192+
return False
193+
194+
def _mark_messages_retrieved(self, config_id):
195+
"""Mark that messages have been retrieved for the given config ID."""
196+
try:
197+
content = self.read_persistent_cache(DATA_STREAMS_MESSAGES_CACHE_KEY)
198+
if content:
199+
config_ids = set(content.split(","))
200+
else:
201+
config_ids = set()
202+
config_ids.add(config_id)
203+
self.write_persistent_cache(DATA_STREAMS_MESSAGES_CACHE_KEY, ",".join(config_ids))
204+
except Exception as e:
205+
self.log.warning('Could not write to persistent cache: %s', str(e))
206+
178207
def _add_broker_timestamps(self, broker_timestamps, highwater_offsets):
179208
for (topic, partition), highwater_offset in highwater_offsets.items():
180209
timestamps = broker_timestamps["{}_{}".format(topic, partition)]
@@ -378,6 +407,78 @@ def send_event(self, title, text, tags, event_type, aggregation_key, severity='i
378407
}
379408
self.event(event_dict)
380409

410+
def data_streams_live_message(self, highwater_offsets, cluster_id):
411+
for cfg in self.config.live_messages_configs:
412+
kafka = cfg['kafka']
413+
topic = kafka["topic"]
414+
partition = kafka["partition"]
415+
start_offset = kafka["start_offset"]
416+
n_messages = kafka["n_messages"]
417+
cluster = kafka["cluster"]
418+
config_id = cfg["id"]
419+
if self._messages_have_been_retrieved(config_id):
420+
continue
421+
if cluster != cluster_id:
422+
continue
423+
start_offsets = resolve_start_offsets(highwater_offsets, topic, partition, start_offset, n_messages)
424+
425+
if not start_offsets:
426+
self.log.warning('Unable to get a list of partitions to read from for live messages')
427+
self.send_log(
428+
{
429+
'timestamp': int(time()),
430+
'config_id': config_id,
431+
'technology': 'kafka',
432+
'cluster': str(cluster),
433+
'topic': str(topic),
434+
'live_messages_error': 'Unable to list partitions to read from',
435+
'message': "Unable to list partitions to read from",
436+
}
437+
)
438+
continue
439+
440+
self.client.start_collecting_messages(start_offsets)
441+
for _ in range(n_messages):
442+
message = self.client.get_next_message()
443+
if message is None:
444+
self.log.debug('Live messages: no message to retrieve')
445+
self.send_log(
446+
{
447+
'timestamp': int(time()),
448+
'config_id': config_id,
449+
'technology': 'kafka',
450+
'cluster': str(cluster),
451+
'topic': str(topic),
452+
'live_messages_error': 'No more messages to retrieve',
453+
'message': "No more messages to retrieve",
454+
}
455+
)
456+
break
457+
data = {
458+
'timestamp': int(time()),
459+
'technology': 'kafka',
460+
'cluster': str(cluster),
461+
'config_id': config_id,
462+
'topic': str(topic),
463+
'partition': str(message.partition()),
464+
'offset': str(message.offset()),
465+
}
466+
decoded_value, value_schema_id, decoded_key, key_schema_id = deserialize_message(message)
467+
if decoded_value:
468+
data['message_value'] = decoded_value
469+
else:
470+
data['message'] = "Message format not supported"
471+
data['live_messages_error'] = 'Message format not supported'
472+
if value_schema_id:
473+
data['value_schema_id'] = str(value_schema_id)
474+
if decoded_key:
475+
data['message_key'] = decoded_key
476+
if key_schema_id:
477+
data['key_schema_id'] = str(key_schema_id)
478+
self.send_log(data)
479+
self.client.close_consumer()
480+
self._mark_messages_retrieved(config_id)
481+
381482

382483
def _get_interpolated_timestamp(timestamps, offset):
383484
if offset in timestamps:
@@ -406,3 +507,66 @@ def _get_interpolated_timestamp(timestamps, offset):
406507
slope = (timestamp_after - timestamp_before) / float(offset_after - offset_before)
407508
timestamp = slope * (offset - offset_after) + timestamp_after
408509
return timestamp
510+
511+
512+
def resolve_start_offsets(highwater_offsets, target_topic, target_partition, start_offset, n_messages):
513+
if int(target_partition) == -1:
514+
# in this case, we get n_messages, starting at offset latest - n_messages on each partition.
515+
# this doesn't match exactly to the latest messages, but if we don't do that, we could run into
516+
# edge cases when some partitions don't get any traffic.
517+
start_offsets = []
518+
for topic, partition in highwater_offsets:
519+
if topic == target_topic and highwater_offsets[(topic, partition)] >= 0:
520+
start_offsets.append(
521+
TopicPartition(topic, partition, max(0, highwater_offsets[(topic, partition)] - n_messages + 1))
522+
)
523+
if len(start_offsets) >= n_messages:
524+
break
525+
return start_offsets
526+
if int(start_offset) == -1:
527+
end_offset = highwater_offsets.get((target_topic, target_partition), -1)
528+
return (
529+
[]
530+
if end_offset < 0
531+
else [TopicPartition(target_topic, target_partition, max(0, end_offset - n_messages + 1))]
532+
)
533+
return [TopicPartition(target_topic, target_partition, start_offset)]
534+
535+
536+
def deserialize_message(message):
537+
try:
538+
decoded_value, value_schema_id = _deserialize_bytes_maybe_schema_registry(message.value())
539+
except (UnicodeDecodeError, json.JSONDecodeError):
540+
return None, None, None, None
541+
try:
542+
decoded_key, key_schema_id = _deserialize_bytes_maybe_schema_registry(message.key())
543+
return decoded_value, value_schema_id, decoded_key, key_schema_id
544+
except (UnicodeDecodeError, json.JSONDecodeError):
545+
return decoded_value, value_schema_id, None, None
546+
547+
548+
def _deserialize_bytes_maybe_schema_registry(message):
549+
try:
550+
return _deserialize_bytes(message), None
551+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
552+
# If the message is not a valid JSON, it might be a schema registry message, that is prefixed
553+
# with a magic byte and a schema ID.
554+
if len(message) < 5 or message[0] != SCHEMA_REGISTRY_MAGIC_BYTE:
555+
raise e
556+
schema_id = int.from_bytes(message[1:5], 'big')
557+
message = message[5:] # Skip the schema ID bytes
558+
return _deserialize_bytes(message), schema_id
559+
560+
561+
def _deserialize_bytes(message):
562+
"""Deserialize a message from Kafka. Supports JSON format.
563+
Args:
564+
message: Raw message bytes from Kafka
565+
Returns:
566+
Decoded message as a string
567+
"""
568+
if not message:
569+
return ""
570+
decoded = message.decode('utf-8')
571+
json.loads(decoded)
572+
return decoded

kafka_consumer/tests/runners.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,33 @@ def run(self):
3838
while not self._shutdown_event.is_set():
3939
for partition in PARTITIONS:
4040
try:
41-
producer.produce('marvel', b"Peter Parker", partition=partition)
42-
producer.produce('marvel', b"Bruce Banner", partition=partition)
43-
producer.produce('marvel', b"Tony Stark", partition=partition)
44-
producer.produce('marvel', b"Johhny Blaze", partition=partition)
45-
producer.produce('marvel', b"\xc2BoomShakalaka", partition=partition)
41+
producer.produce(
42+
'marvel',
43+
b'{"name": "Peter Parker", "age": 18, "transaction_amount": 123, "currency": "dollar"}',
44+
partition=partition,
45+
)
46+
producer.produce(
47+
'marvel',
48+
key=b'{"name": "Bruce Banner"}',
49+
value=b'\x00\x00\x00\x01\x5e{"name": "Bruce Banner",\
50+
"age": 45, "transaction_amount": 456, "currency": "dollar"}',
51+
partition=partition,
52+
)
53+
producer.produce(
54+
'marvel',
55+
b'{"name": "Tony Stark", "age": 35, "transaction_amount": 789, "currency": "dollar"}',
56+
partition=partition,
57+
)
58+
producer.produce(
59+
'marvel',
60+
b'{"name": "Johnny Blaze", "age": 30, "transaction_amount": 321, "currency": "dollar"}',
61+
partition=partition,
62+
)
63+
producer.produce(
64+
'marvel',
65+
b'{"name": "BoomShakalaka", "age": 25, "transaction_amount": 654, "currency": "dollar"}',
66+
partition=partition,
67+
)
4668
producer.produce('dc', b"Diana Prince", partition=partition)
4769
producer.produce('dc', b"Bruce Wayne", partition=partition)
4870
producer.produce('dc', b"Clark Kent", partition=partition)

kafka_consumer/tests/test_integration.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,51 @@ def test_regex_consumer_groups(
466466
aggregator.assert_metric("kafka.estimated_consumer_lag", count=consumer_lag_seconds_count)
467467

468468
assert expected_warning in caplog.text
469+
470+
471+
@mock.patch('datadog_checks.kafka_consumer.kafka_consumer.time', mocked_time)
472+
def test_data_streams_live_messages(dd_run_check, check, kafka_instance, datadog_agent):
473+
cluster_id = common.get_cluster_id()
474+
kafka_instance['live_messages_configs'] = [
475+
{
476+
'kafka': {
477+
'cluster': cluster_id,
478+
'topic': 'marvel',
479+
'partition': 0,
480+
'start_offset': 0,
481+
'n_messages': 2,
482+
'value_format': 'json',
483+
},
484+
'id': 'config_1_id',
485+
}
486+
]
487+
kafka_check = check(kafka_instance)
488+
dd_run_check(kafka_check)
489+
expected_logs = [
490+
{
491+
'timestamp': 400 * 1000,
492+
'technology': 'kafka',
493+
'cluster': str(cluster_id),
494+
'config_id': 'config_1_id',
495+
'topic': 'marvel',
496+
'partition': '0',
497+
'offset': '0',
498+
'message_value': '{"name": "Peter Parker", "age": 18, "transaction_amount": 123, "currency": "dollar"}',
499+
'ddtags': 'optional:tag1',
500+
},
501+
{
502+
'timestamp': 400 * 1000,
503+
'technology': 'kafka',
504+
'cluster': str(cluster_id),
505+
'config_id': 'config_1_id',
506+
'topic': 'marvel',
507+
'partition': '0',
508+
'offset': '1',
509+
'message_value': '{"name": "Bruce Banner", "age": 45,\
510+
"transaction_amount": 456, "currency": "dollar"}',
511+
'value_schema_id': '350',
512+
'message_key': '{"name": "Bruce Banner"}',
513+
'ddtags': 'optional:tag1',
514+
},
515+
]
516+
datadog_agent.assert_logs(kafka_check.check_id, expected_logs)

0 commit comments

Comments
 (0)