|
5 | 5 | from collections import defaultdict |
6 | 6 | from time import time |
7 | 7 |
|
| 8 | +from confluent_kafka import TopicPartition |
| 9 | + |
8 | 10 | from datadog_checks.base import AgentCheck, is_affirmative |
9 | 11 | from datadog_checks.kafka_consumer.client import KafkaClient |
10 | 12 | from datadog_checks.kafka_consumer.config import KafkaConfig |
11 | 13 | from datadog_checks.kafka_consumer.constants import KAFKA_INTERNAL_TOPICS, OFFSET_INVALID |
12 | 14 |
|
13 | 15 | MAX_TIMESTAMPS = 1000 |
| 16 | +SCHEMA_REGISTRY_MAGIC_BYTE = 0x00 |
| 17 | +DATA_STREAMS_MESSAGES_CACHE_KEY = 'get_messages_cache' |
14 | 18 |
|
15 | 19 |
|
16 | 20 | class KafkaCheck(AgentCheck): |
@@ -96,6 +100,7 @@ def check(self, _): |
96 | 100 | ) |
97 | 101 | if self.config._close_admin_client: |
98 | 102 | self.client.close_admin_client() |
| 103 | + self.data_streams_live_message(highwater_offsets or {}, cluster_id) |
99 | 104 |
|
100 | 105 | def get_consumer_offsets(self): |
101 | 106 | # {(consumer_group, topic, partition): offset} |
@@ -175,6 +180,30 @@ def _load_broker_timestamps(self, persistent_cache_key): |
175 | 180 | self.log.warning('Could not read broker timestamps from cache: %s', str(e)) |
176 | 181 | return broker_timestamps |
177 | 182 |
|
| 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 | + |
178 | 207 | def _add_broker_timestamps(self, broker_timestamps, highwater_offsets): |
179 | 208 | for (topic, partition), highwater_offset in highwater_offsets.items(): |
180 | 209 | timestamps = broker_timestamps["{}_{}".format(topic, partition)] |
@@ -378,6 +407,78 @@ def send_event(self, title, text, tags, event_type, aggregation_key, severity='i |
378 | 407 | } |
379 | 408 | self.event(event_dict) |
380 | 409 |
|
| 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 | + |
381 | 482 |
|
382 | 483 | def _get_interpolated_timestamp(timestamps, offset): |
383 | 484 | if offset in timestamps: |
@@ -406,3 +507,66 @@ def _get_interpolated_timestamp(timestamps, offset): |
406 | 507 | slope = (timestamp_after - timestamp_before) / float(offset_after - offset_before) |
407 | 508 | timestamp = slope * (offset - offset_after) + timestamp_after |
408 | 509 | 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 |
0 commit comments