import asyncio
import datetime
import os
import sys
import uuid
import aiofiles
sys.path.append(os.getcwd())
from src.base.kafka import (
ExactlyOnceKafkaConsumeHandler,
ExactlyOnceKafkaProduceHandler,
)
from src.base.clickhouse_kafka_sender import ClickHouseKafkaSender
from src.base.utils import setup_config, get_zeek_sensor_topic_base_names
from src.base.execution import (
create_pipeline_executor,
run_thread_worker_pool,
start_pipeline_worker_replicas,
)
from src.base.log_config import get_logger
module_name = "log_storage.logserver"
logger = get_logger(module_name)
config = setup_config()
CONSUME_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
"logserver_in"
]
PRODUCE_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
"logserver_to_collector"
]
SENSOR_PROTOCOLS = get_zeek_sensor_topic_base_names(config)
READ_FROM_FILE = config["pipeline"]["log_storage"]["logserver"]["input_file"]
COLLECTORS = [
collector for collector in config["pipeline"]["log_collection"]["collectors"]
]
[docs]
class LogServer:
"""Main component of the Log Storage stage to enter data into the pipeline
Receives and sends single log lines. Simultaneously, listens for messages via Kafka and reads
newly added lines from an input file. Sends every log line to a Kafka topic under which it is obtained by
the next stage.
"""
def __init__(self, consume_topic, produce_topics) -> None:
self.consume_topic = consume_topic
self.produce_topics = produce_topics
self.kafka_consume_handler = ExactlyOnceKafkaConsumeHandler(consume_topic)
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
self.monitoring_kafka_producer = ClickHouseKafkaSender.create_shared_producer()
# databases
self.server_logs = ClickHouseKafkaSender(
"server_logs", self.monitoring_kafka_producer
)
self.server_logs_timestamps = ClickHouseKafkaSender(
"server_logs_timestamps", self.monitoring_kafka_producer
)
async def start(self) -> None:
"""Starts the tasks to both fetch messages from Kafka and read them from the input file."""
logger.info(
"LogServer started:\n"
f" ⤷ receiving on Kafka topic '{self.consume_topic}'\n"
f" ⤷ sending on Kafka topics '{self.produce_topics}'"
)
loop = asyncio.get_running_loop()
executor = create_pipeline_executor(config, module_name, self.consume_topic)
try:
await loop.run_in_executor(executor, self.fetch_from_kafka)
finally:
executor.shutdown(wait=False, cancel_futures=True)
# if awaited completely then the while True has come to an end
logger.info("LogServer stopped.")
def send(self, message_id: uuid.UUID, message: str) -> None:
"""Sends a message using Kafka.
Logs the time of sending the message to Kafka as a "timestamp_out" event.
Args:
message_id (uuid.UUID): UUID of the message to be sent.
message (str): Message to be sent.
"""
for topic in self.produce_topics:
self.kafka_produce_handler.produce(
topic=topic,
data=message,
key=str(message_id),
)
logger.debug(f"Sent: '{message}' to topic {topic}")
self.server_logs_timestamps.insert(
dict(
message_id=message_id,
event="timestamp_out",
event_timestamp=datetime.datetime.now(),
)
)
def fetch_from_kafka(self) -> None:
"""Fetches data from the configured Kafka topic in a loop.
Starts an asynchronous loop to continuously fetch new data from the Kafka topic.
When a message is consumed, the unprocessed log line string including
its timestamp ("timestamp_in") is logged.
"""
while True:
source_messages = self.kafka_consume_handler.consume_batch()
if not source_messages:
continue
with self.kafka_produce_handler.transaction_batch(
self.kafka_consume_handler, source_messages
):
for source_message in source_messages:
value = source_message.value
if value is None:
raise ValueError(
"LogServer received a Kafka record without data."
)
logger.debug(f"From Kafka: '{value}'")
message_id = uuid.uuid4()
self.server_logs.insert(
dict(
message_id=message_id,
timestamp_in=datetime.datetime.now(),
message_text=value,
)
)
self.send(message_id, value)
def build_logserver_worker(consume_topic, produce_topics, worker_id=None):
worker = LogServer(consume_topic=consume_topic, produce_topics=produce_topics)
worker.worker_id = worker_id
return worker
def run_logserver_worker_process(
process_index, threads_per_process, consume_topic, produce_topics
):
def worker_factory(worker_id):
return build_logserver_worker(
consume_topic=consume_topic,
produce_topics=produce_topics,
worker_id=worker_id,
)
run_thread_worker_pool(
worker_factory=worker_factory,
target_name="fetch_from_kafka",
module_name=module_name,
instance_name=consume_topic,
process_index=process_index,
threads_per_process=threads_per_process,
)
async def main() -> None:
"""
Creates the :class:`LogServer` instance and starts it for every topic used by any of the Zeek-sensors.
"""
tasks = []
for protocol in SENSOR_PROTOCOLS:
consume_topic = f"{CONSUME_TOPIC_PREFIX}-{protocol}"
produce_topics = [
f'{PRODUCE_TOPIC_PREFIX}-{collector["name"]}'
for collector in COLLECTORS
if collector["protocol_base"] == protocol
]
def worker_factory(
worker_id,
consume_topic=consume_topic,
produce_topics=produce_topics,
):
return build_logserver_worker(
consume_topic=consume_topic,
produce_topics=produce_topics,
worker_id=worker_id,
)
tasks.append(
asyncio.create_task(
start_pipeline_worker_replicas(
config=config,
module_name=module_name,
instance_name=consume_topic,
worker_factory=worker_factory,
target_name="fetch_from_kafka",
process_entrypoint=run_logserver_worker_process,
process_args=(consume_topic, produce_topics),
)
)
)
await asyncio.gather(*tasks)
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())