import json
import os
import sys
import asyncio
import datetime
import uuid
from abc import ABC, abstractmethod
import importlib
from pathlib import Path
sys.path.append(os.getcwd())
from confluent_kafka.admin import AdminClient
from src.base.clickhouse_kafka_sender import ClickHouseKafkaSender
from src.base.utils import setup_config, ensure_directory
from src.base.execution import (
create_pipeline_executor,
run_thread_worker_pool,
start_pipeline_worker_replicas,
)
from src.base.kafka import (
ExactlyOnceKafkaConsumeHandler,
ExactlyOnceKafkaProduceHandler,
KafkaMessageFetchException,
ensure_topics,
)
from src.base.log_config import get_logger
module_name = "pipeline.alerter"
logger = get_logger(module_name)
config = setup_config()
CONSUME_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"].get(
"detector_to_alerter", "pipeline-detector_to_alerter"
)
ALERTING_CONFIG = config["pipeline"].get("alerting", {})
ALTERTERS = ALERTING_CONFIG.get("plugins", [])
PLUGIN_PATH = "src.alerter.plugins"
class AlerterAbstractBase(ABC):
"""
Abstract base class for all alerter implementations.
"""
@abstractmethod
def __init__(self, alerter_config, consume_topic) -> None:
pass
@abstractmethod
def process_alert(self) -> None:
"""
Process the alert data. Subclasses can mutate self.alert_data.
"""
pass
[docs]
class AlerterBase(AlerterAbstractBase):
"""
Base implementation for Alerters in the pipeline.
This class handles the common logic for consuming alerts from Kafka,
executing custom processing via plugins, and performing base actions
like logging to a file or forwarding to an external Kafka topic.
"""
def __init__(self, alerter_config, consume_topic) -> None:
self.name = alerter_config.get("name", "generic")
self.consume_topic = consume_topic
self.alerter_config = alerter_config
self.alert_data = None
self.key = None
self.kafka_consume_handler = ExactlyOnceKafkaConsumeHandler(self.consume_topic)
self.kafka_produce_handler = None
self.server_log_terminal_events = ClickHouseKafkaSender(
"server_log_terminal_events"
)
# Base actions config
self.log_to_file = ALERTING_CONFIG.get("log_to_file", False)
self.log_file_path = ALERTING_CONFIG.get(
"log_file_path", "/opt/logs/alerts.txt"
)
self.log_rotation_config = ALERTING_CONFIG.get("log_rotation", {})
self.log_rotation_enabled = self.log_rotation_config.get("enabled", False)
self.log_retention_days = self._parse_log_retention_days(
self.log_rotation_config.get("retention_days", 7)
)
self._last_log_cleanup_date = None
self.log_to_kafka = ALERTING_CONFIG.get("log_to_kafka", False)
self.external_kafka_topic = ALERTING_CONFIG.get(
"external_kafka_topic", "external_alerts_topic"
)
if self.log_to_file:
ensure_directory(self.log_file_path)
if self.log_to_kafka:
self._setup_kafka_output_topics()
def _setup_kafka_output_topics(self):
"""
Ensure that the external Kafka topic exists.
Since no internal consumer subscribes to this topic, auto-creation
via consumer polling won't happen. We use AdminClient to ensure
the topic exists before producing to it.
"""
brokers = ",".join(
[
f"{broker['hostname']}:{broker['internal_port']}"
for broker in config["environment"]["kafka_brokers"]
]
)
admin_client = AdminClient({"bootstrap.servers": brokers})
try:
ensure_topics(admin_client, [self.external_kafka_topic])
except Exception as e:
logger.warning(
f"Could not auto-create topic {self.external_kafka_topic}: {e}"
)
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
@staticmethod
def _parse_log_retention_days(retention_days) -> int | None:
"""
Parse the configured rotated log retention period.
"""
if retention_days is None:
return None
try:
retention_days = int(retention_days)
except (TypeError, ValueError):
logger.warning(
"Invalid alert log retention_days '%s'. Keeping rotated logs for 7 days.",
retention_days,
)
return 7
if retention_days < 1:
logger.warning(
"Invalid alert log retention_days '%s'. Keeping rotated logs for 1 day.",
retention_days,
)
return 1
return retention_days
def _get_active_log_file_path(
self, timestamp: datetime.datetime | None = None
) -> str:
if not self.log_rotation_enabled:
return self.log_file_path
timestamp = timestamp or datetime.datetime.now()
log_path = Path(self.log_file_path)
rotated_name = f"{log_path.stem}-{timestamp:%Y-%m-%d}{log_path.suffix}"
return str(log_path.with_name(rotated_name))
def _cleanup_rotated_logs(self, today: datetime.date | None = None) -> None:
if not self.log_rotation_enabled or self.log_retention_days is None:
return
today = today or datetime.date.today()
if self._last_log_cleanup_date == today:
return
log_path = Path(self.log_file_path)
cutoff_date = today - datetime.timedelta(days=self.log_retention_days - 1)
for candidate in log_path.parent.glob(f"{log_path.stem}-*{log_path.suffix}"):
log_date = self._extract_rotated_log_date(candidate)
if log_date is None or log_date >= cutoff_date:
continue
try:
candidate.unlink()
logger.info("%s: Removed expired alert log %s", self.name, candidate)
except OSError as e:
logger.warning(
"%s: Could not remove expired alert log %s: %s",
self.name,
candidate,
e,
)
self._last_log_cleanup_date = today
def _extract_rotated_log_date(self, log_path: Path) -> datetime.date | None:
stem_prefix = f"{Path(self.log_file_path).stem}-"
if not log_path.stem.startswith(stem_prefix):
return None
date_value = log_path.stem[len(stem_prefix) :]
try:
return datetime.datetime.strptime(date_value, "%Y-%m-%d").date()
except ValueError:
return None
def get_and_fill_data(self, source_message=None) -> None:
if self.alert_data:
logger.warning(
"Alerter is busy: Not consuming new messages. Wait for the Alerter to finish the current workload."
)
return
key, data = self.kafka_consume_handler.consume_as_json(source_message)
if data:
self.alert_data = data
self.key = key
logger.info(f"Received alert for processing. Belongs to subnet_id {key}.")
else:
logger.info(f"Received empty alert message.")
def clear_data(self) -> None:
self.alert_data = None
self.key = None
def _log_to_file_action(self):
"""
Append the current alert_data to the configured log file.
"""
if not self.log_to_file:
return
active_log_file_path = self._get_active_log_file_path()
ensure_directory(active_log_file_path)
self._cleanup_rotated_logs()
logger.info(f"{self.name}: Logging alert to file {active_log_file_path}")
try:
with open(active_log_file_path, "a+") as f:
json.dump(self.alert_data, f)
f.write("\n")
except IOError as e:
logger.error(f"{self.name}: Error writing alert to file: {e}")
raise
def _log_to_kafka_action(self):
"""
Forward the current alert_data to the external Kafka topic.
"""
if not self.log_to_kafka:
return
logger.info(
f"{self.name}: Forwarding alert to topic {self.external_kafka_topic}"
)
try:
self.kafka_produce_handler.produce(
topic=self.external_kafka_topic,
data=json.dumps(self.alert_data),
key=self.key,
)
except Exception as e:
logger.error(f"{self.name}: Error forwarding alert: {e}")
raise
def _extract_server_message_ids(self) -> set[uuid.UUID]:
server_message_ids = set()
def visit(value):
if isinstance(value, dict):
if value.get("server_message_id"):
self._add_server_message_id(
server_message_ids, value["server_message_id"]
)
if isinstance(value.get("server_message_ids"), list):
for server_message_id in value["server_message_ids"]:
self._add_server_message_id(
server_message_ids, server_message_id
)
for nested_value in value.values():
visit(nested_value)
elif isinstance(value, list):
for item in value:
visit(item)
visit(self.alert_data)
return server_message_ids
@staticmethod
def _add_server_message_id(
server_message_ids: set[uuid.UUID],
server_message_id,
) -> None:
try:
server_message_ids.add(uuid.UUID(str(server_message_id)))
except (TypeError, ValueError):
logger.warning(
"Ignoring non-UUID LogServer message id '%s'.", server_message_id
)
def _record_alerter_terminal_events(
self, server_message_ids: set[uuid.UUID]
) -> None:
if not server_message_ids:
return
timestamp = datetime.datetime.now()
for server_message_id in server_message_ids:
self.server_log_terminal_events.insert(
dict(
message_id=server_message_id,
stage=module_name,
status="processed",
timestamp=timestamp,
)
)
def bootstrap_alerter_instance(self):
"""
Main loop for the alerter instance.
Consumes alerts, processes them, and executes base actions.
"""
logger.info(f"Starting {self.name} Alerter")
while True:
try:
source_messages = self.kafka_consume_handler.consume_batch()
if not source_messages:
continue
if self.kafka_produce_handler is None:
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
with self.kafka_produce_handler.transaction_batch(
self.kafka_consume_handler, source_messages
):
for source_message in source_messages:
try:
self.get_and_fill_data(source_message)
if self.alert_data:
server_message_ids = self._extract_server_message_ids()
# 1. Process specific action
self.process_alert()
# 2. Executing Base Logging Actions
self._log_to_file_action()
self._log_to_kafka_action()
self._record_alerter_terminal_events(server_message_ids)
finally:
self.clear_data()
except KafkaMessageFetchException as e:
logger.debug(e)
except IOError as e:
logger.error(e)
raise e
except ValueError as e:
logger.debug(e)
except KeyboardInterrupt:
logger.info(f" {self.consume_topic} Closing down Alerter...")
break
except Exception as e:
logger.error(f"Unexpected error: {e}")
async def start(self):
loop = asyncio.get_running_loop()
executor = create_pipeline_executor(config, module_name, self.name)
try:
await loop.run_in_executor(executor, self.bootstrap_alerter_instance)
finally:
executor.shutdown(wait=False, cancel_futures=True)
def build_alerter_worker(alerter_config, consume_topic, worker_id=None):
class_name = alerter_config.get("alerter_class_name", "GenericAlerter")
alerter_module_name = alerter_config.get("alerter_module_name", "generic_alerter")
plugin_module_name = f"{PLUGIN_PATH}.{alerter_module_name}"
plugin_module = importlib.import_module(plugin_module_name)
alerter_class = getattr(plugin_module, class_name)
worker = alerter_class(alerter_config=alerter_config, consume_topic=consume_topic)
worker.worker_id = worker_id
return worker
def run_alerter_worker_process(
process_index,
threads_per_process,
alerter_config,
consume_topic,
):
def worker_factory(worker_id):
return build_alerter_worker(
alerter_config=alerter_config,
consume_topic=consume_topic,
worker_id=worker_id,
)
run_thread_worker_pool(
worker_factory=worker_factory,
target_name="bootstrap_alerter_instance",
module_name=module_name,
instance_name=alerter_config.get("name", "generic"),
process_index=process_index,
threads_per_process=threads_per_process,
)
async def main():
tasks = []
# Setup Generic Alerter Task
generic_topic = f"{CONSUME_TOPIC_PREFIX}-generic"
logger.info("Initializing Generic Alerter")
generic_config = {"name": "generic"}
def generic_worker_factory(
worker_id,
generic_config=generic_config,
generic_topic=generic_topic,
):
return build_alerter_worker(
alerter_config=generic_config,
consume_topic=generic_topic,
worker_id=worker_id,
)
tasks.append(
asyncio.create_task(
start_pipeline_worker_replicas(
config=config,
module_name=module_name,
instance_name="generic",
worker_factory=generic_worker_factory,
target_name="bootstrap_alerter_instance",
process_entrypoint=run_alerter_worker_process,
process_args=(generic_config, generic_topic),
)
)
)
# Setup Specific Custom Alerter Tasks
if ALTERTERS:
for alerter_config in ALTERTERS:
logger.info(f"Initializing Custom Alerter: {alerter_config['name']}")
consume_topic = f"{CONSUME_TOPIC_PREFIX}-{alerter_config['name']}"
def worker_factory(
worker_id,
alerter_config=alerter_config,
consume_topic=consume_topic,
):
return build_alerter_worker(
alerter_config=alerter_config,
consume_topic=consume_topic,
worker_id=worker_id,
)
tasks.append(
asyncio.create_task(
start_pipeline_worker_replicas(
config=config,
module_name=module_name,
instance_name=alerter_config["name"],
worker_factory=worker_factory,
target_name="bootstrap_alerter_instance",
process_entrypoint=run_alerter_worker_process,
process_args=(alerter_config, consume_topic),
)
)
)
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())