From 94cc4380090421c702fae18f0fc947995f6e110c Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Fri, 26 Sep 2025 13:06:16 +0200 Subject: [PATCH 01/62] Remove unncessary dockerfiles --- config.yaml | 6 ++--- data/.gitkeep | 0 data/cic/.gitkeep | 0 data/cic/cic_dns_decode.py | 26 ------------------- data/dgta/.gitkeep | 0 data/dgta/dgta_decode.py | 17 ------------ docker/benchmark_tests/Dockerfile.run_test | 17 ------------ .../docker-compose.run_test.yml | 20 -------------- .../docker-compose.swarm-pipeline.yml | 9 +++++++ .../docker-compose.swarm-run_test.yml | 16 ------------ docker/docker-compose.query.yml | 21 --------------- docker/docker-compose.send-mock-logs.yml | 21 --------------- docker/docker-compose.send-real-logs.yml | 13 ---------- .../base/docker-compose.kafka.yml | 6 ++--- docker/dockerfiles/Dockerfile.dev-mock-logs | 16 ------------ docker/dockerfiles/Dockerfile.dev-query | 15 ----------- docker/dockerfiles/Dockerfile.dev-real-logs | 18 ------------- generate-env.sh | 5 ++++ {docker => scripts}/dev-get-csv-data.py | 0 {docker => scripts}/mock_logs.dev.py | 0 {docker => scripts}/query.dev.py | 0 {docker => scripts}/real_logs.dev.py | 0 .../benchmark_tests => scripts}/run_test.py | 0 23 files changed, 20 insertions(+), 206 deletions(-) delete mode 100644 data/.gitkeep delete mode 100644 data/cic/.gitkeep delete mode 100644 data/cic/cic_dns_decode.py delete mode 100644 data/dgta/.gitkeep delete mode 100644 data/dgta/dgta_decode.py delete mode 100644 docker/benchmark_tests/Dockerfile.run_test delete mode 100644 docker/benchmark_tests/docker-compose.run_test.yml delete mode 100644 docker/docker-compose-swarm/docker-compose.swarm-run_test.yml delete mode 100644 docker/docker-compose.query.yml delete mode 100644 docker/docker-compose.send-mock-logs.yml delete mode 100644 docker/docker-compose.send-real-logs.yml delete mode 100644 docker/dockerfiles/Dockerfile.dev-mock-logs delete mode 100644 docker/dockerfiles/Dockerfile.dev-query delete mode 100644 docker/dockerfiles/Dockerfile.dev-real-logs create mode 100644 generate-env.sh rename {docker => scripts}/dev-get-csv-data.py (100%) rename {docker => scripts}/mock_logs.dev.py (100%) rename {docker => scripts}/query.dev.py (100%) rename {docker => scripts}/real_logs.dev.py (100%) rename {docker/benchmark_tests => scripts}/run_test.py (100%) diff --git a/config.yaml b/config.yaml index 87f5ee5a..ba2dc06b 100644 --- a/config.yaml +++ b/config.yaml @@ -71,11 +71,11 @@ pipeline: environment: kafka_brokers: - hostname: kafka1 - port: 8097 + port: 19092 - hostname: kafka2 - port: 8098 + port: 19093 - hostname: kafka3 - port: 8099 + port: 19094 kafka_topics: pipeline: logserver_in: "pipeline-logserver_in" diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/cic/.gitkeep b/data/cic/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/cic/cic_dns_decode.py b/data/cic/cic_dns_decode.py deleted file mode 100644 index 80165efb..00000000 --- a/data/cic/cic_dns_decode.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - -import polars as pl - -if __name__ == "__main__": - files = [ - "CICBellDNS2021_CSV_benign.csv", - "CICBellDNS2021_CSV_malware.csv", - "CICBellDNS2021_CSV_phishing.csv", - "CICBellDNS2021_CSV_spam.csv", - ] - - domains = {} - for file in files: - with open(f"./{file}") as f: - domain_file = [] - for line in f: - txt = line.replace(" ", "") - x = re.split(",(?![^\[\]]*(?:\])|[^()]*\))", txt) - if "Domain" != x[4]: - domain_file.append(x[4][2:-2]) - domains[file] = domain_file - print(domain_file[:5]) - - for key in domains: - pl.DataFrame(domains[key]).write_csv(f"{key}_transformed") diff --git a/data/dgta/.gitkeep b/data/dgta/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/dgta/dgta_decode.py b/data/dgta/dgta_decode.py deleted file mode 100644 index c57b184a..00000000 --- a/data/dgta/dgta_decode.py +++ /dev/null @@ -1,17 +0,0 @@ -import pandas as pd -import polars as pl - - -def custom_decode(data): - retL = [None] * len(data) - for i, datum in enumerate(data): - retL[i] = str(datum.decode("latin-1").encode("utf-8").decode("utf-8")) - - return pl.Series(retL) - - -if __name__ == "__main__": - df_dgta = pl.read_parquet("./dgta-benchmark.parquet") - df_dgta = df_dgta.rename({"domain": "query"}) - df_dgta = df_dgta.with_columns([pl.col("query").map(custom_decode)]) - df_dgta.write_csv("dgta.csv") diff --git a/docker/benchmark_tests/Dockerfile.run_test b/docker/benchmark_tests/Dockerfile.run_test deleted file mode 100644 index 66714cd8..00000000 --- a/docker/benchmark_tests/Dockerfile.run_test +++ /dev/null @@ -1,17 +0,0 @@ -FROM python:3.11-slim-bookworm - -ENV PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /usr/src/app - -RUN pip --disable-pip-version-check install --no-cache-dir --no-compile marshmallow_dataclass colorlog pyYAML confluent_kafka numpy polars scikit-learn torch - -COPY src/base ./src/base -COPY src/train ./src/train -COPY config.yaml . -COPY docker/benchmark_tests . -COPY data/dgta ./data/dgta - -RUN rm -rf /root/.cache - -CMD [ "python", "run_test.py" ] diff --git a/docker/benchmark_tests/docker-compose.run_test.yml b/docker/benchmark_tests/docker-compose.run_test.yml deleted file mode 100644 index 78231964..00000000 --- a/docker/benchmark_tests/docker-compose.run_test.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - benchmark_test_run: - build: - context: ../.. - dockerfile: docker/benchmark_tests/Dockerfile.run_test - network: host - networks: - docker_heidgaf: - deploy: - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - -networks: - docker_heidgaf: - external: true diff --git a/docker/docker-compose-swarm/docker-compose.swarm-pipeline.yml b/docker/docker-compose-swarm/docker-compose.swarm-pipeline.yml index 8e0afff0..5b32fcac 100644 --- a/docker/docker-compose-swarm/docker-compose.swarm-pipeline.yml +++ b/docker/docker-compose-swarm/docker-compose.swarm-pipeline.yml @@ -15,6 +15,7 @@ services: constraints: [ node.hostname == heidgaf-2 ] volumes: - ../default.txt:/opt/file.txt + - ../../config.yaml:/app/config.yaml environment: - GROUP_ID=log_storage @@ -32,6 +33,8 @@ services: # memory: 256m placement: constraints: [ node.hostname == heidgaf-2 ] + volumes: + - ../../config.yaml:/app/config.yaml environment: - GROUP_ID=log_collection @@ -51,6 +54,8 @@ services: # memory: 256m placement: constraints: [ node.hostname == heidgaf-2 ] + volumes: + - ../../config.yaml:/app/config.yaml environment: - GROUP_ID=log_filtering @@ -70,6 +75,8 @@ services: # memory: 256m placement: constraints: [ node.hostname == heidgaf-2 ] + volumes: + - ../../config.yaml:/app/config.yaml environment: - GROUP_ID=data_inspection - NUMBER_OF_INSTANCES=1 @@ -94,6 +101,8 @@ services: # value: 1 placement: constraints: [ node.hostname == heidgaf-2 ] + volumes: + - ../../config.yaml:/app/config.yaml environment: - GROUP_ID=data_analysis diff --git a/docker/docker-compose-swarm/docker-compose.swarm-run_test.yml b/docker/docker-compose-swarm/docker-compose.swarm-run_test.yml deleted file mode 100644 index d37a4794..00000000 --- a/docker/docker-compose-swarm/docker-compose.swarm-run_test.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - benchmark_test_run: - image: localhost:5000/benchmark_test_run - environment: - - TEST_TYPE_NR=3 - networks: - heidgaf: - deploy: - placement: - constraints: [ node.hostname == heidgaf-1 ] - restart_policy: - condition: none - -networks: - heidgaf: - external: true diff --git a/docker/docker-compose.query.yml b/docker/docker-compose.query.yml deleted file mode 100644 index 56febc75..00000000 --- a/docker/docker-compose.query.yml +++ /dev/null @@ -1,21 +0,0 @@ -services: - query: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.dev-query - network: host - networks: - docker_heidgaf: - memswap_limit: 768m - deploy: - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - -networks: - docker_heidgaf: - external: true diff --git a/docker/docker-compose.send-mock-logs.yml b/docker/docker-compose.send-mock-logs.yml deleted file mode 100644 index 13d3cdd8..00000000 --- a/docker/docker-compose.send-mock-logs.yml +++ /dev/null @@ -1,21 +0,0 @@ -services: - send-mock-logs: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.dev-mock-logs - network: host - networks: - docker_heidgaf: - memswap_limit: 768m - deploy: - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - -networks: - docker_heidgaf: - external: true diff --git a/docker/docker-compose.send-real-logs.yml b/docker/docker-compose.send-real-logs.yml deleted file mode 100644 index 07999ff3..00000000 --- a/docker/docker-compose.send-real-logs.yml +++ /dev/null @@ -1,13 +0,0 @@ -services: - send-real-logs: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.dev-real-logs - network: host - networks: - docker_heidgaf: - - -networks: - docker_heidgaf: - external: true diff --git a/docker/docker-compose/base/docker-compose.kafka.yml b/docker/docker-compose/base/docker-compose.kafka.yml index 1541f130..0cdcb9e6 100644 --- a/docker/docker-compose/base/docker-compose.kafka.yml +++ b/docker/docker-compose/base/docker-compose.kafka.yml @@ -36,7 +36,7 @@ services: KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL:PLAINTEXT,INTERNAL:PLAINTEXT,DOCKER:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://kafka1:8097,DOCKER://host.docker.internal:29092 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${HOST_IP}:8097,DOCKER://host.docker.internal:29092 KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer @@ -64,7 +64,7 @@ services: KAFKA_BROKER_ID: 2 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL:PLAINTEXT,INTERNAL:PLAINTEXT,DOCKER:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:19092,EXTERNAL://kafka2:8098,DOCKER://host.docker.internal:29093 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:19092,EXTERNAL://${HOST_IP}:8098,DOCKER://host.docker.internal:29093 KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer @@ -91,7 +91,7 @@ services: KAFKA_BROKER_ID: 3 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: EXTERNAL:PLAINTEXT,INTERNAL:PLAINTEXT,DOCKER:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:19092,EXTERNAL://kafka3:8099,DOCKER://host.docker.internal:29094 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:19092,EXTERNAL://${HOST_IP}:8099,DOCKER://host.docker.internal:29094 KAFKA_LOG4J_LOGGERS: "kafka.controller=INFO,kafka.producer.async.DefaultEventHandler=INFO,state.change.logger=INFO" KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTHORIZER_CLASS_NAME: kafka.security.authorizer.AclAuthorizer diff --git a/docker/dockerfiles/Dockerfile.dev-mock-logs b/docker/dockerfiles/Dockerfile.dev-mock-logs deleted file mode 100644 index 689f5ee7..00000000 --- a/docker/dockerfiles/Dockerfile.dev-mock-logs +++ /dev/null @@ -1,16 +0,0 @@ -FROM python:3.11-slim-bookworm - -ENV PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /usr/src/app - -RUN pip --disable-pip-version-check install --no-cache-dir --no-compile marshmallow_dataclass colorlog pyYAML confluent_kafka - -COPY src/base ./src/base -COPY src/mock ./src/mock -COPY config.yaml . -COPY docker/mock_logs.dev.py . - -RUN rm -rf /root/.cache - -CMD [ "python", "mock_logs.dev.py"] diff --git a/docker/dockerfiles/Dockerfile.dev-query b/docker/dockerfiles/Dockerfile.dev-query deleted file mode 100644 index a4792c4b..00000000 --- a/docker/dockerfiles/Dockerfile.dev-query +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-bookworm - -ENV PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /usr/src/app - -RUN pip --disable-pip-version-check install --no-cache-dir --no-compile clickhouse_connect marshmallow_dataclass colorlog pyYAML confluent_kafka - -COPY src/base ./src/base -COPY config.yaml . -COPY docker/query.dev.py . - -RUN rm -rf /root/.cache - -CMD [ "python", "query.dev.py"] diff --git a/docker/dockerfiles/Dockerfile.dev-real-logs b/docker/dockerfiles/Dockerfile.dev-real-logs deleted file mode 100644 index c6acdca1..00000000 --- a/docker/dockerfiles/Dockerfile.dev-real-logs +++ /dev/null @@ -1,18 +0,0 @@ -FROM python:3.11-slim-bookworm - -ENV PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /usr/src/app - -RUN pip --disable-pip-version-check install --no-cache-dir --no-compile marshmallow_dataclass colorlog pyYAML confluent_kafka numpy polars scikit-learn torch - -COPY src/base ./src/base -COPY src/mock ./src/mock -COPY src/train ./src/train -COPY config.yaml . -COPY docker/real_logs.dev.py . -COPY data/dgta/dgta-benchmark.parquet ./data/dgta/dgta-benchmark.parquet - -RUN rm -rf /root/.cache - -CMD [ "python", "real_logs.dev.py"] diff --git a/generate-env.sh b/generate-env.sh new file mode 100644 index 00000000..c4bc47e7 --- /dev/null +++ b/generate-env.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# generate-env.sh + +HOST_IP=$(ip route get 1 | awk '{print $(NF-2); exit}') +echo "HOST_IP=$HOST_IP" > .env \ No newline at end of file diff --git a/docker/dev-get-csv-data.py b/scripts/dev-get-csv-data.py similarity index 100% rename from docker/dev-get-csv-data.py rename to scripts/dev-get-csv-data.py diff --git a/docker/mock_logs.dev.py b/scripts/mock_logs.dev.py similarity index 100% rename from docker/mock_logs.dev.py rename to scripts/mock_logs.dev.py diff --git a/docker/query.dev.py b/scripts/query.dev.py similarity index 100% rename from docker/query.dev.py rename to scripts/query.dev.py diff --git a/docker/real_logs.dev.py b/scripts/real_logs.dev.py similarity index 100% rename from docker/real_logs.dev.py rename to scripts/real_logs.dev.py diff --git a/docker/benchmark_tests/run_test.py b/scripts/run_test.py similarity index 100% rename from docker/benchmark_tests/run_test.py rename to scripts/run_test.py From 98482acb7b30c5249d782aa0f4ae48d3c9ca6599 Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Fri, 26 Sep 2025 13:20:27 +0200 Subject: [PATCH 02/62] Fix linting --- generate-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 100644 => 100755 generate-env.sh diff --git a/generate-env.sh b/generate-env.sh old mode 100644 new mode 100755 index c4bc47e7..d6e2719d --- a/generate-env.sh +++ b/generate-env.sh @@ -2,4 +2,4 @@ # generate-env.sh HOST_IP=$(ip route get 1 | awk '{print $(NF-2); exit}') -echo "HOST_IP=$HOST_IP" > .env \ No newline at end of file +echo "HOST_IP=$HOST_IP" > .env From c7738bd370889f686316efe24ac8b6bdb0d6fa20 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Sep 2025 08:43:55 +0200 Subject: [PATCH 03/62] Remove fill_levels insertion in collector.py --- src/logcollector/collector.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/logcollector/collector.py b/src/logcollector/collector.py index c331c09e..d7004990 100644 --- a/src/logcollector/collector.py +++ b/src/logcollector/collector.py @@ -51,16 +51,6 @@ def __init__(self) -> None: self.failed_dns_loglines = ClickHouseKafkaSender("failed_dns_loglines") self.dns_loglines = ClickHouseKafkaSender("dns_loglines") self.logline_timestamps = ClickHouseKafkaSender("logline_timestamps") - self.fill_levels = ClickHouseKafkaSender("fill_levels") - - self.fill_levels.insert( - dict( - timestamp=datetime.datetime.now(), - stage=module_name, - entry_type="total_loglines", - entry_count=0, - ) - ) async def start(self) -> None: """Starts fetching messages from Kafka and sending them to the :class:`Prefilter`.""" From b2c587b72e89d61678da9d36ed10065102b24a6d Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Sep 2025 08:58:42 +0200 Subject: [PATCH 04/62] Remove LogCollector fill level panel from dashboard and edit remaining ones (Log Volumes) --- .../dashboards/log_volumes.json | 780 +++++------------- 1 file changed, 189 insertions(+), 591 deletions(-) diff --git a/docker/grafana-provisioning/dashboards/log_volumes.json b/docker/grafana-provisioning/dashboards/log_volumes.json index fbaa7300..77a9f045 100644 --- a/docker/grafana-provisioning/dashboards/log_volumes.json +++ b/docker/grafana-provisioning/dashboards/log_volumes.json @@ -384,7 +384,7 @@ "uid": "grafana" }, "gridPos": { - "h": 7, + "h": 10, "w": 5, "x": 19, "y": 0 @@ -408,105 +408,6 @@ "transparent": true, "type": "dashlist" }, - { - "datasource": { - "default": false, - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "mode": "fixed" - }, - "mappings": [], - "noValue": "-", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 5, - "x": 19, - "y": 7 - }, - "id": 20, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "vertical", - "percentChangeColorMode": "standard", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.2.2+security-01", - "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.6.0", - "queryType": "table", - "rawSql": "SELECT 'Suspicious' AS state, MAX(message_count) AS maximal_size\nFROM suspicious_batch_timestamps\nWHERE stage = 'data_inspection.inspector' AND\n status = 'finished' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n\nUNION ALL\n\nSELECT 'Filtered' AS state, MAX(message_count) AS maximal_size\nFROM batch_timestamps\nWHERE stage = 'log_filtering.prefilter' AND\n status = 'finished' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n\nUNION ALL\n\nSELECT 'Initial' AS state, MAX(message_count) AS maximal_size\nFROM batch_timestamps\nWHERE stage = 'log_collection.batch_handler' AND\n status = 'completed' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime", - "refId": "A" - } - ], - "title": "Maximum Batch fill levels", - "transformations": [ - { - "id": "rowsToFields", - "options": {} - }, - { - "id": "organize", - "options": { - "excludeByName": {}, - "includeByName": {}, - "indexByName": { - "Filtered": 1, - "Initial": 0, - "Suspicious": 2 - }, - "renameByName": { - "Initial": "" - } - } - } - ], - "type": "stat" - }, { "collapsed": false, "gridPos": { @@ -522,7 +423,7 @@ }, { "datasource": { - "default": false, + "default": true, "type": "grafana-clickhouse-datasource", "uid": "PDEE91DDB90597936" }, @@ -620,29 +521,6 @@ } }, "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "hide": false, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.collector'\n AND 'Collector' IN (${include_modules:csv})\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", - "refId": "Collector" - }, { "datasource": { "type": "grafana-clickhouse-datasource", @@ -833,9 +711,9 @@ "table": "" } }, - "pluginVersion": "4.6.0", + "pluginVersion": "4.10.2", "queryType": "table", - "rawSql": "SELECT *\nFROM (\n SELECT 'Collector' AS name, median(value) AS \"Median\", 2 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.collector' AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'BatchHandler' AS name, median(value) AS \"Median\", 3 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler' AND entry_type = 'total_loglines_in_batches' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Prefilter' AS name, median(value) AS \"Median\", 4 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Inspector' AS name, median(value) AS \"Median\", 5 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Detector' AS name, median(value) AS \"Median\", 6 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_analysis.detector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n)\nWHERE name IN (${include_modules:csv})\nORDER BY sort_order ASC;", + "rawSql": "SELECT *\nFROM (\n SELECT 'BatchHandler' AS name, median(value) AS \"Median\", 3 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler' AND entry_type = 'total_loglines_in_batches' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Prefilter' AS name, median(value) AS \"Median\", 4 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Inspector' AS name, median(value) AS \"Median\", 5 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Detector' AS name, median(value) AS \"Median\", 6 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_analysis.detector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n)\nWHERE name IN (${include_modules:csv})\nORDER BY sort_order ASC;", "refId": "Fill States" } ], @@ -896,8 +774,8 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 11, - "gradientMode": "none", + "fillOpacity": 27, + "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, @@ -905,6 +783,9 @@ }, "insertNulls": false, "lineInterpolation": "stepAfter", + "lineStyle": { + "fill": "solid" + }, "lineWidth": 1, "pointSize": 5, "scaleDistribution": { @@ -920,7 +801,6 @@ "mode": "off" } }, - "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", @@ -928,6 +808,10 @@ { "color": "green", "value": null + }, + { + "color": "red", + "value": 80 } ] } @@ -940,13 +824,13 @@ "x": 0, "y": 21 }, - "id": 50, + "id": 51, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", - "showLegend": false + "showLegend": true }, "tooltip": { "mode": "single", @@ -974,11 +858,34 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'log_collection.collector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", - "refId": "A" + "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", + "refId": "Batch" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "PDEE91DDB90597936" + }, + "editorType": "sql", + "format": 1, + "hide": false, + "meta": { + "builderOptions": { + "columns": [], + "database": "", + "limit": 1000, + "mode": "list", + "queryType": "table", + "table": "" + } + }, + "pluginVersion": "4.5.1", + "queryType": "table", + "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", + "refId": "Buffer" } ], - "title": "Collector Fill State", + "title": "BatchHandler Fill State", "type": "timeseries" }, { @@ -1011,16 +918,16 @@ }, "gridPos": { "h": 3, - "w": 5, + "w": 3, "x": 6, "y": 21 }, - "id": 60, + "id": 62, "options": { "colorMode": "value", "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", + "justifyMode": "center", + "orientation": "horizontal", "percentChangeColorMode": "inverted", "reduceOptions": { "calcs": [ @@ -1030,8 +937,8 @@ "values": false }, "showPercentChange": false, - "textMode": "auto", - "wideLayout": true + "textMode": "value_and_name", + "wideLayout": false }, "pluginVersion": "11.2.2+security-01", "targets": [ @@ -1054,11 +961,91 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_collection.collector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "A" + "rawSql": "SELECT median(entry_count) AS \"Batch Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "refId": "Batch" + } + ], + "transparent": true, + "type": "stat" + }, + { + "datasource": { + "default": true, + "type": "grafana-clickhouse-datasource", + "uid": "PDEE91DDB90597936" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "shades" + }, + "fieldMinMax": false, + "mappings": [], + "noValue": "-", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 3, + "w": 2, + "x": 9, + "y": 21 + }, + "id": 68, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "percentChangeColorMode": "inverted", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "value_and_name", + "wideLayout": false + }, + "pluginVersion": "11.2.2+security-01", + "targets": [ + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "PDEE91DDB90597936" + }, + "editorType": "sql", + "format": 1, + "hide": false, + "meta": { + "builderOptions": { + "columns": [], + "database": "", + "limit": 1000, + "mode": "list", + "queryType": "table", + "table": "" + } + }, + "pluginVersion": "4.5.1", + "queryType": "table", + "rawSql": "SELECT median(entry_count) AS \"Buffer Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "refId": "Buffer" } ], - "title": "Median", "transparent": true, "type": "stat" }, @@ -1132,7 +1119,7 @@ "x": 12, "y": 21 }, - "id": 52, + "id": 53, "options": { "legend": { "calcs": [], @@ -1166,11 +1153,11 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", + "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", "refId": "Batch" } ], - "title": "Prefilter Fill State", + "title": "Inspector Fill State", "type": "timeseries" }, { @@ -1207,7 +1194,7 @@ "x": 18, "y": 21 }, - "id": 64, + "id": 66, "options": { "colorMode": "value", "graphMode": "none", @@ -1246,7 +1233,7 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", "refId": "A" } ], @@ -1302,7 +1289,7 @@ "x": 6, "y": 24 }, - "id": 61, + "id": 63, "options": { "colorMode": "value", "graphMode": "none", @@ -1341,8 +1328,31 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_collection.collector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "A" + "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "refId": "Batch" + }, + { + "datasource": { + "type": "grafana-clickhouse-datasource", + "uid": "PDEE91DDB90597936" + }, + "editorType": "sql", + "format": 1, + "hide": false, + "meta": { + "builderOptions": { + "columns": [], + "database": "", + "limit": 1000, + "mode": "list", + "queryType": "table", + "table": "" + } + }, + "pluginVersion": "4.5.1", + "queryType": "table", + "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "refId": "Buffer" } ], "transparent": true, @@ -1396,7 +1406,7 @@ "x": 18, "y": 24 }, - "id": 67, + "id": 65, "options": { "colorMode": "value", "graphMode": "none", @@ -1435,7 +1445,7 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", "refId": "A" } ], @@ -1462,7 +1472,7 @@ "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", - "fillOpacity": 27, + "fillOpacity": 5, "gradientMode": "opacity", "hideFrom": { "legend": false, @@ -1479,7 +1489,7 @@ "scaleDistribution": { "type": "linear" }, - "showPoints": "auto", + "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", @@ -1512,13 +1522,13 @@ "x": 0, "y": 27 }, - "id": 51, + "id": 52, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", - "showLegend": true + "showLegend": false }, "tooltip": { "mode": "single", @@ -1546,34 +1556,11 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", + "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", "refId": "Batch" - }, - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "hide": false, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", - "refId": "Buffer" } ], - "title": "BatchHandler Fill State", + "title": "Prefilter Fill State", "type": "timeseries" }, { @@ -1606,96 +1593,16 @@ }, "gridPos": { "h": 3, - "w": 3, + "w": 5, "x": 6, "y": 27 }, - "id": 62, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "percentChangeColorMode": "inverted", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": false - }, - "pluginVersion": "11.2.2+security-01", - "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Batch Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "Batch" - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "default": true, - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "yellow", - "mode": "shades" - }, - "fieldMinMax": false, - "mappings": [], - "noValue": "-", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 2, - "x": 9, - "y": 27 - }, - "id": 68, + "id": 64, "options": { "colorMode": "value", "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", + "justifyMode": "auto", + "orientation": "auto", "percentChangeColorMode": "inverted", "reduceOptions": { "calcs": [ @@ -1705,8 +1612,8 @@ "values": false }, "showPercentChange": false, - "textMode": "value_and_name", - "wideLayout": false + "textMode": "auto", + "wideLayout": true }, "pluginVersion": "11.2.2+security-01", "targets": [ @@ -1717,7 +1624,6 @@ }, "editorType": "sql", "format": 1, - "hide": false, "meta": { "builderOptions": { "columns": [], @@ -1730,10 +1636,11 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Buffer Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "Buffer" + "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "refId": "A" } ], + "title": "Median", "transparent": true, "type": "stat" }, @@ -1807,7 +1714,7 @@ "x": 12, "y": 27 }, - "id": 53, + "id": 54, "options": { "legend": { "calcs": [], @@ -1841,11 +1748,11 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", + "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'data_analysis.detector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", "refId": "Batch" } ], - "title": "Inspector Fill State", + "title": "Detector Fill State", "type": "timeseries" }, { @@ -1882,7 +1789,7 @@ "x": 18, "y": 27 }, - "id": 66, + "id": 70, "options": { "colorMode": "value", "graphMode": "none", @@ -1921,7 +1828,7 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'data_analysis.detector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", "refId": "A" } ], @@ -1977,315 +1884,7 @@ "x": 6, "y": 30 }, - "id": 63, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "inverted", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.2.2+security-01", - "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_batches'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "Batch" - }, - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "hide": false, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler'\n AND entry_type = 'total_loglines_in_buffer'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "Buffer" - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "default": true, - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "shades" - }, - "fieldMinMax": false, - "mappings": [], - "noValue": "-", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "Maximum" - }, - "properties": [ - { - "id": "color", - "value": { - "fixedColor": "green", - "mode": "shades" - } - } - ] - } - ] - }, - "gridPos": { - "h": 3, - "w": 5, - "x": 18, - "y": 30 - }, - "id": 65, - "options": { - "colorMode": "value", - "graphMode": "none", - "justifyMode": "auto", - "orientation": "auto", - "percentChangeColorMode": "inverted", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "showPercentChange": false, - "textMode": "auto", - "wideLayout": true - }, - "pluginVersion": "11.2.2+security-01", - "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", - "refId": "A" - } - ], - "transparent": true, - "type": "stat" - }, - { - "datasource": { - "default": true, - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": true, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "barWidthFactor": 0.6, - "drawStyle": "line", - "fillOpacity": 5, - "gradientMode": "opacity", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineStyle": { - "fill": "solid" - }, - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 6, - "x": 12, - "y": 33 - }, - "id": 54, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": false - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "hide": false, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT *\nFROM fill_levels\nWHERE stage = 'data_analysis.detector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", - "refId": "Batch" - } - ], - "title": "Detector Fill State", - "type": "timeseries" - }, - { - "datasource": { - "default": true, - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "description": "", - "fieldConfig": { - "defaults": { - "color": { - "fixedColor": "blue", - "mode": "shades" - }, - "fieldMinMax": false, - "mappings": [], - "noValue": "-", - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 3, - "w": 5, - "x": 18, - "y": 33 - }, - "id": 70, + "id": 67, "options": { "colorMode": "value", "graphMode": "none", @@ -2324,11 +1923,10 @@ }, "pluginVersion": "4.5.1", "queryType": "table", - "rawSql": "SELECT median(entry_count) AS \"Median\"\nFROM (\n SELECT *\n FROM fill_levels\n WHERE stage = 'data_analysis.detector'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", + "rawSql": "SELECT min(value) AS \"Minimum\", avg(value) AS \"Average\", max(value) AS \"Maximum\"\nFROM (\n SELECT entry_count AS value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter'\n AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n)", "refId": "A" } ], - "title": "Median", "transparent": true, "type": "stat" }, @@ -2378,7 +1976,7 @@ "h": 3, "w": 5, "x": 18, - "y": 36 + "y": 30 }, "id": 69, "options": { @@ -2432,7 +2030,7 @@ "h": 1, "w": 24, "x": 0, - "y": 39 + "y": 33 }, "id": 76, "panels": [], @@ -2449,7 +2047,7 @@ "h": 7, "w": 24, "x": 0, - "y": 40 + "y": 34 }, "id": 75, "options": { @@ -2579,7 +2177,7 @@ }, { "current": { - "selected": true, + "selected": false, "text": [ "All" ], From 42c2f85589c1614a65804d7e08191a6e295d7bed Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Sep 2025 09:00:26 +0200 Subject: [PATCH 05/62] Edit LogCollector fill level panels from dashboard (Overview) --- .../dashboards/overview.json | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/docker/grafana-provisioning/dashboards/overview.json b/docker/grafana-provisioning/dashboards/overview.json index 5ac938ae..4e17d4b1 100644 --- a/docker/grafana-provisioning/dashboards/overview.json +++ b/docker/grafana-provisioning/dashboards/overview.json @@ -233,7 +233,7 @@ }, { "datasource": { - "default": false, + "default": true, "type": "grafana-clickhouse-datasource", "uid": "PDEE91DDB90597936" }, @@ -331,29 +331,6 @@ } }, "targets": [ - { - "datasource": { - "type": "grafana-clickhouse-datasource", - "uid": "PDEE91DDB90597936" - }, - "editorType": "sql", - "format": 1, - "hide": false, - "meta": { - "builderOptions": { - "columns": [], - "database": "", - "limit": 1000, - "mode": "list", - "queryType": "table", - "table": "" - } - }, - "pluginVersion": "4.5.1", - "queryType": "table", - "rawSql": "SELECT timestamp, entry_count AS \" \"\nFROM fill_levels\nWHERE stage = 'log_collection.collector'\n AND 'Collector' IN (${include_modules:csv})\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\nORDER BY timestamp ASC;", - "refId": "Collector" - }, { "datasource": { "type": "grafana-clickhouse-datasource", @@ -544,9 +521,9 @@ "table": "" } }, - "pluginVersion": "4.7.0", + "pluginVersion": "4.10.2", "queryType": "table", - "rawSql": "SELECT *\nFROM (\n SELECT 'Collector' AS name, median(value) AS \"Median\", 2 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.collector' AND entry_type = 'total_loglines'\n AND timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'BatchHandler' AS name, median(value) AS \"Median\", 3 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler' AND entry_type = 'total_loglines_in_batches' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Prefilter' AS name, median(value) AS \"Median\", 4 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Inspector' AS name, median(value) AS \"Median\", 5 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Detector' AS name, median(value) AS \"Median\", 6 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_analysis.detector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n)\nWHERE name IN (${include_modules:csv})\nORDER BY sort_order ASC;", + "rawSql": "SELECT *\nFROM (\n SELECT 'BatchHandler' AS name, median(value) AS \"Median\", 3 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_collection.batch_handler' AND entry_type = 'total_loglines_in_batches' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Prefilter' AS name, median(value) AS \"Median\", 4 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'log_filtering.prefilter' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Inspector' AS name, median(value) AS \"Median\", 5 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_inspection.inspector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n\n UNION ALL\n\n SELECT 'Detector' AS name, median(value) AS \"Median\", 6 AS sort_order\n FROM (\n SELECT entry_count as value\n FROM fill_levels\n WHERE stage = 'data_analysis.detector' AND entry_type = 'total_loglines' AND\n timestamp >= $__fromTime AND timestamp <= $__toTime\n )\n)\nWHERE name IN (${include_modules:csv})\nORDER BY sort_order ASC;", "refId": "Fill States" } ], @@ -1016,6 +993,6 @@ "timezone": "browser", "title": "Overview", "uid": "eebjla27u66f4f", - "version": 5, + "version": 1, "weekStart": "" } From f7b079417073adad7c079df40851746bc8f51ebc Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Sep 2025 09:04:13 +0200 Subject: [PATCH 06/62] Small updates in Log Volumes dashboard --- .../dashboards/log_volumes.json | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/docker/grafana-provisioning/dashboards/log_volumes.json b/docker/grafana-provisioning/dashboards/log_volumes.json index 77a9f045..18f1a967 100644 --- a/docker/grafana-provisioning/dashboards/log_volumes.json +++ b/docker/grafana-provisioning/dashboards/log_volumes.json @@ -115,7 +115,7 @@ ] }, "gridPos": { - "h": 10, + "h": 8, "w": 10, "x": 0, "y": 0 @@ -299,7 +299,7 @@ ] }, "gridPos": { - "h": 10, + "h": 8, "w": 9, "x": 10, "y": 0 @@ -384,7 +384,7 @@ "uid": "grafana" }, "gridPos": { - "h": 10, + "h": 8, "w": 5, "x": 19, "y": 0 @@ -414,7 +414,7 @@ "h": 1, "w": 24, "x": 0, - "y": 10 + "y": 8 }, "id": 72, "panels": [], @@ -505,7 +505,7 @@ "h": 9, "w": 15, "x": 0, - "y": 11 + "y": 9 }, "id": 55, "options": { @@ -670,7 +670,7 @@ "h": 9, "w": 9, "x": 15, - "y": 11 + "y": 9 }, "id": 47, "options": { @@ -747,7 +747,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 18 }, "id": 59, "panels": [], @@ -822,7 +822,7 @@ "h": 6, "w": 6, "x": 0, - "y": 21 + "y": 19 }, "id": 51, "options": { @@ -920,7 +920,7 @@ "h": 3, "w": 3, "x": 6, - "y": 21 + "y": 19 }, "id": 62, "options": { @@ -1000,7 +1000,7 @@ "h": 3, "w": 2, "x": 9, - "y": 21 + "y": 19 }, "id": 68, "options": { @@ -1117,7 +1117,7 @@ "h": 6, "w": 6, "x": 12, - "y": 21 + "y": 19 }, "id": 53, "options": { @@ -1192,7 +1192,7 @@ "h": 3, "w": 5, "x": 18, - "y": 21 + "y": 19 }, "id": 66, "options": { @@ -1287,7 +1287,7 @@ "h": 3, "w": 5, "x": 6, - "y": 24 + "y": 22 }, "id": 63, "options": { @@ -1404,7 +1404,7 @@ "h": 3, "w": 5, "x": 18, - "y": 24 + "y": 22 }, "id": 65, "options": { @@ -1520,7 +1520,7 @@ "h": 6, "w": 6, "x": 0, - "y": 27 + "y": 25 }, "id": 52, "options": { @@ -1595,7 +1595,7 @@ "h": 3, "w": 5, "x": 6, - "y": 27 + "y": 25 }, "id": 64, "options": { @@ -1712,7 +1712,7 @@ "h": 6, "w": 6, "x": 12, - "y": 27 + "y": 25 }, "id": 54, "options": { @@ -1787,7 +1787,7 @@ "h": 3, "w": 5, "x": 18, - "y": 27 + "y": 25 }, "id": 70, "options": { @@ -1882,7 +1882,7 @@ "h": 3, "w": 5, "x": 6, - "y": 30 + "y": 28 }, "id": 67, "options": { @@ -1976,7 +1976,7 @@ "h": 3, "w": 5, "x": 18, - "y": 30 + "y": 28 }, "id": 69, "options": { @@ -2030,7 +2030,7 @@ "h": 1, "w": 24, "x": 0, - "y": 33 + "y": 31 }, "id": 76, "panels": [], @@ -2047,7 +2047,7 @@ "h": 7, "w": 24, "x": 0, - "y": 34 + "y": 32 }, "id": 75, "options": { From 24086fde85cb9d7267fe2b1604e08dbccac44a8f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 29 Sep 2025 09:06:16 +0200 Subject: [PATCH 07/62] Small updates in Overview dashboard --- docker/grafana-provisioning/dashboards/overview.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docker/grafana-provisioning/dashboards/overview.json b/docker/grafana-provisioning/dashboards/overview.json index 4e17d4b1..a935300d 100644 --- a/docker/grafana-provisioning/dashboards/overview.json +++ b/docker/grafana-provisioning/dashboards/overview.json @@ -312,7 +312,7 @@ ] }, "gridPos": { - "h": 7, + "h": 8, "w": 15, "x": 0, "y": 8 @@ -477,7 +477,7 @@ ] }, "gridPos": { - "h": 7, + "h": 8, "w": 9, "x": 15, "y": 8 @@ -603,7 +603,7 @@ "h": 8, "w": 13, "x": 0, - "y": 15 + "y": 16 }, "id": 1, "options": { @@ -731,7 +731,7 @@ "h": 4, "w": 11, "x": 13, - "y": 15 + "y": 16 }, "id": 3, "options": { @@ -852,7 +852,7 @@ "h": 4, "w": 11, "x": 13, - "y": 19 + "y": 20 }, "id": 4, "options": { @@ -927,7 +927,7 @@ "list": [ { "current": { - "selected": true, + "selected": false, "text": [ "All" ], From 66e178c14dcc32d12f1125f6b39ef869b9bd7fd5 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Sep 2025 15:28:12 +0200 Subject: [PATCH 08/62] Update pipeline.rst for Stage 1: Log Storage --- docs/pipeline.rst | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 6d56c17f..27b71794 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -13,13 +13,14 @@ traverses through it using Apache Kafka. Stage 1: Log Storage ==================== -This stage serves as the central contact point for all data. +This stage serves as the central contact point for all data. Data is read and entered into the pipeline. Overview -------- -The :class:`LogServer` class is the core component of this stage. It reads from several input sources and sends the -data to Kafka, where it can be obtained by the following module. +The :class:`LogServer` class is the core component of this stage. It reads data from one or multiple data sources and +enters it into the pipeline by sending it to Kafka, where it can be obtained by the following module. For monitoring, +it logs all ingoing log lines including their timestamps of entering and leaving the module. Main Class ---------- @@ -30,8 +31,11 @@ Main Class Usage and configuration ----------------------- -Currently, the :class:`LogServer` reads from both an input file and a Kafka topic, simultaneously. The configuration -allows changing the file name to read from. +The :class:`LogServer` simultaneously listens on a Kafka topic and reads from an input file. The configuration +allows changing the Kafka topic to listen on, as well as the file name to read from. + +The Kafka topic to listen on can be changed through setting the ``environment.kafka_topics.pipeline.logserver_in`` +field in `config.yaml`. Changing the file name to read from differs depending on your environment: - **Without Docker**: From feb9e6dfb8b5e7698a61d3ebff42811c771b0962 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 30 Sep 2025 15:28:54 +0200 Subject: [PATCH 09/62] Update docstrings for server.py --- src/logserver/server.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/logserver/server.py b/src/logserver/server.py index 79a78a1b..4a462757 100644 --- a/src/logserver/server.py +++ b/src/logserver/server.py @@ -34,8 +34,9 @@ class LogServer: """ - Receives and sends single log lines. Listens for messages via Kafka and reads newly added lines from an input - file. + 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) -> None: @@ -47,9 +48,7 @@ def __init__(self) -> None: self.server_logs_timestamps = ClickHouseKafkaSender("server_logs_timestamps") async def start(self) -> None: - """ - Starts fetching messages from Kafka and from the input file. - """ + """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 '{CONSUME_TOPIC}'\n" @@ -74,11 +73,11 @@ async def start(self) -> None: def send(self, message_id: uuid.UUID, message: str) -> None: """ - Sends a received message using Kafka. + 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 - message (str): Message to be sent + message_id (uuid.UUID): UUID of the message to be sent. + message (str): Message to be sent. """ self.kafka_produce_handler.produce(topic=PRODUCE_TOPIC, data=message) logger.debug(f"Sent: '{message}'") @@ -93,7 +92,8 @@ def send(self, message_id: uuid.UUID, message: str) -> None: async def fetch_from_kafka(self) -> None: """ - Starts a loop to continuously listen on the configured Kafka topic. If a message is consumed, it is sent. + Starts a 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. """ loop = asyncio.get_running_loop() @@ -116,11 +116,14 @@ async def fetch_from_kafka(self) -> None: async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: """ - Continuously checks for new lines at the end of the input file. If one or multiple new lines are found, any - empty lines are removed and the remaining lines are sent individually. + Starts a loop to continuously (every 0.1 seconds) check for new lines at the end of the input file. + If one or multiple new lines are found, any empty lines are removed and the remaining lines are sent + individually. For each fetched log line, the unprocessed log line string including its + timestamp ("timestamp_in") is logged. Args: - file (str): Filename of the file to be read + file (str): Filename of the file to be read. + Default: File configured in `config.yaml` (``pipeline.log_storage.logserver.input_file``) """ async with aiofiles.open(file, mode="r") as file: await file.seek(0, 2) # jump to end of file @@ -153,9 +156,7 @@ async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: def main() -> None: - """ - Creates the :class:`LogServer` instance and starts it. - """ + """Creates the :class:`LogServer` instance and starts it.""" server_instance = LogServer() asyncio.run(server_instance.start()) From 9f5cf3f14043af1e3afdd7e5cc2f4f7cabccf102 Mon Sep 17 00:00:00 2001 From: maldwg Date: Tue, 30 Sep 2025 15:34:42 +0200 Subject: [PATCH 10/62] Add developer guide section to readthedocs for better structure --- docs/developer_guide.rst | 17 +++++++++++++++++ docs/index.rst | 1 + docs/usage.rst | 15 --------------- 3 files changed, 18 insertions(+), 15 deletions(-) create mode 100644 docs/developer_guide.rst diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst new file mode 100644 index 00000000..0739653a --- /dev/null +++ b/docs/developer_guide.rst @@ -0,0 +1,17 @@ +Developer Guide +=============== + +This section describes useful information for contributors of the project. + +Commit Hook +------------ + +Contributing to the project you might be noting failed pipeline runs. +This can be due to the pre.commit hook finding errors in the formatting. Therefore, we suggest you run + +.. code-block:: console + + (.venv) pre-commit run --show-diff-on-failure --color=always --all-files + +before committing your changes to GitHub. +This reformates the code accordingly, preventing errors in the pipeline. diff --git a/docs/index.rst b/docs/index.rst index 2e064478..ee8a2286 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,5 +24,6 @@ Contents pipeline monitoring training + developer_guide api/index references diff --git a/docs/usage.rst b/docs/usage.rst index 081d19f5..63a0fcaf 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -49,21 +49,6 @@ Now, you can start each module, e.g. the `Inspector`: (.venv) $ python src/inspector/main.py - -Commit Hook ------------- - -Contributing to the project you might be noting failed pipeline runs. -This can be due to the pre.commit hook finding errors in the formatting. Therefore, we suggest you run - -.. code-block:: console - - (.venv) pre-commit run --show-diff-on-failure --color=always --all-files - -before committing your changes to GitHub. -This reformates the code accordingly, preventing errors in the pipeline. - - Configuration ------------- From cbff8a610516c1372bd4763737eb459b33aef62c Mon Sep 17 00:00:00 2001 From: maldwg Date: Tue, 30 Sep 2025 15:36:26 +0200 Subject: [PATCH 11/62] Fix smal typo --- docs/developer_guide.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index 0739653a..bc7c95d2 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -7,7 +7,7 @@ Commit Hook ------------ Contributing to the project you might be noting failed pipeline runs. -This can be due to the pre.commit hook finding errors in the formatting. Therefore, we suggest you run +This can be due to the pre-commit hook finding errors in the formatting. Therefore, we suggest you run .. code-block:: console From 42b583413504f4019bee456911aa3e5b95619cf4 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 1 Oct 2025 14:39:03 +0200 Subject: [PATCH 12/62] Update docstrings for server.py --- src/logserver/server.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/logserver/server.py b/src/logserver/server.py index 4a462757..2481d75d 100644 --- a/src/logserver/server.py +++ b/src/logserver/server.py @@ -33,7 +33,8 @@ 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. @@ -72,8 +73,9 @@ async def start(self) -> None: 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. + """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. @@ -91,9 +93,10 @@ def send(self, message_id: uuid.UUID, message: str) -> None: ) async def fetch_from_kafka(self) -> None: - """ - Starts a 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. + """Starts a 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. """ loop = asyncio.get_running_loop() @@ -115,11 +118,11 @@ async def fetch_from_kafka(self) -> None: self.send(message_id, value) async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: - """ - Starts a loop to continuously (every 0.1 seconds) check for new lines at the end of the input file. - If one or multiple new lines are found, any empty lines are removed and the remaining lines are sent - individually. For each fetched log line, the unprocessed log line string including its - timestamp ("timestamp_in") is logged. + """Starts a loop to continuously check for new lines at the end of the input file and sends them. + + Checks are done every 0.1 seconds. If one or multiple new lines are found, any empty lines are removed + and the remaining lines are sent individually. For each fetched log line, the unprocessed log line string + including its timestamp ("timestamp_in") is logged. Args: file (str): Filename of the file to be read. From 7d0b48c9171f171668911d3318dcd0011546f6fd Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 1 Oct 2025 14:42:58 +0200 Subject: [PATCH 13/62] Update docstrings for server.py (2) --- src/logserver/server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/logserver/server.py b/src/logserver/server.py index 2481d75d..a10d4d22 100644 --- a/src/logserver/server.py +++ b/src/logserver/server.py @@ -93,8 +93,9 @@ def send(self, message_id: uuid.UUID, message: str) -> None: ) async def fetch_from_kafka(self) -> None: - """Starts a loop to continuously fetch new data from the Kafka topic. + """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. """ @@ -118,7 +119,7 @@ async def fetch_from_kafka(self) -> None: self.send(message_id, value) async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: - """Starts a loop to continuously check for new lines at the end of the input file and sends them. + """Starts a loop to continuously check for new lines at the end of the input file and sends them Checks are done every 0.1 seconds. If one or multiple new lines are found, any empty lines are removed and the remaining lines are sent individually. For each fetched log line, the unprocessed log line string From 1c69201119050892ac42212029c94afaacf4ad7c Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 1 Oct 2025 14:45:10 +0200 Subject: [PATCH 14/62] Update docstrings for server.py (3) --- src/logserver/server.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/logserver/server.py b/src/logserver/server.py index a10d4d22..6a1156bd 100644 --- a/src/logserver/server.py +++ b/src/logserver/server.py @@ -49,7 +49,7 @@ def __init__(self) -> None: self.server_logs_timestamps = ClickHouseKafkaSender("server_logs_timestamps") async def start(self) -> None: - """Starts the tasks to both fetch messages from Kafka and read them from the input file.""" + """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 '{CONSUME_TOPIC}'\n" @@ -73,7 +73,7 @@ async def start(self) -> None: logger.info("LogServer stopped.") def send(self, message_id: uuid.UUID, message: str) -> None: - """Sends a message using Kafka. + """Sends a message using Kafka Logs the time of sending the message to Kafka as a "timestamp_out" event. @@ -160,7 +160,7 @@ async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: def main() -> None: - """Creates the :class:`LogServer` instance and starts it.""" + """Creates the :class:`LogServer` instance and starts it""" server_instance = LogServer() asyncio.run(server_instance.start()) From 720b36f3f00b97f870d1affbd3ffff8f59442ea1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 1 Oct 2025 14:48:05 +0200 Subject: [PATCH 15/62] Update docstrings for server.py (4) --- src/logserver/server.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/logserver/server.py b/src/logserver/server.py index 6a1156bd..ec43da88 100644 --- a/src/logserver/server.py +++ b/src/logserver/server.py @@ -49,7 +49,7 @@ def __init__(self) -> None: self.server_logs_timestamps = ClickHouseKafkaSender("server_logs_timestamps") async def start(self) -> None: - """Starts the tasks to both fetch messages from Kafka and read them from the input file""" + """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 '{CONSUME_TOPIC}'\n" @@ -73,7 +73,7 @@ async def start(self) -> None: logger.info("LogServer stopped.") def send(self, message_id: uuid.UUID, message: str) -> None: - """Sends a message using Kafka + """Sends a message using Kafka. Logs the time of sending the message to Kafka as a "timestamp_out" event. @@ -93,7 +93,7 @@ def send(self, message_id: uuid.UUID, message: str) -> None: ) async def fetch_from_kafka(self) -> None: - """Fetches data from the configured Kafka topic in a loop + """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 @@ -119,7 +119,7 @@ async def fetch_from_kafka(self) -> None: self.send(message_id, value) async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: - """Starts a loop to continuously check for new lines at the end of the input file and sends them + """Starts a loop to continuously check for new lines at the end of the input file and sends them. Checks are done every 0.1 seconds. If one or multiple new lines are found, any empty lines are removed and the remaining lines are sent individually. For each fetched log line, the unprocessed log line string @@ -160,7 +160,7 @@ async def fetch_from_file(self, file: str = READ_FROM_FILE) -> None: def main() -> None: - """Creates the :class:`LogServer` instance and starts it""" + """Creates the :class:`LogServer` instance and starts it.""" server_instance = LogServer() asyncio.run(server_instance.start()) From 147cf95dd95fd0fbc5c12aa0893cd65b3cdfb178 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 1 Oct 2025 15:09:15 +0200 Subject: [PATCH 16/62] Update docstrings for collector.py --- src/logcollector/collector.py | 47 +++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 16 deletions(-) diff --git a/src/logcollector/collector.py b/src/logcollector/collector.py index d7004990..d6cdf1af 100644 --- a/src/logcollector/collector.py +++ b/src/logcollector/collector.py @@ -37,8 +37,10 @@ class LogCollector: - """Consumes incoming log lines from the :class:`LogServer`. Validates all data fields by type and - value, invalid loglines are discarded. All valid loglines are sent to the batch sender. + """Main component of the Log Collection stage to pre-process and format data + + Consumes incoming loglines from the LogServer. Validates all data fields by type and + value, invalid loglines are discarded. All valid loglines are sent to the BatchSender. """ def __init__(self) -> None: @@ -53,7 +55,7 @@ def __init__(self) -> None: self.logline_timestamps = ClickHouseKafkaSender("logline_timestamps") async def start(self) -> None: - """Starts fetching messages from Kafka and sending them to the :class:`Prefilter`.""" + """Starts the task to fetch data from Kafka.""" logger.info( "LogCollector started:\n" f" ⤷ receiving on Kafka topic '{CONSUME_TOPIC}'" @@ -71,8 +73,11 @@ async def start(self) -> None: logger.info("LogCollector stopped.") async def fetch(self) -> None: - """Starts a loop to continuously listen on the configured Kafka topic. If a message is consumed, it is - decoded and sent.""" + """Fetches data from the configured Kafka topic in a loop. + + Starts an asynchronous loop to continuously listen on the configured Kafka topic and fetch new messages. + If a message is consumed, it is decoded and sent. + """ loop = asyncio.get_running_loop() while True: @@ -84,12 +89,18 @@ async def fetch(self) -> None: self.send(datetime.datetime.now(), value) def send(self, timestamp_in: datetime.datetime, message: str) -> None: - """Sends the logline in JSON format to the BatchSender, where it is stored in - a temporary batch before being sent to the :class:`Prefilter`. Adds the subnet ID to the message. + """Adds a message to the BatchSender to be stored temporarily. + + The message is added in JSON format to the BatchSender, where it is stored in + a temporary batch before being sent to the Prefilter. The subnet ID is added to the message. + In the case that a message does not have a valid logline format, it is logged as a failed logline + including timestamps of entering and being detected as invalid. In the case of a valid message, the logline's + fields as well as an "in_process" event are logged using the timestamp of it entering the module. After + processing, a "finished" event is logged for it. Args: - timestamp_in (datetime.datetime): Timestamp of entering the pipeline - message (str): Message to be stored + timestamp_in (datetime.datetime): Timestamp of entering the pipeline. + message (str): Message to be stored. """ try: @@ -154,14 +165,20 @@ def send(self, timestamp_in: datetime.datetime, message: str) -> None: @staticmethod def _get_subnet_id(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> str: - """ - Returns the subnet ID of an IP address. + """Returns the subnet ID of an IP address. + + The subnet ID is formatted as `[NORMALIZED_IP_ADDRESS]_[PREFIX_LENGTH]`. + Depending on the IP address, the configuration value + ``pipeline.log_collection.batch_handler.subnet_id.[ipv4_prefix_length | ipv6_prefix_length]`` + is used as `PREFIX_LENGTH`. + + For example, the IPv4 address `192.168.1.1` with prefix length `24` is formatted to ``192.168.1.0_24``. Args: - address (ipaddress.IPv4Address | ipaddress.IPv6Address): IP address to get the subnet ID for + address (ipaddress.IPv4Address | ipaddress.IPv6Address): IP address to get the subnet ID for. Returns: - subnet ID for the given IP address as string + Subnet ID for the given IP address as string. """ if isinstance(address, ipaddress.IPv4Address): normalized_ip_address, prefix_length = utils.normalize_ipv4_address( @@ -178,9 +195,7 @@ def _get_subnet_id(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> st def main() -> None: - """ - Creates the :class:`LogCollector` instance and starts it. - """ + """Creates the :class:`LogCollector` instance and starts it.""" collector_instance = LogCollector() asyncio.run(collector_instance.start()) From 1bf7fe05226b5628a3aaf63aae10534617f2d123 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Thu, 2 Oct 2025 13:45:01 +0200 Subject: [PATCH 17/62] Update docstrings for batch_handler.py --- src/logcollector/batch_handler.py | 169 +++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 36 deletions(-) diff --git a/src/logcollector/batch_handler.py b/src/logcollector/batch_handler.py index f06a544a..542fef20 100644 --- a/src/logcollector/batch_handler.py +++ b/src/logcollector/batch_handler.py @@ -32,8 +32,12 @@ class BufferedBatch: - """Data structure for managing the batch, buffer, and timestamps. The batch contains the latest messages and a - buffer that stores the previous batch messages. Sorts the batches and can return timestamps. + """Data structure for managing batches, buffers, and timestamps in the log collection pipeline + + Manages two data structures: a current batch that collects incoming messages and a buffer that stores + previously processed batch messages. The batch groups messages by key (typically subnet ID) and handles + automatic sending when size or timeout limits are reached. All batches are sorted by timestamp to ensure + chronological processing. Tracks batch metadata including IDs, timestamps, and fill levels for monitoring. """ def __init__(self): @@ -65,12 +69,16 @@ def __init__(self): ) def add_message(self, key: str, logline_id: uuid.UUID, message: str) -> None: - """Adds message to the key. If the key does not exist yet, it is created first. + """Adds a message to the batch associated with the given key. + + If the key does not exist in the current batch, a new batch entry is created with a unique batch ID. + For existing keys, the message is appended to the existing batch. Logs the association between the + logline and batch ID, updates batch timestamps, and tracks fill levels for monitoring purposes. Args: - key (str): Key to which the message is added - logline_id (uuid.UUID): Logline ID of the message - message (str): Message to be added + key (str): Key to which the message is added (typically subnet ID). + logline_id (uuid.UUID): Unique identifier of the logline message. + message (str): JSON-formatted message to be added to the batch. """ if key in self.batch: # key already has messages associated self.batch[key].append(message) @@ -128,18 +136,33 @@ def add_message(self, key: str, logline_id: uuid.UUID, message: str) -> None: ) def get_message_count_for_batch(self) -> int: - """Returns the number of all batch entries as a sum over all key's batch entries.""" + """Returns the total number of messages across all batches. + + Calculates the sum of message counts from all key-specific batches currently stored. + + Returns: + Total number of messages in all batches. + """ return sum(len(key_entry) for key_entry in self.batch.values()) def get_message_count_for_buffer(self) -> int: - """Returns the number of all buffered entries as a sum over all key's buffer entries.""" + """Returns the total number of messages across all buffers. + + Calculates the sum of message counts from all key-specific buffers currently stored. + + Returns: + Total number of messages in all buffers. + """ return sum(len(key_entry) for key_entry in self.buffer.values()) def get_message_count_for_batch_key(self, key: str) -> int: - """Returns the number of all batch messages for a given key. + """Returns the number of messages in the batch for a specific key. Args: - key (str): Key for which message count is returned + key (str): Key for which message count is returned. + + Returns: + Number of messages in the batch for the given key, or 0 if key doesn't exist. """ if key in self.batch: return len(self.batch[key]) @@ -147,10 +170,13 @@ def get_message_count_for_batch_key(self, key: str) -> int: return 0 def get_message_count_for_buffer_key(self, key: str) -> int: - """Returns the number of all buffered messages for a given key. + """Returns the number of messages in the buffer for a specific key. Args: - key (str): Key for which message count is returned + key (str): Key for which message count is returned. + + Returns: + Number of messages in the buffer for the given key, or 0 if key doesn't exist. """ if key in self.buffer: return len(self.buffer[key]) @@ -158,15 +184,23 @@ def get_message_count_for_buffer_key(self, key: str) -> int: return 0 def complete_batch(self, key: str) -> dict: - """Completes the batch and returns a full data packet including timestamps and messages. Depending on the - stored data, either both batches are added to the packet, or just the latest messages. + """Completes the batch for a specific key and returns a formatted data packet. + + Handles multiple scenarios based on available data: + - Variant 1: Only batch has entries - sends current batch data + - Variant 2: Both buffer and batch have entries - combines both in chronological order + - Variant 3: Only buffer has entries - cleans up old buffer data + - Variant 4: No data exists - raises ValueError + + The method sorts all messages by timestamp, creates a data packet with batch metadata, + logs completion timestamps, and moves current batch data to buffer for next iteration. Args: - key (str): Key for which to complete the current batch and return data packet + key (str): Key for which to complete the current batch and return data packet. Returns: - Set of new Logline IDs and dictionary of begin_timestamp, end_timestamp and messages (including buffered - data) associated with a key + Dictionary containing batch_id, begin_timestamp, end_timestamp, and chronologically + sorted message data combining buffer and batch messages. Raises: ValueError: No data is available for sending. @@ -284,11 +318,13 @@ def _get_last_timestamp_of_batch() -> str | None: raise ValueError("No data available for sending.") def get_stored_keys(self) -> set: - """ - Retrieve all keys stored in either the batch or the buffer. + """Retrieves all keys stored in either the batch or the buffer. + + Combines keys from both the current batch dictionary and the buffer dictionary + to provide a complete set of all keys that have associated data. Returns: - List of all keys stored in either dictionary + Set of all unique keys stored in either batch or buffer dictionaries. """ keys_set = set() @@ -304,6 +340,17 @@ def get_stored_keys(self) -> set: def _extract_tuples_from_json_formatted_strings( data: list[str], ) -> list[tuple[str, str]]: + """Extracts timestamp-message tuples from JSON-formatted message strings. + + Parses each JSON string to extract the timestamp field and creates tuples + containing the timestamp and the original message for sorting purposes. + + Args: + data (list[str]): List of JSON-formatted message strings. + + Returns: + List of tuples containing (timestamp, message) pairs. + """ tuples = [] for item in data: @@ -318,18 +365,45 @@ def _extract_tuples_from_json_formatted_strings( def _sort_by_timestamp( data: list[tuple[str, str]], ) -> list[str]: + """Sorts message tuples by timestamp and returns the sorted messages. + + Takes a list of (timestamp, message) tuples, sorts them chronologically + by timestamp, and extracts the sorted messages. + + Args: + data (list[tuple[str, str]]): List of (timestamp, message) tuples. + + Returns: + List of messages sorted chronologically by timestamp. + """ sorted_data = sorted(data, key=lambda x: x[0]) loglines = [message for _, message in sorted_data] return loglines def _sort_batch(self, key: str): + """Sorts the batch messages for a specific key by timestamp. + + Extracts timestamps from JSON-formatted messages in the batch and sorts them + chronologically. Updates the batch in-place with the sorted messages. + + Args: + key (str): Key identifying the batch to be sorted. + """ if key in self.batch: self.batch[key] = self._sort_by_timestamp( self._extract_tuples_from_json_formatted_strings(self.batch[key]) ) def _sort_buffer(self, key: str): + """Sorts the buffer messages for a specific key by timestamp. + + Extracts timestamps from JSON-formatted messages in the buffer and sorts them + chronologically. Updates the buffer in-place with the sorted messages. + + Args: + key (str): Key identifying the buffer to be sorted. + """ if key in self.buffer: self.buffer[key] = self._sort_by_timestamp( self._extract_tuples_from_json_formatted_strings(self.buffer[key]) @@ -337,7 +411,14 @@ def _sort_buffer(self, key: str): class BufferedBatchSender: - """Adds messages to the :class:`BufferedBatch` and sends them after a timer ran out or a key's batch is full.""" + """Main component for managing batch collection and dispatch in the log collection stage + + Coordinates the addition of messages to batches and handles automatic sending based on two triggers: + size-based (when a batch reaches the configured size limit) or time-based (when a timeout expires). + Manages a timer that ensures batches are sent even if they don't reach the size threshold, + preventing data from being held indefinitely. Sends completed batches to the next pipeline stage + via Kafka and tracks message timestamps for monitoring and debugging purposes. + """ def __init__(self): self.topic = PRODUCE_TOPIC @@ -356,12 +437,16 @@ def __del__(self): self._send_all_batches(reset_timer=False) def add_message(self, key: str, message: str) -> None: - """Adds the message to the key's batch and sends it if it is full. In the first execution, a timer starts - whose timeout triggers sending for all batches. + """Adds a message to the batch and triggers sending if batch size limit is reached. + + Extracts the logline ID from the JSON message, logs the processing timestamps, + and adds the message to the appropriate batch. If the batch reaches the configured + size limit, it is immediately sent. On the first message, starts a timer that will + trigger sending of all batches when the timeout expires. Args: - message (str): Message to be added to the batch - key (str): Key of the message (e.g. subnet of client IP address in a log message) + key (str): Key of the message (typically subnet ID derived from client IP address). + message (str): JSON-formatted message to be added to the batch. """ logline_id = json.loads(message).get("logline_id") self.logline_timestamps.insert( @@ -398,11 +483,15 @@ def add_message(self, key: str, message: str) -> None: self._reset_timer() def _send_all_batches(self, reset_timer: bool = True) -> None: - """ - Dispatch all batches for the Kafka queue + """Dispatches all available batches to the Kafka queue. + + Iterates through all stored keys and sends their associated batches. Provides + detailed logging about the number of messages and batches sent. Optionally + resets the internal timer after completion. Args: - reset_timer (bool): whether the timer should be reset + reset_timer (bool): Whether the timer should be reset after sending. + Default: True """ number_of_keys = 0 total_number_of_batch_messages = self.batch.get_message_count_for_batch() @@ -436,11 +525,13 @@ def _send_all_batches(self, reset_timer: bool = True) -> None: ) def _send_batch_for_key(self, key: str) -> None: - """ - Send one batch based on the key + """Sends a single batch for the specified key. + + Attempts to complete the batch for the given key and sends the resulting + data packet. If no data is available for the key, the operation is skipped. Args: - key (str): Key to identify the batch + key (str): Key to identify the batch to be sent. """ try: data = self.batch.complete_batch(key) @@ -451,12 +542,14 @@ def _send_batch_for_key(self, key: str) -> None: self._send_data_packet(key, data) def _send_data_packet(self, key: str, data: dict) -> None: - """ - Sends a packet of a Batch to the defined Kafka topic + """Sends a batch data packet to the configured Kafka topic. + + Serializes the batch data using the Batch schema and produces it to the + configured Kafka topic with the associated key for proper partitioning. Args: - key (str): key to identify the batch - data (dict): the batch data to send + key (str): Key to identify the batch for Kafka partitioning. + data (dict): The batch data to be serialized and sent. """ batch_schema = marshmallow_dataclass.class_schema(Batch)() @@ -467,7 +560,11 @@ def _send_data_packet(self, key: str, data: dict) -> None: ) def _reset_timer(self) -> None: - """Restarts the internal timer of the object""" + """Restarts the internal timer for batch timeout handling. + + Cancels any existing timer and creates a new one with the configured timeout. + When the timer expires, all available batches will be automatically sent. + """ if self.timer: self.timer.cancel() From b181763be151ae96680cd4795f47b6a6ef5a7602 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Thu, 2 Oct 2025 13:57:55 +0200 Subject: [PATCH 18/62] Update pipeline.rst for Stage 2: Log Collection --- docs/pipeline.rst | 77 ++++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 27b71794..14f0d620 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -71,7 +71,7 @@ The `Log Collection` stage comprises three main classes: and content. Adds ``subnet_id`` that it retrieves from the client's IP address in the logline. 2. :class:`BufferedBatch`: Buffers validated loglines with respect to their ``subnet_id``. Maintains the timestamps for accurate processing and analysis per key (``subnet_id``). Returns sorted batches. -3. :class:`CollectorKafkaBatchSender`: Adds messages to the data structure :class:`BufferedBatch`, maintains the timer +3. :class:`BufferedBatchSender`: Adds messages to the data structure :class:`BufferedBatch`, maintains the timer and checks the fill level of the key-specific batches. Sends the key's batches if full, sends all batches at timeout. Main Classes @@ -84,7 +84,7 @@ Main Classes .. autoclass:: BufferedBatch .. py:currentmodule:: src.logcollector.batch_handler -.. autoclass:: CollectorKafkaBatchSender +.. autoclass:: BufferedBatchSender Usage ----- @@ -98,7 +98,7 @@ validates. The logline is parsed into its respective fields, each checked for co - **Field Validation**: - Checks include data type verification and value range checks (e.g., verifying that an IP address is valid). - - Only loglines meeting the criteria are forwarded to the :class:`CollectorKafkaBatchSender`. + - Only loglines meeting the criteria are forwarded to the :class:`BufferedBatchSender`. - **Subnet Identification**: @@ -129,17 +129,16 @@ validates. The logline is parsed into its respective fields, each checked for co | **Field** | **Description** | +======================+================================================+ | ``TIMESTAMP`` | The date and time when the log entry was | - | | recorded. Formatted as | - | | ``YYYY-MM-DDTHH:MM:SS.sssZ``. | + | | recorded. The format is configurable through | + | | the ``logline_format`` in ``config.yaml``. | | | | - | | - **Format**: ``%Y-%m-%dT%H:%M:%S.%f`` (with | - | | microseconds truncated to milliseconds). | - | | - **Time Zone**: ``Z`` | - | | indicates Zulu time (UTC). | - | | - **Example**: ``2024-07-28T14:45:30.123Z`` | + | | - **Default Format**: ``%Y-%m-%dT%H:%M:%S.%fZ``| + | | (ISO 8601 with microseconds and UTC). | + | | - **Example**: ``2024-07-28T14:45:30.123456Z`` | | | | - | | This format closely resembles ISO 8601, with | - | | milliseconds precision. | + | | The format can be customized by modifying the | + | | timestamp configuration in the pipeline | + | | configuration file. | +----------------------+------------------------------------------------+ | ``STATUS`` | The status of the DNS query, e.g., ``NOERROR``,| | | ``NXDOMAIN``. | @@ -168,7 +167,7 @@ validates. The logline is parsed into its respective fields, each checked for co BufferedBatch ............. -The :class:`BufferedBatch` manages the buffering of validated loglines as well as their timestamps: +The :class:`BufferedBatch` manages the buffering of validated loglines as well as their timestamps and batch metadata: - **Batching Logic and Buffering Strategy**: @@ -177,20 +176,37 @@ The :class:`BufferedBatch` manages the buffering of validated loglines as well a - This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in :ref:`Stage 4: Data Inspection` and :ref:`Stage 5: Data Analysis`. - All batches get sorted by their timestamps at completion to ensure correct chronological order. - - A `begin_timestamp` and `end_timestamp` per key are extracted and send as metadata (needed for analysis). These + - A `begin_timestamp` and `end_timestamp` per key are extracted and sent as metadata (needed for analysis). These are taken from the chronologically first and last message in a batch. + - Tracks batch IDs, timestamps, and fill levels for comprehensive monitoring and debugging. -CollectorKafkaBatchSender -......................... +- **Monitoring and Metadata**: -The :class:`CollectorKafkaBatchSender` manages the sending of validated loglines stored in the :class:`BufferedBatch`: + - Each batch is assigned a unique batch ID for tracking purposes. + - Logs associations between loglines and their respective batches. + - Maintains fill level statistics for both batches and buffers. + - Records batch status changes (waiting, completed) with timestamps. -- Starts a timer upon receiving the first log entry. -- When a batch reaches the configured size (e.g., 1000 entries), the current and previous - batches of this key are concatenated and sent to the Kafka Broker(s) with topic ``Prefilter``. -- Upon timer expiration, the currently stored batches of all keys are sent. Serves as backup if batches don't reach - the configured size. -- If no messages are present when the timer expires, nothing is sent. +BufferedBatchSender +................... + +The :class:`BufferedBatchSender` manages the sending of validated loglines stored in the :class:`BufferedBatch`: + +- **Timer-based and Size-based Triggers**: + + - Starts a timer upon receiving the first log entry. + - When a batch reaches the configured size (e.g., 1000 entries), the current and previous + batches of this key are concatenated and sent to the Kafka topic ``batch_sender_to_prefilter``. + - Upon timer expiration, the currently stored batches of all keys are sent. Serves as backup if batches don't reach + the configured size. + - If no messages are present when the timer expires, nothing is sent. + +- **Message Processing and Monitoring**: + + - Extracts logline IDs from JSON messages for tracking purposes. + - Logs processing timestamps (in_process, batched) for each message. + - Provides detailed logging about the number of messages and batches sent. + - Uses the Batch schema for serialization before sending to Kafka. Configuration ------------- @@ -218,7 +234,11 @@ Class Overview - **Batch**: Stores the latest incoming messages associated with a particular key. -- **Buffer**: Stores the previous batch of messages associated with a particular key, including the timestamps. +- **Buffer**: Stores the previous batch of messages associated with a particular key. + +- **Batch ID**: Unique identifier assigned to each batch for tracking and monitoring purposes. + +- **Monitoring Databases**: Tracks logline-to-batch associations, batch timestamps, and fill levels for comprehensive monitoring. Key Procedures .............. @@ -227,14 +247,17 @@ Key Procedures - When a new message arrives, the ``add_message()`` method is called. - If the key already exists in the batch, the message is appended to the list of messages for that key. - - If the key does not exist, a new entry is created in the batch. + - If the key does not exist, a new entry is created in the batch with a unique batch ID. + - Batch timestamps and logline-to-batch associations are logged for monitoring. - **Example**: - ``message_1`` arrives for ``key_1`` and is added to ``batch["key_1"]``. 2. **Retrieving Message Counts**: - - Use ``get_number_of_messages(key)`` to get the count of messages in the current batch for a specific key. - - Use ``get_number_of_buffered_messages(key)`` to get the count of messages in the buffer for a specific key. + - Use ``get_message_count_for_batch_key(key)`` to get the count of messages in the current batch for a specific key. + - Use ``get_message_count_for_buffer_key(key)`` to get the count of messages in the buffer for a specific key. + - Use ``get_message_count_for_batch()`` to get the total count across all batches. + - Use ``get_message_count_for_buffer()`` to get the total count across all buffers. 3. **Completing a Batch**: From 7741f3fd3cd8229df20beb083b401c75b0f27423 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Thu, 2 Oct 2025 14:16:21 +0200 Subject: [PATCH 19/62] Update pipeline.rst for Stage 2: Log Collection (2) --- docs/pipeline.rst | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 14f0d620..7ebbcd3c 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -53,14 +53,27 @@ field in `config.yaml`. Changing the file name to read from differs depending on Stage 2: Log Collection ======================= -The `Log Collection` stage is responsible for retrieving loglines from the :ref:`Log Storage`, -parsing their information fields, and validating the data. Each field is checked to ensure it is of the correct type -and format. This stage ensures that all data is accurate, reducing the need for further verification in subsequent -stages. Any loglines that do not meet the required format are immediately discarded to maintain data integrity. Valid -loglines are then buffered and transmitted in batches after a pre-defined timeout or when the buffer reaches its -capacity. This minimizes the number of messages sent to the next stage and optimizes performance. The client's IP -address is retrieved from the logline and used to create the ``subnet_id`` with the number of subnet bits specified in -the configuration. The functionality of the buffer is detailed in the subsection, :ref:`Buffer Functionality`. +The Log Collection stage validates and processes incoming loglines from the Log Storage stage, organizes them into batches based on subnet IDs, and forwards them to the next pipeline stage for further analysis. + +Core Functionality +------------------ + +The `Log Collection` stage is responsible for retrieving loglines from the :ref:`Log Storage`, parsing their information fields, and validating the data. Each field is checked to ensure it is of the correct type and format. This stage ensures that all data is accurate, reducing the need for further verification in subsequent stages. + +Data Processing and Validation +.............................. + +Any loglines that do not meet the required format are immediately discarded to maintain data integrity. The validation process includes data type verification and value range checks (e.g., verifying that IP addresses are valid). Only validated loglines proceed to the batching phase. + +Batching and Performance Optimization +..................................... + +Valid loglines are buffered and transmitted in batches after a pre-defined timeout or when the buffer reaches its capacity. This minimizes the number of messages sent to the next stage and optimizes performance. The client's IP address is retrieved from the logline and used to create the ``subnet_id`` with the number of subnet bits specified in the configuration. + +Advanced Features +................ + +The functionality of the buffer system is detailed in the subsection :ref:`Buffer Functionality`. This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in later pipeline stages. Overview -------- From 4b13a7a120bf473b980f225b78073b1bf2407c8c Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Thu, 2 Oct 2025 14:16:21 +0200 Subject: [PATCH 20/62] Update pipeline.rst for Stage 2: Log Collection (2) --- docs/pipeline.rst | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 14f0d620..3c838262 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -53,14 +53,37 @@ field in `config.yaml`. Changing the file name to read from differs depending on Stage 2: Log Collection ======================= +The Log Collection stage validates and processes incoming loglines from the Log Storage stage, organizes them into +batches based on subnet IDs, and forwards them to the next pipeline stage for further analysis. + +Core Functionality +------------------ + The `Log Collection` stage is responsible for retrieving loglines from the :ref:`Log Storage`, parsing their information fields, and validating the data. Each field is checked to ensure it is of the correct type -and format. This stage ensures that all data is accurate, reducing the need for further verification in subsequent -stages. Any loglines that do not meet the required format are immediately discarded to maintain data integrity. Valid -loglines are then buffered and transmitted in batches after a pre-defined timeout or when the buffer reaches its +and format. This stage ensures that all data is accurate, reducing the need for further verification +in subsequent stages. + +Data Processing and Validation +.............................. + +Any loglines that do not meet the required format are immediately discarded to maintain data integrity. The +validation process includes data type verification and value range checks (e.g., verifying that IP addresses are +valid). Only validated loglines proceed to the batching phase. + +Batching and Performance Optimization +..................................... + +Valid loglines are buffered and transmitted in batches after a pre-defined timeout or when the buffer reaches its capacity. This minimizes the number of messages sent to the next stage and optimizes performance. The client's IP -address is retrieved from the logline and used to create the ``subnet_id`` with the number of subnet bits specified in -the configuration. The functionality of the buffer is detailed in the subsection, :ref:`Buffer Functionality`. +address is retrieved from the logline and used to create the ``subnet_id`` with the number of subnet bits specified +in the configuration. + +Advanced Features +................ + +The functionality of the buffer system is detailed in the subsection :ref:`Buffer Functionality`. This approach helps +detect errors or attacks that may occur at the boundary between two batches when analyzed in later pipeline stages. Overview -------- @@ -344,9 +367,8 @@ Stage 4: Inspection Overview -------- -The `Inspector` stage is responsible to run time-series based anomaly detection on prefiltered batches. This stage is essentiell to reduce -the load on the `Detection` stage. -Otherwise, resource complexity increases disproportionately. +The `Inspector` stage is responsible to run time-series based anomaly detection on prefiltered batches. This stage +is essentiell to reduce the load on the `Detection` stage. Otherwise, resource complexity increases disproportionately. Main Class ---------- From 1db94ad090733a61893477b808b196492846adfc Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Thu, 2 Oct 2025 14:22:09 +0200 Subject: [PATCH 21/62] Update pipeline.rst for Stage 2: Log Collection (3) --- docs/pipeline.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 3c838262..27724932 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -273,6 +273,7 @@ Key Procedures - If the key does not exist, a new entry is created in the batch with a unique batch ID. - Batch timestamps and logline-to-batch associations are logged for monitoring. - **Example**: + - ``message_1`` arrives for ``key_1`` and is added to ``batch["key_1"]``. 2. **Retrieving Message Counts**: From a217bf2120beaeae78d07fc784b9d1915e76e27d Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Thu, 2 Oct 2025 14:24:03 +0200 Subject: [PATCH 22/62] Update docker compose --- assets/heidgaf_logo_github.png | Bin 0 -> 52543 bytes .../docker-compose.swarm-kafka.yml | 110 +++++++++++------- .../docker-compose.swarm-monitoring.yml | 6 + .../base/docker-compose.kafka.yml | 63 +++++++--- .../datatests/docker-compose.clickhouse.yml | 6 + .../prod/docker-compose.clickhouse.yml | 6 + 6 files changed, 130 insertions(+), 61 deletions(-) create mode 100644 assets/heidgaf_logo_github.png diff --git a/assets/heidgaf_logo_github.png b/assets/heidgaf_logo_github.png new file mode 100644 index 0000000000000000000000000000000000000000..751c8612e45381e51533d13f98bcea7524d7e679 GIT binary patch literal 52543 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 Y|5z_Y Date: Thu, 2 Oct 2025 15:41:26 +0200 Subject: [PATCH 23/62] Update docker compose changes --- .../docker-compose.swarm-kafka.yml | 6 +- docker/docker-compose.datatests.yml | 122 --------------- docker/docker-compose.external.yml | 146 ------------------ docker/docker-compose.yml | 9 ++ .../base/docker-compose.kafka.yml | 20 +-- .../base/docker-compose.pipeline.yml | 9 ++ .../datatests/docker-compose.clickhouse.yml | 27 ---- .../datatests/docker-compose.grafana.yml | 23 --- .../prod/docker-compose.clickhouse.yml | 4 - .../prod/docker-compose.datatest.yml | 13 ++ 10 files changed, 39 insertions(+), 340 deletions(-) delete mode 100644 docker/docker-compose.datatests.yml delete mode 100644 docker/docker-compose.external.yml delete mode 100644 docker/docker-compose/datatests/docker-compose.clickhouse.yml delete mode 100644 docker/docker-compose/datatests/docker-compose.grafana.yml create mode 100644 docker/docker-compose/prod/docker-compose.datatest.yml diff --git a/docker/docker-compose-swarm/docker-compose.swarm-kafka.yml b/docker/docker-compose-swarm/docker-compose.swarm-kafka.yml index 5717db62..f2628574 100644 --- a/docker/docker-compose-swarm/docker-compose.swarm-kafka.yml +++ b/docker/docker-compose-swarm/docker-compose.swarm-kafka.yml @@ -32,7 +32,7 @@ services: KAFKA_BROKER_ID: 1 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://192.168.174.52:8097 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka1:19092,EXTERNAL://${HOST_IP}:8097 KAFKA_LISTENERS: INTERNAL://0.0.0.0:19092,EXTERNAL://0.0.0.0:8097 KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" @@ -65,7 +65,7 @@ services: KAFKA_BROKER_ID: 2 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:19093,EXTERNAL://192.168.174.52:8098 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka2:19093,EXTERNAL://${HOST_IP}:8098 KAFKA_LISTENERS: INTERNAL://0.0.0.0:19093,EXTERNAL://0.0.0.0:8098 KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" @@ -98,7 +98,7 @@ services: KAFKA_BROKER_ID: 3 KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:19094,EXTERNAL://192.168.174.52:8099 + KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka3:19094,EXTERNAL://${HOST_IP}:8099 KAFKA_LISTENERS: INTERNAL://0.0.0.0:19094,EXTERNAL://0.0.0.0:8099 KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" diff --git a/docker/docker-compose.datatests.yml b/docker/docker-compose.datatests.yml deleted file mode 100644 index 5083f5ee..00000000 --- a/docker/docker-compose.datatests.yml +++ /dev/null @@ -1,122 +0,0 @@ -services: - zookeeper: - extends: - file: "docker-compose/base/docker-compose.kafka.yml" - service: zookeeper - - kafka1: - extends: - file: "docker-compose/base/docker-compose.kafka.yml" - service: kafka1 - depends_on: - zookeeper: - condition: service_healthy - - kafka2: - extends: - file: "docker-compose/base/docker-compose.kafka.yml" - service: kafka2 - depends_on: - zookeeper: - condition: service_healthy - - kafka3: - extends: - file: "docker-compose/base/docker-compose.kafka.yml" - service: kafka3 - depends_on: - zookeeper: - condition: service_healthy - - logserver: - extends: - file: "docker-compose/base/docker-compose.pipeline.yml" - service: logserver - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - - logcollector: - extends: - file: "docker-compose/base/docker-compose.pipeline.yml" - service: logcollector - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - - prefilter: - extends: - file: "docker-compose/base/docker-compose.pipeline.yml" - service: prefilter - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - - inspector: - extends: - file: "docker-compose/base/docker-compose.pipeline.yml" - service: inspector - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - - detector: - extends: - file: "docker-compose/base/docker-compose.pipeline.yml" - service: detector - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - - clickhouse-server: - extends: - file: "docker-compose/datatests/docker-compose.clickhouse.yml" - service: clickhouse-server - - grafana: - extends: - file: "docker-compose/datatests/docker-compose.grafana.yml" - service: grafana - - monitoring_agent: - extends: - file: "docker-compose/base/docker-compose.monitoring.yml" - service: monitoring_agent - depends_on: - kafka1: - condition: service_healthy - kafka2: - condition: service_healthy - kafka3: - condition: service_healthy - clickhouse-server: - condition: service_healthy - -networks: - heidgaf: - driver: bridge - ipam: - driver: default - config: - - subnet: 172.27.0.0/16 - gateway: 172.27.0.1 diff --git a/docker/docker-compose.external.yml b/docker/docker-compose.external.yml deleted file mode 100644 index fb577b2d..00000000 --- a/docker/docker-compose.external.yml +++ /dev/null @@ -1,146 +0,0 @@ -services: - - logcollector: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.logcollector - network: host - restart: "unless-stopped" - depends_on: - logserver: - condition: service_started - networks: - heidgaf: - ipv4_address: 172.27.0.7 - volumes: - - ./config.yaml:/usr/src/app/config.yaml - memswap_limit: 768m - deploy: - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - - logserver: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.logserver - network: host - restart: "unless-stopped" - ports: - - 9998:9998 - networks: - heidgaf: - ipv4_address: 172.27.0.8 - memswap_limit: 768m - deploy: - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - volumes: - - "${MOUNT_PATH:?MOUNT_PATH not set}:/opt/file.txt" - - ./config.yaml:/usr/src/app/config.yaml - - - inspector: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.inspector - network: host - restart: "unless-stopped" - depends_on: - logserver: - condition: service_started - prefilter: - condition: service_started - logcollector: - condition: service_started - networks: - heidgaf: - ipv4_address: 172.27.0.6 - volumes: - - ./config.yaml:/usr/src/app/config.yaml - deploy: - mode: "replicated" - replicas: 1 - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - - prefilter: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.prefilter - network: host - restart: "unless-stopped" - depends_on: - logcollector: - condition: service_started - logserver: - condition: service_started - networks: - heidgaf: - ipv4_address: 172.27.0.9 - volumes: - - ./config.yaml:/usr/src/app/config.yaml - deploy: - mode: "replicated" - replicas: 1 - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - - detector: - build: - context: .. - dockerfile: docker/dockerfiles/Dockerfile.detector - network: host - restart: "unless-stopped" - depends_on: - logcollector: - condition: service_started - logserver: - condition: service_started - networks: - heidgaf: - ipv4_address: 172.27.0.10 - volumes: - - ./config.yaml:/usr/src/app/config.yaml - deploy: - mode: "replicated" - replicas: 1 - resources: - limits: - cpus: '2' - memory: 512m - reservations: - cpus: '1' - memory: 256m - devices: - - driver: nvidia - count: 1 # alternatively, use `count: all` for all GPUs - capabilities: [gpu] - -networks: - heidgaf: - driver: bridge - ipam: - driver: default - config: - - subnet: 172.27.0.0/16 - gateway: 172.27.0.1 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5198da86..6fd8b1be 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -120,3 +120,12 @@ networks: config: - subnet: 172.27.0.0/16 gateway: 172.27.0.1 + +volumes: + ch_data: + ch_logs: + kafka-data1: + kafka-data2: + kafka-data3: + zk-data: + zk-txn-logs: \ No newline at end of file diff --git a/docker/docker-compose/base/docker-compose.kafka.yml b/docker/docker-compose/base/docker-compose.kafka.yml index dbfe2cd3..c52aaca3 100644 --- a/docker/docker-compose/base/docker-compose.kafka.yml +++ b/docker/docker-compose/base/docker-compose.kafka.yml @@ -26,12 +26,11 @@ services: - heidgaf ports: - "8097:8097" - - "19092:19092" depends_on: zookeeper: condition: service_healthy healthcheck: - test: [ "CMD", "kafka-topics", "--bootstrap-server", "kafka1:8097", "--list" ] + test: ["CMD-SHELL", "nc -z localhost 19092"] interval: 30s timeout: 10s retries: 10 @@ -52,19 +51,18 @@ services: - kafka-data1:/var/lib/kafka/data kafka2: - image: confluentinc/cp-kafka:7.3.2 + image: confluentinc/cp-kafka:7.9.3 container_name: kafka2 restart: "unless-stopped" networks: - heidgaf ports: - "8098:8098" - - "19093:19093" depends_on: zookeeper: condition: service_healthy healthcheck: - test: [ "CMD", "kafka-topics", "--bootstrap-server", "kafka2:8098", "--list" ] + test: ["CMD-SHELL", "nc -z localhost 19093"] interval: 30s timeout: 10s retries: 10 @@ -86,19 +84,18 @@ services: - kafka-data2:/var/lib/kafka/data kafka3: - image: confluentinc/cp-kafka:7.3.2 + image: confluentinc/cp-kafka:7.9.3 container_name: kafka3 restart: "unless-stopped" networks: - heidgaf ports: - "8099:8099" - - "19094:19094" depends_on: zookeeper: condition: service_healthy healthcheck: - test: [ "CMD", "kafka-topics", "--bootstrap-server", "kafka3:8099", "--list" ] + test: ["CMD-SHELL", "nc -z localhost 19094"] interval: 30s timeout: 10s retries: 10 @@ -122,10 +119,3 @@ services: networks: heidgaf: external: true - -volumes: - kafka-data1: - kafka-data2: - kafka-data3: - zk-data: - zk-txn-logs: \ No newline at end of file diff --git a/docker/docker-compose/base/docker-compose.pipeline.yml b/docker/docker-compose/base/docker-compose.pipeline.yml index 5ab77385..54a50ecf 100644 --- a/docker/docker-compose/base/docker-compose.pipeline.yml +++ b/docker/docker-compose/base/docker-compose.pipeline.yml @@ -18,6 +18,7 @@ services: memory: 256m volumes: - "${MOUNT_PATH:?MOUNT_PATH not set}:/opt/file.txt" + - ../../../config.yaml:/app/config.yaml environment: - GROUP_ID=log_storage @@ -27,6 +28,8 @@ services: dockerfile: docker/dockerfiles/Dockerfile.logcollector network: host restart: "unless-stopped" + volumes: + - ../../../config.yaml:/app/config.yaml networks: heidgaf: memswap_limit: 768m @@ -47,6 +50,8 @@ services: dockerfile: docker/dockerfiles/Dockerfile.prefilter network: host restart: "unless-stopped" + volumes: + - ../../../config.yaml:/app/config.yaml networks: heidgaf: deploy: @@ -68,6 +73,8 @@ services: dockerfile: docker/dockerfiles/Dockerfile.inspector network: host restart: "unless-stopped" + volumes: + - ../../../config.yaml:/app/config.yaml networks: heidgaf: deploy: @@ -90,6 +97,8 @@ services: dockerfile: docker/dockerfiles/Dockerfile.detector network: host restart: "unless-stopped" + volumes: + - ../../../config.yaml:/app/config.yaml networks: heidgaf: deploy: diff --git a/docker/docker-compose/datatests/docker-compose.clickhouse.yml b/docker/docker-compose/datatests/docker-compose.clickhouse.yml deleted file mode 100644 index 2f99e470..00000000 --- a/docker/docker-compose/datatests/docker-compose.clickhouse.yml +++ /dev/null @@ -1,27 +0,0 @@ -services: - clickhouse-server: - image: clickhouse/clickhouse-server:24.3.12.75-alpine - container_name: clickhouse-server - volumes: - - ../../create_tables:/create_tables - - ../../create_datatest_tables:/create_datatest_tables - - ../../insert_datatest_data:/insert_datatest_data - - ../../insert_datatest_data/data:/var/lib/clickhouse/user_files/data - - ../../init_datatests.sh:/docker-entrypoint-initdb.d/init.sh - - ch_data:/var/lib/clickhouse/ - - ch_logs:/var/log/clickhouse-server/ - networks: - - heidgaf - restart: "unless-stopped" - ports: - - "8123:8123" - - "9000:9000" - healthcheck: - test: [ "CMD-SHELL", "nc -z 127.0.0.1 8123" ] - interval: 10s - timeout: 5s - retries: 3 - -volumes: - ch_data: - ch_logs: diff --git a/docker/docker-compose/datatests/docker-compose.grafana.yml b/docker/docker-compose/datatests/docker-compose.grafana.yml deleted file mode 100644 index a5539d48..00000000 --- a/docker/docker-compose/datatests/docker-compose.grafana.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - grafana: - image: grafana/grafana:11.2.2-security-01 - container_name: grafana - networks: - - heidgaf - restart: "unless-stopped" - ports: - - "3000:3000" - volumes: - - ../../grafana-provisioning/dashboards:/etc/grafana/provisioning/dashboards - - ../../datatests.json:/etc/grafana/provisioning/dashboards/datatests.json - - ../../grafana-provisioning/dashboards/dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml - - ../../grafana-provisioning/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml - environment: - - GF_SECURITY_ADMIN_USER=admin - - GF_SECURITY_ADMIN_PASSWORD=admin - - GF_INSTALL_PLUGINS=grafana-clickhouse-datasource - healthcheck: - test: [ "CMD-SHELL", "nc -z localhost 3000" ] - interval: 10s - timeout: 5s - retries: 3 diff --git a/docker/docker-compose/prod/docker-compose.clickhouse.yml b/docker/docker-compose/prod/docker-compose.clickhouse.yml index 515ed6de..1ca7bdf8 100644 --- a/docker/docker-compose/prod/docker-compose.clickhouse.yml +++ b/docker/docker-compose/prod/docker-compose.clickhouse.yml @@ -17,7 +17,3 @@ services: interval: 10s timeout: 5s retries: 3 - -volumes: - ch_data: - ch_logs: diff --git a/docker/docker-compose/prod/docker-compose.datatest.yml b/docker/docker-compose/prod/docker-compose.datatest.yml new file mode 100644 index 00000000..5859ffe3 --- /dev/null +++ b/docker/docker-compose/prod/docker-compose.datatest.yml @@ -0,0 +1,13 @@ +services: + clickhouse-server: + profiles: ["datatest"] + volumes: + - ../../create_tables:/docker-entrypoint-initdb.d # keep this for both + - ../../create_datatest_tables:/create_datatest_tables + - ../../insert_datatest_data:/insert_datatest_data + - ../../insert_datatest_data/data:/var/lib/clickhouse/user_files/data + - ../../init_datatests.sh:/docker-entrypoint-initdb.d/init.sh + grafana: + profiles: ["datatest"] + volumes: + - ../../datatests.json:/etc/grafana/provisioning/dashboards/datatests.json \ No newline at end of file From d95b611cbb422865b6cc86f127709552055e5bb1 Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Thu, 2 Oct 2025 15:42:36 +0200 Subject: [PATCH 24/62] Fix linting --- docker/docker-compose.yml | 2 +- docker/docker-compose/prod/docker-compose.datatest.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6fd8b1be..63fb7483 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -128,4 +128,4 @@ volumes: kafka-data2: kafka-data3: zk-data: - zk-txn-logs: \ No newline at end of file + zk-txn-logs: diff --git a/docker/docker-compose/prod/docker-compose.datatest.yml b/docker/docker-compose/prod/docker-compose.datatest.yml index 5859ffe3..f34a5d3d 100644 --- a/docker/docker-compose/prod/docker-compose.datatest.yml +++ b/docker/docker-compose/prod/docker-compose.datatest.yml @@ -10,4 +10,4 @@ services: grafana: profiles: ["datatest"] volumes: - - ../../datatests.json:/etc/grafana/provisioning/dashboards/datatests.json \ No newline at end of file + - ../../datatests.json:/etc/grafana/provisioning/dashboards/datatests.json From 8edbc3ca4e2d1c73ac0cd2f5cfee49cfd374dc5e Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Thu, 2 Oct 2025 16:09:07 +0200 Subject: [PATCH 25/62] Update banner --- README.md | 66 +++++++++++------- .../heidgaf_overview_detailed.drawio.png | Bin 0 -> 132938 bytes 2 files changed, 39 insertions(+), 27 deletions(-) create mode 100644 docs/media/heidgaf_overview_detailed.drawio.png diff --git a/README.md b/README.md index e65d9e75..aad65dd4 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,6 @@ Explore the docs »

- View Demo - · Report Bug · Request Feature @@ -58,14 +56,14 @@ ## About the Project -![Pipeline overview](https://raw.githubusercontent.com/stefanDeveloper/heiDGAF/main/docs/media/pipeline_overview.png?raw=true) +![Pipeline overview](https://raw.githubusercontent.com/stefanDeveloper/heiDGAF/main/docs/media/heidgaf_overview_detailed.drawio.png?raw=true) -## Getting Started +## 🛠️ Getting Started -If you want to use heiDGAF, just use the provided Docker compose to quickly bootstrap your environment: +Run `heiDGAF` using Docker Compose: -``` -docker compose -f docker/docker-compose.yml up +```sh +HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up ```

Terminal example @@ -129,9 +127,9 @@ In the below summary you will find examplary views of the grafana dashboards. Th -### Developing +## Developing -Install all Python requirements: +Install `Python` requirements: ```sh python -m venv .venv @@ -167,24 +165,23 @@ The full list of configuration parameters is available at the [documentation](ht

(back to top)

-### Insert test data +### Insert Data >[!IMPORTANT] -> To be able to train and test our or your own models, you will need to download the datasets. +> We rely on the following datasets to train and test our or your own models: For training our models, we currently rely on the following data sets: - [CICBellDNS2021](https://www.unb.ca/cic/datasets/dns-2021.html) - [DGTA Benchmark](https://data.mendeley.com/datasets/2wzf9bz7xr/1) - [DNS Tunneling Queries for Binary Classification](https://data.mendeley.com/datasets/mzn9hvdcxg/1) - [UMUDGA - University of Murcia Domain Generation Algorithm Dataset](https://data.mendeley.com/datasets/y8ph45msv8/1) -- [Real-CyberSecurity-Datasets](https://github.com/gfek/Real-CyberSecurity-Datasets/) +- [DGArchive](https://dgarchive.caad.fkie.fraunhofer.de/) -However, we compute all feature separately and only rely on the `domain` and `class`. -Currently, we are only interested in binary classification, thus, the `class` is either `benign` or `malicious`. +We compute all feature separately and only rely on the `domain` and `class` for binary classification. After downloading the dataset and storing it under `/data` you can run -``` -docker compose -f docker/docker-compose.send-real-logs.yml up +```sh +python scripts/real_logs.dev.py ``` to start inserting the dataset traffic. @@ -192,36 +189,51 @@ to start inserting the dataset traffic. ### Train your own models + > [!IMPORTANT] > This is only a brief wrap-up of a custom training process. > We highly encourage you to have a look at the [documentation](https://heidgaf.readthedocs.io/en/latest/training.html) > for a full description and explanation of the configuration parameters. -Currently, we feature two trained models, namely XGBoost and RandomForest. +We feature two trained models: XGBoost (`src/train/model.py#XGBoostModel`) and RandomForest (`src/train/model.py#RandomForestModel`). ```sh -python -m venv .venv -source .venv/bin/activate +> python -m venv .venv +> source .venv/bin/activate + +> pip install -r requirements/requirements.train.txt + +> python src/train/train.py +Usage: train.py [OPTIONS] COMMAND [ARGS]... -pip install -r requirements/requirements.train.txt +Options: + -h, --help Show this message and exit. + +Commands: + explain + test + train ``` -After setting up the [dataset directories](#insert-test-data) (and adding the code for your model class if applicable), you can start the training process by running the following commands: +Setting up the [dataset directories](#insert-test-data) (and adding the code for your model class if applicable) let's you start the training process by running the following commands: **Model Training** -``` -python src/train/train.py train --dataset --dataset_path --model + +```sh +> python src/train/train.py train --dataset --dataset_path --model ``` The results will be saved per default to `./results`, if not configured otherwise.
**Model Tests** -``` -python src/train/train.py test --dataset --dataset_path --model --model_path + +```sh +> python src/train/train.py test --dataset --dataset_path --model --model_path ``` **Model Explain** -``` -python src/train/train.py explain --dataset --dataset_path --model --model_path + +```sh +> python src/train/train.py explain --dataset --dataset_path --model --model_path ``` This will create a rules.txt file containing the innards of the model, explaining the rules it created. diff --git a/docs/media/heidgaf_overview_detailed.drawio.png b/docs/media/heidgaf_overview_detailed.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..2d5b72a2ef4a19f6c967d96311beccce77537b57 GIT binary patch literal 132938 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

17;V??Npy$U8tWctnM)0g>L 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 Date: Thu, 2 Oct 2025 16:10:28 +0200 Subject: [PATCH 26/62] Update quality --- .../heidgaf_overview_detailed.drawio.png | Bin 132938 -> 204077 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/media/heidgaf_overview_detailed.drawio.png b/docs/media/heidgaf_overview_detailed.drawio.png index 2d5b72a2ef4a19f6c967d96311beccce77537b57..95dfd51185982277e808a4bed9fe2e1e3e9ab56b 100644 GIT binary patch delta 115060 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�wWlmkYHB=`uf9hRR|aev<2= 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 KvRN`le*Xgn^=^6q delta 43361 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 Tjnv#C0zWE>nhGWNES~=tPr*96 From af7383c20b695e9ae06c94f092ebc726093893bc Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Thu, 2 Oct 2025 16:14:35 +0200 Subject: [PATCH 27/62] Update readthedocs --- docs/monitoring.rst | 6 ++++-- docs/training.rst | 2 +- docs/usage.rst | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/monitoring.rst b/docs/monitoring.rst index 638a215c..83eac713 100644 --- a/docs/monitoring.rst +++ b/docs/monitoring.rst @@ -25,7 +25,7 @@ when executing: .. code-block:: console - $ docker compose -f docker/docker-compose.yml up + $ HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up All modules send their monitoring-relevant information to Kafka, from which it is then collected by the @@ -41,9 +41,11 @@ stage, the monitoring functionality can be started in the `datatest` mode: .. code-block:: console - $ docker compose -f docker/docker-compose.datatests.yml up + $ HOST_IP=127.0.0.1 docker compose --profile datatest -f docker-compose.yml -f ./docker-compose/prod/docker-compose.datatest.yml up `Grafana` then shows one more dashboard view, `Datatests`, that shows the confusion matrix for a testing dataset. +Make sure that you set the profile to `datatest` and use the additional docker-compose file +``docker-compose/prod/docker-compose.datatest.yml``. .. warning:: diff --git a/docs/training.rst b/docs/training.rst index df694a17..04fca8bb 100644 --- a/docs/training.rst +++ b/docs/training.rst @@ -4,7 +4,7 @@ Training Overview ======== -In total, we support ``RandomForest``, and ``XGBoost``. +In total, we support ``RandomForest``, ``XGBoost``, and ``LightGBM``. The :class:`DetectorTraining` resembles the main function to fit any model. After initialisation, diff --git a/docs/usage.rst b/docs/usage.rst index 63a0fcaf..e2b2d6c7 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -16,16 +16,18 @@ To use heiDGAF, just use the provided ``docker-compose.yml`` to quickly bootstra .. code-block:: console - $ docker compose -f docker/docker-compose.yml up + $ HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up If you want to run containers individually, use: .. code-block:: console - $ docker compose -f docker/docker-compose.kafka.yml up + $ HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.kafka.yml up $ docker run ... +Make sure you set the environment variable ``HOST_IP`` to your host's IP address, so that the services can communicate with each other. + Installation ------------ From a5c28d225f07d50e542dc0f45c39cee46587e7ba Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 10:38:40 +0200 Subject: [PATCH 28/62] Update logline format description in configuration.rst --- docs/configuration.rst | 88 ++++++++++++++++++++++++++++++------------ docs/pipeline.rst | 3 +- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index ac26d7f6..5438697d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,63 +1,101 @@ Logline format configuration ............................ -Users can define the format and fields of their DNS server loglines. For this, change the -``pipeline.log_collection.collector.logline_format`` parameter: +Configure the format and validation rules for DNS server loglines through flexible field definitions that +support timestamps, IP addresses, regular expressions, and list-based validation. + +Configuration Overview +^^^^^^^^^^^^^^^^^^^^^^ + +Users can define the format and fields of their DNS server loglines through the +``pipeline.log_collection.collector.logline_format`` parameter. This configuration allows complete customization +of field types, validation rules, and filtering criteria for incoming log data. For example, a logline might look like this: .. code-block:: console - 2025-04-04T14:45:32.458Z NXDOMAIN 192.168.3.152 10.10.0.3 test.com AAAA 192.168.15.34 196b + 2025-04-04T14:45:32.458123Z NXDOMAIN 192.168.3.152 10.10.0.3 test.com AAAA 192.168.15.34 196b + +Field Definition Structure +^^^^^^^^^^^^^^^^^^^^^^^^^^ Each list entry of the parameter defines one field of the input logline, and the order of the entries corresponds to the order of the values in each logline. Each list entry itself consists of a list with -three or four entries: For example, a field definition might look like this: +two to four entries depending on the field type. For example, a field definition might look like this: .. code-block:: console [ "status_code", ListItem, [ "NOERROR", "NXDOMAIN" ], [ "NXDOMAIN" ] ] -The first entry always corresponds to the name of the field. Some field values must exist in the logline, as they are -used by the modules. Some field names are cannot be used, as they are defined for internal communication. +Field Names and Requirements +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The first entry of each field definition always corresponds to the name of the field. Certain field names are required +for proper pipeline operation, while others are forbidden as they are reserved for internal use. .. list-table:: Required and forbidden field names - :header-rows: 0 + :header-rows: 1 :widths: 15 50 - * - Required + * - Category + - Field Names + * - **Required** - ``timestamp``, ``status_code``, ``client_ip``, ``record_type``, ``domain_name`` - * - Forbidden + * - **Forbidden** - ``logline_id``, ``batch_id`` -The second entry specifies the type of the field. Depending on the type defined here, the method for defining the -possible values varies. The third and fourth entry change depending on the type. -Please check the following table for more information on the types. +**Required fields** must be present in the configuration as they are essential for pipeline processing. +**Forbidden fields** are reserved for internal communication and cannot be used as custom field names. + +Field Types and Validation +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The second entry specifies the type of the field. Depending on the type defined, the method for defining +validation parameters varies. The third and fourth entries change depending on the type. -There are three types to choose from: +There are four field types available: .. list-table:: Field types :header-rows: 1 - :widths: 20 20 20 30 + :widths: 20 25 20 35 * - Field type - Format of 3rd entry - Format of 4th entry - Description + * - ``Timestamp`` + - Timestamp format string + - *(not used)* + - Validates timestamp fields using Python's strptime format. Automatically converts to ISO format for internal processing. + Example: ``"%Y-%m-%dT%H:%M:%S.%fZ"`` + * - ``IpAddress`` + - *(not used)* + - *(not used)* + - Validates IPv4 and IPv6 addresses. No additional parameters required. * - ``RegEx`` (Regular Expression) - RegEx pattern as string - - - - The logline field is checked against the pattern. If the pattern is met, the field is valid. + - *(not used)* + - Validates field content against a regular expression pattern. If the pattern matches, the field is valid. * - ``ListItem`` - - List of values - - List of values (optional) - - If the logline field value is in the first list, it is valid. If it is also in the second list, it is relevant - for the inspection and detection algorithm. All values in the second list must also be in the first list, not - vice versa. If this entry is not specified, all values are deemed relevant. - * - ``IpAddress`` - - - - - - If the logline field value is an IPv4 or IPv6 address, it is valid. + - List of allowed values + - List of relevant values *(optional)* + - Validates field values against an allowed list. Optionally defines relevant values for filtering in later pipeline stages. + All relevant values must also be in the allowed list. If not specified, all allowed values are deemed relevant. + +Configuration Examples +^^^^^^^^^^^^^^^^^^^^^ + +Here are examples for each field type: + +.. code-block:: yaml + + logline_format: + - [ "timestamp", Timestamp, "%Y-%m-%dT%H:%M:%S.%fZ" ] + - [ "status_code", ListItem, [ "NOERROR", "NXDOMAIN" ], [ "NXDOMAIN" ] ] + - [ "client_ip", IpAddress ] + - [ "domain_name", RegEx, '^(?=.{1,253}$)((?!-)[A-Za-z0-9-]{1,63}(? Date: Mon, 6 Oct 2025 14:33:53 +0200 Subject: [PATCH 29/62] Update pipeline.rst for Stage 3: Log Filtering --- docs/configuration.rst | 3 -- docs/pipeline.rst | 89 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 5438697d..a414cf5c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -255,9 +255,6 @@ The following parameters control the infrastructure of the software. * - Parameter - Default Value - Description - * - timestamp_format - - ``"%Y-%m-%dT%H:%M:%S.%fZ"`` - - Timestamp format used by the Inspector. Will be removed soon. * - kafka_brokers - ``hostname: kafka1, port: 8097``, ``hostname: kafka2, port: 8098``, ``hostname: kafka3, port: 8099`` - Hostnames and ports of the Kafka brokers, given as list. diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 16f2713b..2844d503 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -323,12 +323,28 @@ and efficient data processing across different message streams. Stage 3: Log Filtering ====================== -Overview --------- +The Log Filtering stage processes batches from the Log Collection stage and filters out irrelevant entries based on configurable relevance criteria, ensuring only meaningful data proceeds to anomaly detection. + +Core Functionality +------------------ The `Log Filtering` stage is responsible for processing and refining log data by filtering out entries based on -specified error types. This step ensures that only relevant logs are passed on for further analysis, optimizing the -performance and accuracy of subsequent pipeline stages. +relevance criteria defined in the logline format configuration. This step ensures that only relevant logs are passed +on for further analysis, optimizing the performance and accuracy of subsequent pipeline stages. + +Data Processing Pipeline +........................ + +The filtering process operates on complete batches rather than individual loglines, maintaining batch metadata and +timestamps throughout the process. Each batch is processed as a unit, preserving the subnet-based grouping established +in the previous stage. + +Relevance-Based Filtering +......................... + +The filtering mechanism uses the ``check_relevance()`` method from the :class:`LoglineHandler` to determine which +entries should proceed to the next stage. This approach allows for flexible filtering criteria based on field values +defined in the configuration. Main Class ---------- @@ -336,29 +352,68 @@ Main Class .. py:currentmodule:: src.prefilter.prefilter .. autoclass:: Prefilter -The :class:`Prefilter` class serves as the primary component in this stage, handling the extraction and filtering of -log data. - Usage ----- -The :class:`Prefilter` loads data from the Kafka topic ``Prefilter``. It extracts the log entries and applies a filter -to retain only those entries that match the specified error types. These error types are provided as a list of strings -during the initialization of a :class:`Prefilter` instance. +Data Flow and Processing +........................ + +The :class:`Prefilter` consumes batches from the Kafka topic ``batch_sender_to_prefilter`` and processes them through +the following workflow: + +1. **Batch Reception**: Receives complete batches with metadata (batch_id, begin_timestamp, end_timestamp, subnet_id) +2. **Relevance Filtering**: Applies relevance checks to each logline within the batch +3. **Monitoring**: Tracks filtered and unfiltered data counts for monitoring purposes +4. **Batch Forwarding**: Sends filtered batches to the ``prefilter_to_inspector`` topic -Once the filtering process is complete, the refined data is sent back to the Kafka Brokers under the topic ``Inspect`` -for further processing in subsequent stages. +Filtering Logic +............... + +The filtering process: + +- Retains loglines that pass the relevance check defined by ``ListItem`` field configurations +- Discards irrelevant loglines and marks them as "filtered_out" in the monitoring system +- Preserves batch structure and metadata for filtered data +- Handles empty batches gracefully (logs info but does not forward empty data) + +Error Handling +.............. + +The implementation includes robust error handling: + +- **Empty Data**: Logs informational messages when batches contain no data +- **No Filtered Data**: Raises ``ValueError`` when no relevant data remains after filtering +- **Kafka Exceptions**: Continues processing on message fetch exceptions +- **Graceful Shutdown**: Supports ``KeyboardInterrupt`` for clean termination Configuration ------------- -To customize the filtering behavior, the following options in the ``logline_format`` set -in the ``config.yaml`` are used. +Filtering behavior is controlled through the ``logline_format`` configuration in ``config.yaml``: + +- **Relevance Criteria**: + + - For fields of type ``ListItem``, the fourth entry (relevant_list) defines which values are considered relevant + - If no relevant_list is specified, all allowed values are deemed relevant + - Multiple fields can have relevance criteria, and all must pass for a logline to be retained + +- **Example Configuration**: + + .. code-block:: yaml + + logline_format: + - [ "status_code", ListItem, [ "NOERROR", "NXDOMAIN" ], [ "NXDOMAIN" ] ] # Only NXDOMAIN relevant + - [ "record_type", ListItem, [ "A", "AAAA" ] ] # A and AAAA relevant + +Monitoring and Metrics +........................ -- **Relevant Types**: +The :class:`Prefilter` provides comprehensive monitoring: - - If the fourth entry of the field configuration with type ``ListItem`` in the ``logline_format`` list is defined for - any field name, the values in this list are the relevant values. +- **Batch Processing**: Tracks batch timestamps and processing status +- **Fill Levels**: Monitors data volumes before and after filtering +- **Logline Tracking**: Records "filtered_out" status for individual loglines +- **Performance Metrics**: Logs processing statistics for each batch Stage 4: Inspection From f22cd30477600e629bf2a2523bd8441c6dd865f9 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 14:52:50 +0200 Subject: [PATCH 30/62] Update references and underlines in configuration.rst and pipeline.rst --- docs/configuration.rst | 2 +- docs/pipeline.rst | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index a414cf5c..1796c66a 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -84,7 +84,7 @@ There are four field types available: All relevant values must also be in the allowed list. If not specified, all allowed values are deemed relevant. Configuration Examples -^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^ Here are examples for each field type: diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 2844d503..6962882e 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -80,7 +80,7 @@ address is retrieved from the logline and used to create the ``subnet_id`` with in the configuration. Advanced Features -................ +................. The functionality of the buffer system is detailed in the subsection :ref:`Buffer Functionality`. This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in later pipeline stages. @@ -197,7 +197,7 @@ The :class:`BufferedBatch` manages the buffering of validated loglines as well a - Collects log entries into a ``batch`` dictionary, with the ``subnet_id`` as key. - Uses a ``buffer`` per key to concatenate and send both the current and previous batches together. - This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in - :ref:`Stage 4: Data Inspection` and :ref:`Stage 5: Data Analysis`. + :ref:`Stage 4: Inspection` and :ref:`Stage 5: Detection`. - All batches get sorted by their timestamps at completion to ensure correct chronological order. - A `begin_timestamp` and `end_timestamp` per key are extracted and sent as metadata (needed for analysis). These are taken from the chronologically first and last message in a batch. @@ -406,7 +406,7 @@ Filtering behavior is controlled through the ``logline_format`` configuration in - [ "record_type", ListItem, [ "A", "AAAA" ] ] # A and AAAA relevant Monitoring and Metrics -........................ +...................... The :class:`Prefilter` provides comprehensive monitoring: @@ -417,7 +417,7 @@ The :class:`Prefilter` provides comprehensive monitoring: Stage 4: Inspection -======================== +=================== Overview -------- From 5941c399c8475a4f02e9cf9bb2f4c632d1ba78f9 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 15:00:06 +0200 Subject: [PATCH 31/62] Update docstrings for prefilter.py --- src/prefilter/prefilter.py | 59 ++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/prefilter/prefilter.py b/src/prefilter/prefilter.py index 2703b306..b05f63c7 100644 --- a/src/prefilter/prefilter.py +++ b/src/prefilter/prefilter.py @@ -36,9 +36,11 @@ class Prefilter: - """ - Loads the data from the topic ``Prefilter`` and filters it so that only entries with the given status type(s) are - kept. Filtered data is then sent using topic ``Inspect``. + """Main component of the Log Filtering stage to process and filter batches + + Consumes batches from the Log Collection stage and applies relevance-based filtering + using the :class:`LoglineHandler`. Filters out irrelevant loglines and forwards only relevant + data to the next pipeline stage for anomaly detection. """ def __init__(self): @@ -69,9 +71,11 @@ def __init__(self): ) def get_and_fill_data(self) -> None: - """ - Clears data already stored and consumes new data. Unpacks the data and checks if it is empty. Data is stored - internally, including timestamps. + """Retrieves and processes a new batch from the configured Kafka topic. + + Clears any previously stored data and consumes a new batch message. Unpacks the batch + data including metadata (batch_id, timestamps, subnet_id) and stores it internally. + Logs batch reception information and updates monitoring metrics for tracking purposes. """ self.clear_data() # clear in case we already have data stored @@ -117,9 +121,12 @@ def get_and_fill_data(self) -> None: ) def filter_by_error(self) -> None: - """ - Applies the filter to the data in ``unfiltered_data``, i.e. all loglines whose error status is in - the given error types are kept and added to ``filtered_data``, all other ones are discarded. + """Applies relevance-based filtering to the unfiltered batch data. + + Iterates through all loglines in the unfiltered data and applies the relevance check + using the :class:`LoglineHandler`. Relevant loglines are added to the filtered data, while + irrelevant ones are discarded and marked as "filtered_out" in the monitoring system. + Updates fill level metrics to track filtering progress. """ for e in self.unfiltered_data: if self.logline_handler.check_relevance(e): @@ -146,9 +153,15 @@ def filter_by_error(self) -> None: ) ) - def send_filtered_data(self): - """ - Sends the filtered data if available via the :class:`KafkaProduceHandler`. + def send_filtered_data(self) -> None: + """Sends the filtered batch data to the next pipeline stage via Kafka. + + Creates a properly formatted batch message with metadata and sends it to the + configured output topic. Updates batch processing status and resets fill level + metrics. Logs detailed statistics about the filtering results. + + Raises: + ValueError: If no filtered data is available to send. """ if not self.filtered_data: raise ValueError("Failed to send data: No filtered data.") @@ -192,24 +205,26 @@ def send_filtered_data(self): f"{len(self.unfiltered_data)} message(s). Belongs to subnet_id '{self.subnet_id}'." ) - def clear_data(self): - """Clears the data in the internal data structures.""" + def clear_data(self) -> None: + """Clears all data from the internal data structures. + + Resets both unfiltered_data and filtered_data lists to empty state, + preparing for the next batch processing cycle. + """ self.unfiltered_data = [] self.filtered_data = [] def main(one_iteration: bool = False) -> None: - """ - Runs the main loop by - - 1. Retrieving new data, - 2. Filtering the data and - 3. Sending the filtered data if not empty. + """Creates the :class:`Prefilter` instance and runs the main processing loop. - Stops by a ``KeyboardInterrupt``, any internal data is lost. + Continuously processes batches by retrieving data, applying filters, and sending + filtered results. The loop handles various exceptions gracefully and supports + clean shutdown via KeyboardInterrupt. Args: - one_iteration (bool): Only one iteration is done if True (for testing purposes). False by default. + one_iteration (bool): If True, only processes one batch and exits. + Used primarily for testing purposes. Default: False """ prefilter = Prefilter() From ceac86d941b327eaa7cba3757759d7047d6434c0 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 15:14:22 +0200 Subject: [PATCH 32/62] Update docstrings for logline_handler.py --- src/base/logline_handler.py | 162 +++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/src/base/logline_handler.py b/src/base/logline_handler.py index 10dad490..fa8c3cf5 100644 --- a/src/base/logline_handler.py +++ b/src/base/logline_handler.py @@ -22,29 +22,38 @@ class FieldType: - """ - Base class for types of fields. + """Base class for all field validation types in the logline format configuration. + + Provides the common interface for field validation. All specific field types inherit + from this class and implement their own validation logic in the :meth:`validate` method. """ def __init__(self, name: str): self.name = name def validate(self, value) -> bool: - """ - Validates the input value. Implementation in inheriting classes. + """Validates the input value according to the field type's rules. + + This method must be implemented by all inheriting field type classes. + Each implementation defines specific validation logic appropriate for the field type. Args: - value: The value to be validated + value: The value to be validated. + + Returns: + True if the value is valid according to the field type's rules, False otherwise. Raises: - NotImplementedError + NotImplementedError: This base method must be overridden by subclasses. """ raise NotImplementedError class RegEx(FieldType): - """ - A :cls:`RegEx` object takes a name, and a pattern, which a value needs to have the format of. + """Field type for regular expression pattern validation + + Validates field values against a specified regular expression pattern. + Useful for validating structured text fields like domain names, sizes, or custom formats. """ def __init__(self, name: str, pattern: str): @@ -52,21 +61,22 @@ def __init__(self, name: str, pattern: str): self.pattern = re.compile(r"{}".format(pattern)) def validate(self, value) -> bool: - """ - Validates the input value. + """Validates the input value against the configured regular expression pattern. Args: - value: The value to be validated + value: The value to be validated against the regex pattern. Returns: - True if the value is valid, False otherwise + True if the value matches the pattern, False otherwise. """ return True if re.match(self.pattern, value) else False class Timestamp(FieldType): - """ - A :cls:`Timestamp` object takes a name, and a timestamp format, which a value needs to have. + """Field type for timestamp validation and parsing + + Validates timestamp fields according to a specified format string and provides + functionality to convert valid timestamps to ISO format for internal processing. """ def __init__(self, name: str, timestamp_format: str): @@ -74,14 +84,13 @@ def __init__(self, name: str, timestamp_format: str): self.timestamp_format = timestamp_format def validate(self, value) -> bool: - """ - Validates the input value. + """Validates the input value against the configured timestamp format. Args: - value: The value to be validated + value: The timestamp string to be validated. Returns: - True if the value is valid, False otherwise + True if the value matches the timestamp format, False otherwise. """ try: datetime.datetime.strptime(value, self.timestamp_format) @@ -91,36 +100,35 @@ def validate(self, value) -> bool: return True def get_timestamp_as_str(self, value) -> str: - """ - Returns the timestamp as string for a given timestamp with valid format. + """Converts a valid timestamp to ISO format string. Args: - value: Correctly formatted timestamp according to self.timestamp_format + value: Correctly formatted timestamp according to self.timestamp_format. Returns: - String of the given timestamp with standard format + ISO formatted timestamp string for internal processing. """ return str(datetime.datetime.strptime(value, self.timestamp_format).isoformat()) class IpAddress(FieldType): - """ - An :cls:`IpAddress` object takes only a name. It is used for IP addresses, and checks in the :meth:`validate` method - if the value is a correct IP address. + """Field type for IP address validation + + Validates both IPv4 and IPv6 addresses using the utility validation functions. + No additional configuration parameters are required beyond the field name. """ def __init__(self, name): super().__init__(name) def validate(self, value) -> bool: - """ - Validates the input value. + """Validates the input value as a valid IP address. Args: - value: The value to be validated + value: The IP address string to be validated. Returns: - True if the value is valid, False otherwise + True if the value is a valid IPv4 or IPv6 address, False otherwise. """ try: validate_host(value) @@ -131,11 +139,12 @@ def validate(self, value) -> bool: class ListItem(FieldType): - """ - A :cls:`ListItem` object takes a name, and two lists: The - ``allowed_list`` contains all values, that the :cls:`ListItem` is allowed to have, and therefore are not sorted out. - The ``relevant_list`` must contain fields that are also in ``allowed_list`` and that are relevant for further - inspection. These are filtered in the Prefilter stage. + """Field type for list-based validation with optional relevance filtering + + Validates field values against an allowed list and optionally defines which values + are considered relevant for filtering in later pipeline stages. The allowed_list + contains all valid values, while the optional relevant_list defines a subset + used for relevance-based filtering in the Log Filtering stage. """ def __init__(self, name: str, allowed_list: list, relevant_list: list): @@ -148,26 +157,24 @@ def __init__(self, name: str, allowed_list: list, relevant_list: list): self.relevant_list = relevant_list def validate(self, value) -> bool: - """ - Validates the input value. + """Validates the input value against the allowed list. Args: - value: The value to be validated + value: The value to be validated. Returns: - True if the value is valid, False otherwise + True if the value is in the allowed_list, False otherwise. """ return True if value in self.allowed_list else False def check_relevance(self, value) -> bool: - """ - Checks if the given value is a relevant value. + """Checks if the given value is considered relevant for filtering. Args: - value: Value to be checked for relevance + value: Value to be checked for relevance. Returns: - True if the value is relevant, else False + True if the value is relevant (in relevant_list or if no relevant_list is defined), False otherwise. """ if self.relevant_list: return True if value in self.relevant_list else False @@ -176,9 +183,11 @@ def check_relevance(self, value) -> bool: class LoglineHandler: - """ - Stores the configuration format of loglines and can be used to validate a given logline, i.e. checks if the given - logline has the format given in the configuration. Can also return the validated logline as dictionary. + """Main handler for logline validation and processing + + Manages the configuration-based validation of loglines according to the format + specified in the configuration file. Provides validation, field extraction, + and relevance checking functionality for the log processing pipeline. """ def __init__(self): @@ -210,16 +219,17 @@ def __init__(self): raise ValueError("No fields configured") def validate_logline(self, logline: str) -> bool: - """ - Validates the given input logline by checking if the number of fields is correct as well as all the fields, by - calling the :meth:`validate` method of each field. If the logline is incorrect, it shows an error with the - incorrect fields being highlighted. + """Validates a complete logline according to the configured format. + + Checks if the number of fields is correct and validates each field using + the appropriate field type validator. Provides detailed error logging + with visual indicators for incorrect fields. Args: - logline (str): Logline as string to be validated + logline (str): Logline string to be validated. Returns: - True if the logline contains correct fields in the configured format, False otherwise + True if the logline contains correct fields in the configured format, False otherwise. """ parts = logline.split() number_of_entries = len(parts) @@ -253,15 +263,17 @@ def validate_logline(self, logline: str) -> bool: return True def __get_fields_as_json(self, logline: str) -> dict: - """ - Returns the fields of the given logline as dictionary, with the names of the fields as key, and the field value - as value. Does not validate fields. + """Extracts fields from a logline and returns them as a dictionary. + + Parses the logline into individual fields and creates a dictionary with + field names as keys and field values as values. Handles timestamp conversion + to ISO format for internal processing. Does not perform validation. Args: - logline (str): Logline to get the fields from + logline (str): Logline to extract fields from. Returns: - Dictionary of field names as keys and field values as value + Dictionary with field names as keys and field values as values. """ parts = logline.split() return_dict = {} @@ -277,14 +289,19 @@ def __get_fields_as_json(self, logline: str) -> dict: return return_dict.copy() def validate_logline_and_get_fields_as_json(self, logline: str) -> dict: - """Validates the fields and returns them as dictionary, with the names of the fields as key, and the field - value as value. + """Validates a logline and returns the fields as a dictionary. + + Combines validation and field extraction in a single operation. + First validates the logline format, then extracts and returns the fields. Args: - logline (str): Logline as string to be validated + logline (str): Logline string to be validated and parsed. Returns: - Dictionary of field names as keys and field values as value + Dictionary with field names as keys and field values as values. + + Raises: + ValueError: If logline validation fails. """ if not self.validate_logline(logline): raise ValueError("Incorrect logline, validation unsuccessful") @@ -292,14 +309,17 @@ def validate_logline_and_get_fields_as_json(self, logline: str) -> dict: return self.__get_fields_as_json(logline) def check_relevance(self, logline_dict: dict) -> bool: - """ - Checks if the given logline is relevant. + """Checks if a logline is relevant based on configured relevance criteria. + + Iterates through all ListItem fields and checks their relevance using + the check_relevance method. A logline is considered relevant only if + all ListItem fields pass their relevance checks. Args: - logline_dict (dict): Logline parts to be checked for relevance as dictionary + logline_dict (dict): Logline fields as dictionary to be checked for relevance. Returns: - True if the logline is relevant, else False + True if the logline is relevant according to all configured criteria, False otherwise. """ relevant = True @@ -316,14 +336,20 @@ def check_relevance(self, logline_dict: dict) -> bool: @staticmethod def _create_instance_from_list_entry(field_list: list): - """ - Extracts the information from the ``field_list`` to generate one instance of the specified type. + """Creates a field type instance from configuration list entry. + + Parses the configuration format and creates the appropriate field type instance + based on the specified class name and parameters. Supports RegEx, Timestamp, + ListItem, and IpAddress field types with their respective parameter requirements. Args: - field_list (list): List of field name, type and additional fields + field_list (list): Configuration list containing field name, type, and parameters. Returns: - Generated instance with the name, type and additional parameters given in the ``field_list`` + Field type instance configured according to the specification. + + Raises: + ValueError: If the field configuration is invalid or unsupported. """ len_of_field_list = len(field_list) From 5d23967c01619482a9955f36635ef449b6edb77f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:03 +0200 Subject: [PATCH 33/62] Update docstrings for clickhouse_kafka_sender.py --- src/base/clickhouse_kafka_sender.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/base/clickhouse_kafka_sender.py b/src/base/clickhouse_kafka_sender.py index e3f38471..514b1f60 100644 --- a/src/base/clickhouse_kafka_sender.py +++ b/src/base/clickhouse_kafka_sender.py @@ -17,9 +17,21 @@ class ClickHouseKafkaSender: - """Sends insert operations for the specified table via Kafka to the MonitoringAgent.""" + """Sends insert operations for the specified table via Kafka to the MonitoringAgent. + + The ClickHouseKafkaSender serves as a Kafka producer that encapsulates database insert + operations into Kafka messages. It automatically handles data schema validation and + serialization for the specified ClickHouse table. + """ def __init__(self, table_name: str): + """ + Args: + table_name (str): Name of the ClickHouse table to send insert operations for. + + Raises: + KeyError: If the specified table name is not found in TABLE_NAME_TO_TYPE mapping. + """ self.table_name = table_name self.kafka_producer = SimpleKafkaProduceHandler() self.data_schema = marshmallow_dataclass.class_schema( @@ -27,11 +39,18 @@ def __init__(self, table_name: str): )() def insert(self, data: dict): - """ - Produces the insert operation to Kafka. + """Produces the insert operation to Kafka for ClickHouse insertion. + + Validates the provided data against the table schema, serializes it, and sends + it to the appropriate Kafka topic for processing by the MonitoringAgent. Args: - data (dict): content to write into the Kafka queue + data (dict): Dictionary containing the data to insert into ClickHouse. + Must conform to the table's schema structure. + + Raises: + marshmallow.ValidationError: If the data does not conform to the table schema. + KafkaException: If the Kafka message cannot be produced. """ self.kafka_producer.produce( topic=f"clickhouse_{self.table_name}", From fe5f545a851004160d39f77d7757a74e9aad9bd5 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:10 +0200 Subject: [PATCH 34/62] Update docstrings for utils.py --- src/base/utils.py | 95 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/src/base/utils.py b/src/base/utils.py index 8ae55cf5..6aa20b57 100644 --- a/src/base/utils.py +++ b/src/base/utils.py @@ -1,6 +1,7 @@ import ipaddress import os import sys +from typing import Optional import yaml from confluent_kafka import KafkaError, Message @@ -14,14 +15,20 @@ def setup_config(): - """ - Loads the configuration data from the configuration file and returns it as the corresponding Python object. + """Load and return the application configuration from the YAML configuration file. + + Reads the configuration file from the predefined CONFIG_FILEPATH and parses + it as a YAML document. This function provides centralized configuration + loading for the entire application. Returns: - Configuration data as corresponding Python object + dict: Configuration data as a Python dictionary containing all + application settings and parameters. Raises: - FileNotFoundError: Configuration file could not be opened + FileNotFoundError: If the configuration file does not exist at the + expected path. + yaml.YAMLError: If the configuration file contains invalid YAML syntax. """ try: logger.debug(f"Opening configuration file at {CONFIG_FILEPATH}...") @@ -38,17 +45,23 @@ def setup_config(): def validate_host( host: int | str | bytes | ipaddress.IPv4Address | ipaddress.IPv6Address, ) -> ipaddress.IPv4Address | ipaddress.IPv6Address: - """ - Checks if the given host is a valid IP address. If it is, the IP address is returned with IP address type. + """Validate and normalize a host IP address. + + Accepts various input formats for IP addresses and validates them using + the ipaddress module. Returns a properly typed IP address object for + further processing. Args: - host (int | str | bytes | IPv4Address | IPv6Address): Host IP address to be checked + host (int | str | bytes | IPv4Address | IPv6Address): Host IP address + in any supported format (string, integer, bytes, or existing + IP address object). Returns: - Correct IP address as ipaddress.IPv4Address or ipaddress.IPv6Address type. + ipaddress.IPv4Address | ipaddress.IPv6Address: Validated IP address + object with the appropriate type. Raises: - ValueError: Invalid host IP address format + ValueError: If the provided host is not a valid IP address format. """ logger.debug(f"Validating host IP address {host}...") try: @@ -61,18 +74,21 @@ def validate_host( def validate_port(port: int) -> int: - """ - Checks if the given port number is in the valid port number range. If it is, the port is returned. + """Validate that a port number is within the valid range. + + Checks if the provided port number is an integer and falls within + the valid TCP/UDP port range (1-65535). Returns the validated port + number if valid. Args: - port (int): Port number to be checked + port (int): Port number to validate. Returns: - Validated port number as integer + int: Validated port number. Raises: - ValueError: Port number not in valid port number range - TypeError: Invalid type for port number, must be int + TypeError: If port is not an integer. + ValueError: If port number is not in the valid range (1-65535). """ logger.debug(f"Validating port {port}...") if not isinstance(port, int): @@ -85,9 +101,16 @@ def validate_port(port: int) -> int: return port -def kafka_delivery_report(err: None | KafkaError, msg: None | Message): - """ - Delivery report used by Kafka Producers. Specifies the format of the returned messages during producing. +def kafka_delivery_report(err: Optional[KafkaError], msg: Optional[Message]): + """Callback function for Kafka message delivery reports + + Used by Kafka Producers to handle delivery confirmations and errors. + Logs successful deliveries with topic and partition information, and + warns about delivery failures. + + Args: + err (Optional[KafkaError]): Error object if delivery failed, None if successful. + msg (Optional[Message]): Message object containing delivery details, None if error. """ if err: logger.warning("Message delivery failed: {}".format(err)) @@ -102,15 +125,23 @@ def kafka_delivery_report(err: None | KafkaError, msg: None | Message): def normalize_ipv4_address( address: ipaddress.IPv4Address, prefix_length: int ) -> tuple[ipaddress.IPv4Address, int]: - """ - Returns the first part of an IPv4 address, the rest is filled with 0. + """Extract the network portion of an IPv4 address using the specified prefix length. + + Creates a subnet identifier by zeroing out the host portion of the IP address + based on the provided prefix length. This is useful for network analysis + and grouping IP addresses by subnet. Args: - address (ipaddress.IPv4Address): The IPv4 address to get the subnet ID of - prefix_length (int): Prefix length to be used for the subnet ID + address (ipaddress.IPv4Address): The IPv4 address to normalize. + prefix_length (int): CIDR prefix length (0-32) defining the network portion. Returns: - Subnet ID of the given IP address + tuple[ipaddress.IPv4Address, int]: A tuple containing: + - Network address with host bits set to zero. + - The prefix length used for normalization. + + Raises: + ValueError: If prefix_length is not in the valid range (0-32). """ if not (0 <= prefix_length <= 32): raise ValueError("Invalid prefix length for IPv4. Must be between 0 and 32.") @@ -122,15 +153,23 @@ def normalize_ipv4_address( def normalize_ipv6_address( address: ipaddress.IPv6Address, prefix_length: int ) -> tuple[ipaddress.IPv6Address, int]: - """ - Returns the first part of an IPv6 address, the rest is filled with 0. + """Extract the network portion of an IPv6 address using the specified prefix length. + + Creates a subnet identifier by zeroing out the host portion of the IP address + based on the provided prefix length. This is useful for network analysis + and grouping IPv6 addresses by subnet. Args: - address (ipaddress.IPv6Address): The IPv6 address to get the subnet ID of - prefix_length (int): Prefix length to be used for the subnet ID + address (ipaddress.IPv6Address): The IPv6 address to normalize. + prefix_length (int): CIDR prefix length (0-128) defining the network portion. Returns: - Subnet ID of the given IP address + tuple[ipaddress.IPv6Address, int]: A tuple containing: + - Network address with host bits set to zero. + - The prefix length used for normalization. + + Raises: + ValueError: If prefix_length is not in the valid range (0-128). """ if not (0 <= prefix_length <= 128): raise ValueError("Invalid prefix length for IPv6. Must be between 0 and 128.") From df686beaeec5fbc4fd7590e8e7e8935df2603512 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:17 +0200 Subject: [PATCH 35/62] Update docstrings for log_config.py --- src/base/log_config.py | 46 +++++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/base/log_config.py b/src/base/log_config.py index 3179efcc..6e99694d 100644 --- a/src/base/log_config.py +++ b/src/base/log_config.py @@ -32,20 +32,25 @@ class CustomHandler(logging.StreamHandler): - """ - Handles the different styles of logging messages with respect to their level. + """Custom logging handler that applies different formatting based on log level + + Provides level-specific message formatting where INFO and WARNING messages + use a simplified format, while DEBUG, ERROR, and CRITICAL messages include + detailed context information such as module name, line number, and function name. """ def format(self, record) -> str: - """ - Formats the data with respect to the level. Uses the simple format for INFO and WARNING messages, - for all other levels, the detailed format is used. + """Format log records with level-appropriate detail. + + Applies simple formatting for INFO and WARNING messages, and detailed + formatting (including module, line number, and function name) for all + other log levels. Args: - record: record to be formatted + record: The log record to format. Returns: - formatted logging info + str: Formatted log message string. """ if record.levelno in (logging.INFO, logging.WARNING): return simple_formatter.format(record) @@ -53,7 +58,15 @@ def format(self, record) -> str: def load_config() -> Dict[str, Any]: - """Loads the configuration file.""" + """Load the application configuration from the YAML configuration file. + + Returns: + Dict[str, Any]: Parsed configuration data as a dictionary. + + Raises: + FileNotFoundError: If the configuration file cannot be found at the expected path. + yaml.YAMLError: If the configuration file contains invalid YAML syntax. + """ try: with open(CONFIG_FILEPATH, "r") as file: return yaml.safe_load(file) @@ -62,14 +75,23 @@ def load_config() -> Dict[str, Any]: def get_logger(module_name: str = "base") -> logging.Logger: - """ - Creates or retrieves a logger for a specific module. + """Create or retrieve a configured logger for a specific module. + + Sets up a logger with custom formatting and debug level configuration + based on the module-specific settings in config.yaml. If no module-specific + configuration exists, falls back to the base module settings. Args: - module_name (str): Name of the module (as defined in config.yaml) + module_name (str): Name of the module to create a logger for. + Must match a module defined in config.yaml ``logging.modules``. + Default: "base". Returns: - Configured logger for the module + logging.Logger: Configured logger instance for the specified module. + + Raises: + FileNotFoundError: If the configuration file cannot be loaded. + KeyError: If the configuration structure is invalid. """ config = load_config() logger = logging.getLogger(module_name) From e9db495d1c359f050dc150b4683ad8f7e3b3d22a Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:27 +0200 Subject: [PATCH 36/62] Update docstrings for logline_handler.py --- src/base/logline_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/base/logline_handler.py b/src/base/logline_handler.py index fa8c3cf5..16f0ffb4 100644 --- a/src/base/logline_handler.py +++ b/src/base/logline_handler.py @@ -22,7 +22,7 @@ class FieldType: - """Base class for all field validation types in the logline format configuration. + """Base class for all field validation types in the logline format configuration Provides the common interface for field validation. All specific field types inherit from this class and implement their own validation logic in the :meth:`validate` method. From ce68ef98f1a67a45e87fbaae689a4a1fb430cb1e Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:39 +0200 Subject: [PATCH 37/62] Update docstrings for kafka_handler.py --- src/base/kafka_handler.py | 303 +++++++++++++++++++++++++++++--------- 1 file changed, 237 insertions(+), 66 deletions(-) diff --git a/src/base/kafka_handler.py b/src/base/kafka_handler.py index 29d61a51..5953ff7e 100644 --- a/src/base/kafka_handler.py +++ b/src/base/kafka_handler.py @@ -9,6 +9,7 @@ import sys import time from abc import abstractmethod +from typing import Optional import marshmallow_dataclass from confluent_kafka import ( @@ -35,59 +36,99 @@ class TooManyFailedAttemptsError(Exception): - """ - Exception for too many failed attempts. + """Exception raised when operations exceed the maximum number of retry attempts + + This exception is typically raised during Kafka topic creation or connection + establishment when the maximum number of retry attempts has been exceeded. """ pass class KafkaMessageFetchException(Exception): - """ - Exception for failed fetch of Kafka messages during consuming. + """Exception raised when Kafka message consumption fails + + This exception is raised when there are errors during the process of fetching + or consuming messages from Kafka topics, including network issues, timeout + errors, or malformed message data. """ pass class KafkaHandler: - """ - Base class for all Kafka wrappers. Only specifies the initialization. + """Base class for all Kafka wrappers and handlers + + Provides common initialization and configuration setup for Kafka producers + and consumers. This abstract base class establishes the foundation for + specific Kafka handling implementations. """ def __init__(self) -> None: """ - Initializes the broker configuration. + Sets up the initial configuration and initializes the consumer attribute + to None. Specific implementations should override this method to establish + their respective Kafka clients. """ self.consumer = None class KafkaProduceHandler(KafkaHandler): - """ - Base class for Kafka Producer wrappers. + """Abstract base class for Kafka Producer wrappers + + Extends KafkaHandler to provide producer-specific functionality. This class + establishes the interface for Kafka message production with different + semantic guarantees (simple vs exactly-once). """ def __init__(self, conf): + """ + Args: + conf (dict): Configuration dictionary for the Kafka producer. + Should contain broker settings and producer-specific options. + """ super().__init__() self.producer = Producer(conf) @abstractmethod def produce(self, *args, **kwargs): - """ - Encodes the given data for transport and sends it on the specified topic. + """Abstract method for producing messages to Kafka topics + + Encodes the given data for transport and sends it to the specified topic. + Implementations must define the specific behavior for message production. + + Args: + *args: Variable arguments depending on implementation. + **kwargs: Keyword arguments depending on implementation. + + Raises: + NotImplementedError: This method must be implemented by subclasses. """ raise NotImplementedError def __del__(self) -> None: + """Cleanup method called when the object is destroyed + + Ensures that all pending messages are flushed before the producer + is destroyed, preventing message loss. + """ self.producer.flush() class SimpleKafkaProduceHandler(KafkaProduceHandler): - """ - Simple wrapper for the Kafka Producer without Write-Exactly-Once semantics. + """Simple Kafka Producer wrapper without Write-Exactly-Once semantics + + Provides basic message production capabilities with at-least-once delivery + guarantees. This implementation prioritizes simplicity and performance over + strict consistency guarantees. """ def __init__(self): + """ + Sets up a Kafka producer with standard configuration for simple message + production without transactional guarantees. Broker addresses are + automatically configured from the global KAFKA_BROKERS setting. + """ self.brokers = ",".join( [f"{broker['hostname']}:{broker['port']}" for broker in KAFKA_BROKERS] ) @@ -101,13 +142,21 @@ def __init__(self): super().__init__(conf) def produce(self, topic: str, data: str, key: None | str = None) -> None: - """ - Encodes the given data for transport and sends it on the specified topic. + """Produce a message to the specified Kafka topic. + + Encodes and sends the provided data to the specified topic. The producer + is flushed before sending to ensure message delivery. Empty data is + silently ignored. Args: - topic (str): Topic to send the data with - data (str): Data to be sent - key (str): Key to send the data with + topic (str): Target Kafka topic name. + data (str): Message data to send (ignored if empty). + key (str, optional): Optional message key for partitioning. + Default: None. + + Raises: + KafkaException: If message production fails. + BufferError: If the producer's message buffer is full. """ if not data: return @@ -122,11 +171,29 @@ def produce(self, topic: str, data: str, key: None | str = None) -> None: class ExactlyOnceKafkaProduceHandler(KafkaProduceHandler): - """ - Wrapper for the Kafka Producer with Write-Exactly-Once semantics. + """Kafka Producer wrapper with Write-Exactly-Once semantics + + Provides transactional message production with exactly-once delivery + guarantees. This implementation ensures that messages are delivered + exactly once, even in the presence of failures and retries. + + Configuration: + - transactional.id: Set to HOSTNAME for unique transaction identification + - enable.idempotence: True (required for exactly-once semantics) + + Note: + Each instance must have a unique transactional.id to avoid conflicts. """ def __init__(self): + """ + Sets up a Kafka producer with transactional capabilities for exactly-once + semantics. The producer is initialized with transactions enabled and + configured with a unique transactional ID based on the hostname. + + Raises: + KafkaException: If transaction initialization fails. + """ self.brokers = ",".join( [f"{broker['hostname']}:{broker['port']}" for broker in KAFKA_BROKERS] ) @@ -141,17 +208,21 @@ def __init__(self): self.producer.init_transactions() def produce(self, topic: str, data: str, key: None | str = None) -> None: - """ - Encodes the given data for transport and sends it with the specified topic. + """Produce a message to the specified Kafka topic with exactly-once semantics. + + Sends the provided data within a Kafka transaction to ensure exactly-once + delivery. The transaction is automatically committed on success or aborted + on failure. Empty data is silently ignored. Args: - topic (str): Topic to send the data with. - data (str): Data to be sent. - key (str): Key to send the data with. + topic (str): Target Kafka topic name. + data (str): Message data to send (ignored if empty). + key (str, optional): Optional message key for partitioning. + Default: None. Raises: - Exception: During :meth:`commit_transaction_with_retry()` or Producer's ``produce()``. Aborts - transaction then. + KafkaException: If message production or transaction handling fails. + RuntimeError: If transaction commit fails after retries. """ if not data: return @@ -176,13 +247,21 @@ def produce(self, topic: str, data: str, key: None | str = None) -> None: def commit_transaction_with_retry( self, max_retries: int = 3, retry_interval_ms: int = 1000 ) -> None: - """ - Commits a transaction including retries. If committing fails, it is retried after the given retry interval - time up to ``max_retries`` times. + """Commit a Kafka transaction with automatic retry logic. + + Attempts to commit the current transaction with built-in retry mechanism + for handling transient failures. If committing fails due to conflicting + API calls, the method will retry after the specified interval. Args: - max_retries (int): Maximum number of retries - retry_interval_ms (int): Interval between retries in ms + max_retries (int): Maximum number of commit retry attempts. Default: 3. + retry_interval_ms (int): Time to wait between retries in milliseconds. + Default: 1000. + + Raises: + KafkaException: If transaction commit fails for reasons other than + conflicting API calls. + RuntimeError: If transaction commit fails after all retry attempts. """ committed = False retry_count = 0 @@ -209,11 +288,30 @@ def commit_transaction_with_retry( class KafkaConsumeHandler(KafkaHandler): - """ - Base class for Kafka Consumer wrappers. + """Abstract base class for Kafka Consumer wrappers + + Provides common functionality for Kafka message consumption including + topic creation, subscription management, and consumer configuration. + All consumer implementations should extend this class. + + Attributes: + brokers (str): Comma-separated list of Kafka broker addresses. + consumer (Consumer): Confluent Kafka Consumer instance. """ def __init__(self, topics: str | list[str]) -> None: + """ + Creates a Kafka consumer, ensures the specified topics exist, and + subscribes to them. Topics are automatically created if they don't exist. + + Args: + topics (str | list[str]): Topic name(s) to subscribe to. + Can be a single topic string or list of topics. + + Raises: + TooManyFailedAttemptsError: If topic creation fails after retries. + KafkaException: If consumer creation or subscription fails. + """ super().__init__() # get brokers @@ -253,21 +351,34 @@ def __init__(self, topics: str | list[str]) -> None: @abstractmethod def consume(self, *args, **kwargs): - """ - Consumes available messages on the specified topic and decodes it. + """Abstract method for consuming messages from Kafka topics + + Implementations must define the specific behavior for message consumption, + including how to handle message polling, error handling, and data decoding. + + Args: + *args: Variable arguments depending on implementation. + **kwargs: Keyword arguments depending on implementation. + + Raises: + NotImplementedError: This method must be implemented by subclasses. """ raise NotImplementedError def consume_as_json(self) -> tuple[None | str, dict]: - """ - Consumes available messages on the specified topic. Decodes the data and returns the contents in JSON format. - Blocks and waits if no data is available. + """Consume messages and return them in JSON format. + + Consumes available messages from subscribed topics, decodes the data, + and returns the contents as a JSON dictionary. This method blocks + until a message is available. Returns: - Consumed data in JSON format + tuple[None | str, dict]: A tuple containing: + - Message key (str or None) + - Message value as dictionary (empty dict if no message) Raises: - ValueError: Invalid data format + ValueError: If the message data format is invalid or cannot be parsed. """ key, value, topic = self.consume() @@ -284,14 +395,18 @@ def consume_as_json(self) -> tuple[None | str, dict]: except Exception: raise ValueError("Unknown data format") - def _all_topics_created(self, topics) -> bool: - """ - Checks whether each topic in a list of topics was created. If not, retries for a set amount of times + def _all_topics_created(self, topics: list[str]) -> bool: + """Verify that all specified topics have been created successfully. + + Polls the Kafka cluster to check if each topic in the provided list + has been created. Retries for a maximum duration if topics are not + immediately available. Args: - topics (list): List of topics to check + topics (list[str]): List of topic names to verify. + Returns: - bool + bool: True if all topics are created, False if timeout exceeded. """ number_of_retries_left = 30 all_topics_created = False @@ -314,26 +429,49 @@ def _all_topics_created(self, topics) -> bool: return True def __del__(self) -> None: + """Cleanup method called when the object is destroyed + + Properly closes the Kafka consumer connection to release resources + and ensure graceful shutdown. + """ if self.consumer: self.consumer.close() class SimpleKafkaConsumeHandler(KafkaConsumeHandler): - """ - Simple wrapper for the Kafka Consumer without Write-Exactly-Once semantics. + """Simple Kafka Consumer wrapper without Write-Exactly-Once semantics + + Provides basic message consumption capabilities with at-least-once delivery + semantics. Messages are not automatically committed, allowing for manual + offset management by the application. """ - def __init__(self, topics): + def __init__(self, topics: str | list[str]) -> None: + """ + Args: + topics (str | list[str]): Topic name(s) to subscribe to. + """ super().__init__(topics) - def consume(self) -> tuple[str | None, str | None, str | None]: + def consume(self) -> tuple[Optional[str], Optional[str], Optional[str]]: """ - Consumes available messages on the specified topic. Decodes the data and returns a tuple - of key, data and topic of the message. Blocks and waits if no data is available. + Consume messages from subscribed Kafka topics. + + Polls for available messages and decodes them. This method blocks + until a message is available or a keyboard interrupt is received. + The consumer does not automatically commit offsets. Returns: - Either ``[None,None,None]`` if empty data was retrieved or ``[key,value,topic]`` as tuple - of strings of the consumed data. + tuple[Optional[str], Optional[str], Optional[str]]: A tuple containing: + - Message key (str or None) + - Message value (str or None) + - Topic name (str or None) + Returns (None, None, None) if no valid message is retrieved. + + Raises: + ValueError: If the received message is invalid. + KeyboardInterrupt: If consumption is interrupted by user. + KafkaException: If message commit fails. """ empty_data_retrieved = False @@ -366,21 +504,39 @@ def consume(self) -> tuple[str | None, str | None, str | None]: class ExactlyOnceKafkaConsumeHandler(KafkaConsumeHandler): - """ - Wrapper for the Kafka Consumer with Write-Exactly-Once semantics. + """Kafka Consumer wrapper with Write-Exactly-Once semantics + + Provides message consumption with exactly-once processing guarantees. + Messages are automatically committed after successful processing to + ensure each message is processed exactly once. """ def __init__(self, topics: str | list[str]) -> None: + """ + Args: + topics (str | list[str]): Topic name(s) to subscribe to. + """ super().__init__(topics) - def consume(self) -> tuple[str | None, str | None, str | None]: + def consume(self) -> tuple[Optional[str], Optional[str], Optional[str]]: """ - Consumes available messages on the specified topic. Decodes the data and returns a tuple - of key, data and topic of the message. Blocks and waits if no data is available. + Consume messages from subscribed Kafka topics with exactly-once semantics. + + Polls for available messages, decodes them, and automatically commits + the message offset after successful processing. This ensures each + message is processed exactly once. Returns: - Either ``[None,None,None]`` if empty data was retrieved or ``[key,value,topic]`` as tuple - of strings of the consumed data. + tuple[Optional[str], Optional[str], Optional[str]]: A tuple containing: + - Message key (str or None) + - Message value (str or None) + - Topic name (str or None) + Returns (None, None, None) if no valid message is retrieved. + + Raises: + ValueError: If the received message is invalid. + KeyboardInterrupt: If consumption is interrupted by user. + KafkaException: If message commit fails. """ empty_data_retrieved = False @@ -415,18 +571,33 @@ def consume(self) -> tuple[str | None, str | None, str | None]: @staticmethod def _is_dicts(obj): + """Check if the provided object is a list containing only dictionaries. + + Args: + obj: Object to check. + + Returns: + bool: True if obj is a list of dictionaries, False otherwise. + """ return isinstance(obj, list) and all(isinstance(item, dict) for item in obj) - def consume_as_object(self) -> tuple[None | str, Batch]: + def consume_as_object(self) -> tuple[Optional[str], Batch]: """ - Consumes available messages on the specified topic. Decodes the data and converts it to a Batch - object. Returns the Batch object. + Consume messages and return them as Batch objects. + + Consumes available messages from subscribed topics, decodes the data, + and converts it to a structured Batch object using marshmallow schema + validation. This method provides type-safe message consumption. Returns: - Consumed data as Batch object + tuple[Optional[str], Batch]: A tuple containing: + - Message key (str or None). + - Batch object containing the deserialized message data. Raises: - ValueError: Invalid data format + ValueError: If the message data format is invalid or cannot be + converted to a Batch object. + marshmallow.ValidationError: If data doesn't conform to Batch schema. """ key, value, topic = self.consume() From 89a372bdad632a7e6b8e4d93285462c3eeaa7b86 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:46 +0200 Subject: [PATCH 38/62] Update docstrings for inspector.py --- src/inspector/inspector.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/inspector/inspector.py b/src/inspector/inspector.py index c3ebfe6a..a7575800 100644 --- a/src/inspector/inspector.py +++ b/src/inspector/inspector.py @@ -77,7 +77,12 @@ class EnsembleModels(str, Enum): class Inspector: - """Finds anomalies in a batch of requests and produces it to the ``Detector``.""" + """Main component of the Data Inspection stage to detect anomalies in request batches + + Analyzes batches of DNS requests using configurable streaming anomaly detection models. + Processes batches to identify suspicious patterns and forwards anomalous batches to the + Detector for further analysis. Supports both univariate and multivariate detection models. + """ def __init__(self) -> None: self.batch_id = None From 174eb0f8ed90431fd957f1ee4404ae9fc0cc9219 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:08:56 +0200 Subject: [PATCH 39/62] Update docstrings for detector.py --- src/detector/detector.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/detector/detector.py b/src/detector/detector.py index 3ff324fe..fc95a170 100644 --- a/src/detector/detector.py +++ b/src/detector/detector.py @@ -44,8 +44,11 @@ class WrongChecksum(Exception): # pragma: no cover class Detector: - """Logs detection with probability score of requests. It runs the provided machine learning model. - In addition, it returns all individually probabilities of the anomalous batch. + """Main component of the Data Analysis stage to perform anomaly detection + + Processes suspicious batches from the Inspector using machine learning models to detect + anomalous DNS requests. Downloads and validates models, calculates probability scores, and + generates alerts when anomalies are detected above the configured threshold. """ def __init__(self) -> None: From c813b29a3a6609da2a65c8318938db3b6c5f2701 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:16:22 +0200 Subject: [PATCH 40/62] Update docstrings for clickhouse_batch_sender.py --- src/monitoring/clickhouse_batch_sender.py | 59 +++++++++++++++++------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/src/monitoring/clickhouse_batch_sender.py b/src/monitoring/clickhouse_batch_sender.py index 29cc258c..41aa9f98 100644 --- a/src/monitoring/clickhouse_batch_sender.py +++ b/src/monitoring/clickhouse_batch_sender.py @@ -26,17 +26,27 @@ @dataclass class Table: - """Defines the table name and allowed column fields with types.""" + """Defines the table name and allowed column fields with types. + + Stores metadata about ClickHouse table structure including column names + and their expected data types for validation during batch insertion. + """ name: str columns: dict[str, type] def verify(self, data: dict[str, Any]): - """ - Verify if the data has the correct columns and types. + """Verify if the data has the correct columns and types. + + Validates that the provided data dictionary contains the expected columns + with correct data types according to the table schema definition. Args: - data (dict): The values for each cell + data (dict): The values for each cell. + + Raises: + ValueError: If column count or column names don't match expected schema. + TypeError: If data types don't match expected column types. """ if len(data) != len(self.columns): raise ValueError( @@ -63,8 +73,12 @@ def verify(self, data: dict[str, Any]): class ClickHouseBatchSender: - """Manages the batches that store insert commands for each table. After the timer runs out, all batches are sent. - If a batch reaches the maximum size, it is also sent.""" + """Manages batched insert operations for ClickHouse tables. + + Collects insert commands in batches and sends them to ClickHouse when either + the batch size limit is reached or a timeout occurs. Provides efficient bulk + insertion with automatic schema validation for all monitored tables. + """ def __init__(self): self.tables = { @@ -188,13 +202,18 @@ def __del__(self): self.insert_all() def add(self, table_name: str, data: dict[str, Any]): - """ - Adds the data to the batch for the table. Verifies the fields first. + """Adds the data to the batch for the table. + + Verifies the data fields first, then adds the data to the appropriate + table batch. Triggers immediate insertion if batch size limit is reached. Args: - table_name (str): Name of the table to add data to - data (dict): The values for each cell in the table + table_name (str): Name of the table to add data to. + data (dict): The values for each cell in the table. + Raises: + ValueError: If table name is invalid or data format is incorrect. + TypeError: If data types don't match table schema. """ self.tables.get(table_name).verify(data) self.batch.get(table_name).append(list(data.values())) @@ -206,11 +225,13 @@ def add(self, table_name: str, data: dict[str, Any]): self._start_timer() def insert(self, table_name: str): - """ - Inserts the batch for the given table. + """Inserts the batch for the given table. + + Executes the accumulated batch insert operation for the specified table + and clears the batch after successful insertion. Args: - table_name (str): Name of the table to insert data to + table_name (str): Name of the table to insert data to. """ if self.batch[table_name]: with self.lock: @@ -225,7 +246,11 @@ def insert(self, table_name: str): self.batch[table_name] = [] def insert_all(self): - """Inserts the batch for every table.""" + """Inserts the batch for every table. + + Executes batch insert operations for all tables with pending data + and cancels the current timer if active. + """ for table in self.batch: self.insert(table) @@ -235,7 +260,11 @@ def insert_all(self): self.timer = None def _start_timer(self): - """Set the timer for batch processing of data insertion""" + """Set the timer for batch processing of data insertion. + + Cancels any existing timer and starts a new one that will trigger + batch insertion after the configured timeout period. + """ if self.timer: self.timer.cancel() From ff1916fb399d84ae2b2a890d8ba59d325971e7e8 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 16:16:34 +0200 Subject: [PATCH 41/62] Update docstrings for monitoring_agent.py --- src/monitoring/monitoring_agent.py | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/monitoring/monitoring_agent.py b/src/monitoring/monitoring_agent.py index 5f898f29..50d4af0b 100644 --- a/src/monitoring/monitoring_agent.py +++ b/src/monitoring/monitoring_agent.py @@ -22,6 +22,15 @@ def prepare_all_tables(): + """Prepare and create all ClickHouse tables from SQL files. + + Reads all SQL files from the CREATE_TABLES_DIRECTORY and executes them + to create the required database tables for monitoring data storage. + + Raises: + Exception: If any CREATE TABLE statement fails to execute. + """ + def _load_contents(file_name: str) -> str: with open(file_name, "r") as file: return file.read() @@ -40,7 +49,18 @@ def _load_contents(file_name: str) -> str: class MonitoringAgent: + """Main component of the Monitoring stage to collect and store pipeline data + + Consumes monitoring data from Kafka topics and batches them for efficient + insertion into ClickHouse. Handles data deserialization and forwards it to + the batch sender for persistent storage. + """ + def __init__(self): + """ + Sets up consumption from all ClickHouse-related Kafka topics and + initializes the batch sender for efficient data insertion. + """ self.table_names = [ "server_logs", "server_logs_timestamps", @@ -60,6 +80,16 @@ def __init__(self): self.batch_sender = ClickHouseBatchSender() async def start(self): + """Start the monitoring agent to consume and process data continuously. + + Runs an infinite loop to consume messages from Kafka topics, deserialize + the data according to table schemas, and forward it to the batch sender + for insertion into ClickHouse. + + Raises: + KeyboardInterrupt: When the agent is manually stopped. + Exception: For any other processing errors (logged as warnings). + """ loop = asyncio.get_running_loop() while True: @@ -84,6 +114,11 @@ async def start(self): def main(): + """Creates the :class:`MonitoringAgent` instance and starts it. + + Entry point for the monitoring agent that initializes and runs + the asynchronous monitoring process. + """ clickhouse_consumer = MonitoringAgent() asyncio.run(clickhouse_consumer.start()) From 28eb2a5784b1ebec87034846166a897914834eac Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 6 Oct 2025 17:07:17 +0200 Subject: [PATCH 42/62] Handle all sphinx warnings --- docs/conf.py | 1 - docs/configuration.rst | 4 ++-- docs/pipeline.rst | 26 ++++++++++++++++++++------ docs/usage.rst | 2 ++ src/base/kafka_handler.py | 10 ++++++---- src/train/train.py | 7 +++---- 6 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 66d5d497..25c334d7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,7 +41,6 @@ "sphinx.ext.ifconfig", "sphinx.ext.viewcode", "sphinx.ext.autosummary", - "sphinx.ext.autosectionlabel", "sphinxcontrib.bibtex", "sphinx.ext.mathjax", ] diff --git a/docs/configuration.rst b/docs/configuration.rst index 1796c66a..56d453f9 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -149,8 +149,8 @@ functionality of the modules. * - Parameter - Description * - logline_format - - Defines the expected format for incoming log lines. See the :ref:`Logline format configuration` section for more - details. + - Defines the expected format for incoming log lines. See the :ref:`logline-format-configuration` + section for more details. .. list-table:: ``batch_handler`` Parameters :header-rows: 1 diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 6962882e..5eb8610a 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -10,6 +10,8 @@ traverses through it using Apache Kafka. .. image:: media/pipeline_overview.png +.. _stage-1-log-storage: + Stage 1: Log Storage ==================== @@ -59,7 +61,7 @@ batches based on subnet IDs, and forwards them to the next pipeline stage for fu Core Functionality ------------------ -The `Log Collection` stage is responsible for retrieving loglines from the :ref:`Log Storage`, +The `Log Collection` stage is responsible for retrieving loglines from the :ref:`Log Storage`, parsing their information fields, and validating the data. Each field is checked to ensure it is of the correct type and format. This stage ensures that all data is accurate, reducing the need for further verification in subsequent stages. @@ -82,7 +84,7 @@ in the configuration. Advanced Features ................. -The functionality of the buffer system is detailed in the subsection :ref:`Buffer Functionality`. This approach helps +The functionality of the buffer system is detailed in the subsection :ref:`buffer-functionality`. This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in later pipeline stages. Overview @@ -185,7 +187,7 @@ validates. The logline is parsed into its respective fields, each checked for co | | bytes. | +----------------------+------------------------------------------------+ - - Users can change the format and field types, as described in the :ref:`Logline format configuration` section. + - Users can change the format and field types, as described in the :ref:`logline-format-configuration` section. BufferedBatch ............. @@ -197,7 +199,7 @@ The :class:`BufferedBatch` manages the buffering of validated loglines as well a - Collects log entries into a ``batch`` dictionary, with the ``subnet_id`` as key. - Uses a ``buffer`` per key to concatenate and send both the current and previous batches together. - This approach helps detect errors or attacks that may occur at the boundary between two batches when analyzed in - :ref:`Stage 4: Inspection` and :ref:`Stage 5: Detection`. + :ref:`stage-4-inspection` and :ref:`stage-5-detection`. - All batches get sorted by their timestamps at completion to ensure correct chronological order. - A `begin_timestamp` and `end_timestamp` per key are extracted and sent as metadata (needed for analysis). These are taken from the chronologically first and last message in a batch. @@ -234,7 +236,10 @@ The :class:`BufferedBatchSender` manages the sending of validated loglines store Configuration ------------- -The :class:`LogCollector` checks the validity of incoming loglines. For this, it uses the ``logline_format`` configured in the ``config.yaml``. Section :ref:`Logline format configuration` provides detailed information on how to customize the logline format and field definitions. The LogCollector uses the following configuration options from the configuration: +The :class:`LogCollector` checks the validity of incoming loglines. For this, it uses the ``logline_format`` +configured in the ``config.yaml``. Section :ref:`logline-format-configuration` provides detailed information +on how to customize the logline format and field definitions. The LogCollector uses the following +configuration options from the configuration: - **LogCollector Analyzation Criteria**: @@ -243,6 +248,9 @@ The :class:`LogCollector` checks the validity of incoming loglines. For this, it - Valid record types: The accepted DNS record types for logline validation. This is defined in the field with name ``"record_type"`` in the ``logline_format`` list. + +.. _buffer-functionality: + Buffer Functionality -------------------- @@ -416,6 +424,9 @@ The :class:`Prefilter` provides comprehensive monitoring: - **Performance Metrics**: Logs processing statistics for each batch + +.. _stage-4-inspection: + Stage 4: Inspection =================== @@ -471,13 +482,16 @@ Currently, we rely on the packet size and number occurances for multivariate pro - :class:`OCSVMDetector` - :class:`RrcfDetector` -Ensemble prediction in ``streamad.process: +Ensemble prediction in ``streamad.process``: - :class:`WeightEnsemble` - :class:`VoteEnsemble` It takes a list of ``streamad.model`` for perform the ensemble prediction. + +.. _stage-5-detection: + Stage 5: Detection ================== diff --git a/docs/usage.rst b/docs/usage.rst index e2b2d6c7..87c5fbda 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -54,4 +54,6 @@ Now, you can start each module, e.g. the `Inspector`: Configuration ------------- +.. _logline-format-configuration: + .. include:: configuration.rst diff --git a/src/base/kafka_handler.py b/src/base/kafka_handler.py index 5953ff7e..9386a705 100644 --- a/src/base/kafka_handler.py +++ b/src/base/kafka_handler.py @@ -365,7 +365,7 @@ def consume(self, *args, **kwargs): """ raise NotImplementedError - def consume_as_json(self) -> tuple[None | str, dict]: + def consume_as_json(self) -> tuple[Optional[str], dict]: """Consume messages and return them in JSON format. Consumes available messages from subscribed topics, decodes the data, @@ -373,7 +373,7 @@ def consume_as_json(self) -> tuple[None | str, dict]: until a message is available. Returns: - tuple[None | str, dict]: A tuple containing: + tuple[Optional[str], dict]: A tuple containing: - Message key (str or None) - Message value as dictionary (empty dict if no message) @@ -466,7 +466,8 @@ def consume(self) -> tuple[Optional[str], Optional[str], Optional[str]]: - Message key (str or None) - Message value (str or None) - Topic name (str or None) - Returns (None, None, None) if no valid message is retrieved. + + Returns (None, None, None) if no valid message is retrieved. Raises: ValueError: If the received message is invalid. @@ -531,7 +532,8 @@ def consume(self) -> tuple[Optional[str], Optional[str], Optional[str]]: - Message key (str or None) - Message value (str or None) - Topic name (str or None) - Returns (None, None, None) if no valid message is retrieved. + + Returns (None, None, None) if no valid message is retrieved. Raises: ValueError: If the received message is invalid. diff --git a/src/train/train.py b/src/train/train.py index d8fc7dfb..87d15699 100644 --- a/src/train/train.py +++ b/src/train/train.py @@ -1,19 +1,18 @@ import hashlib import json +import os import pickle import sys -import os -from enum import Enum, unique import tempfile +from enum import Enum, unique import click import joblib import numpy as np +import polars as pl import torch from sklearn.metrics import classification_report, confusion_matrix from sklearn.preprocessing import StandardScaler -import polars as pl - sys.path.append(os.getcwd()) from src.train.dataset import DatasetLoader From 2783a52684968411d31109775b0dfbb5f58c580f Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 8 Oct 2025 16:21:50 +0200 Subject: [PATCH 43/62] Create global variable to make mock_logs.dev.py more adjustable --- scripts/mock_logs.dev.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/mock_logs.dev.py b/scripts/mock_logs.dev.py index 8a417daa..ed611c99 100644 --- a/scripts/mock_logs.dev.py +++ b/scripts/mock_logs.dev.py @@ -7,10 +7,12 @@ kafka_producer = SimpleKafkaProduceHandler() +NUMBER_OF_LOGLINES_TO_SEND: int = 50000 + def main(): try: - for i in range(50000): + for i in range(NUMBER_OF_LOGLINES_TO_SEND): kafka_producer.produce( "pipeline-logserver_in", f"{generate_dns_log_line('random-ip.de')}" ) From 2b5de4b03221a6da3054655662fbf3cf8bcf98cf Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 8 Oct 2025 16:22:37 +0200 Subject: [PATCH 44/62] Refactor and update README.md --- README.md | 188 +++++++++++++++++++++++++++++------------------------- 1 file changed, 100 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index aad65dd4..989d85b6 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ ![Pipeline overview](https://raw.githubusercontent.com/stefanDeveloper/heiDGAF/main/docs/media/heidgaf_overview_detailed.drawio.png?raw=true) -## 🛠️ Getting Started +## Getting Started -Run `heiDGAF` using Docker Compose: +##### Run **heiDGAF** using Docker Compose: ```sh HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up @@ -69,10 +69,65 @@ HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up Terminal example

-## Examplary Dashboards -In the below summary you will find examplary views of the grafana dashboards. The metrics were obtained using the [mock-generator](./docker/docker-compose.send-real-logs.yml) +##### Or run the modules locally on your machine: +```sh +python -m venv .venv +source .venv/bin/activate + +sh install_requirements.sh +``` +Alternatively, you can use `pip install` and enter all needed requirements individually with `-r requirements.*.txt`. + +Now, you can start each stage, e.g. the inspector: + +```sh +python src/inspector/inspector.py +``` + +

(back to top)

+ + +## Usage + +### Configuration + +To configure **heiDGAF** according to your needs, use the provided `config.yaml`. + +The most relevant settings are related to your specific log line format, the model you want to use, and +possibly infrastructure. + +The section `pipeline.log_collection.collector.logline_format` has to be adjusted to reflect your specific input log +line format. Using our adjustable and flexible log line configuration, you can rename, reorder and fully configure each +field of a valid log line. Freely define timestamps, RegEx patterns, lists, and IP addresses. For example, your +configuration might look as follows: + +```yml +- [ "timestamp", Timestamp, "%Y-%m-%dT%H:%M:%S.%fZ" ] +- [ "status_code", ListItem, [ "NOERROR", "NXDOMAIN" ], [ "NXDOMAIN" ] ] +- [ "client_ip", IpAddress ] +- [ "dns_server_ip", IpAddress ] +- [ "domain_name", RegEx, '^(?=.{1,253}$)((?!-)[A-Za-z0-9-]{1,63}(? - 📊 Overview Dashboard + Overview dashboard + + Contains the most relevant information on the system's runtime behavior, its efficiency and its effectivity.

@@ -83,7 +138,10 @@ In the below summary you will find examplary views of the grafana dashboards. Th

- 📈 Latencies Dashboard + Latencies dashboard + + Presents any information on latencies, including comparisons between the modules and more detailed, + stand-alone metrics.

@@ -94,7 +152,11 @@ In the below summary you will find examplary views of the grafana dashboards. Th

- 📉 Log Volumes Dashboard + Log Volumes dashboard + + Presents any information on the fill levels of each module, i.e. the number of entries that are currently in the + module for processing. Includes comparisons between the modules, more detailed, stand-alone metrics, as well as + total numbers of logs entering the pipeline or being marked as fully processed.

@@ -105,7 +167,9 @@ In the below summary you will find examplary views of the grafana dashboards. Th

- 🚨 Alerts Dashboard + Alerts dashboard + + Presents details on the number of logs detected as malicious including IP addresses responsible for those alerts.

@@ -116,7 +180,13 @@ In the below summary you will find examplary views of the grafana dashboards. Th

- 🧪 Dataset Dashboard + Dataset dashboard + + This dashboard is only active for the **_datatest_** mode. Users who want to test their own models can use this mode + for inspecting confusion matrices on testing data. + + > [!CAUTION] + > This feature is in a very early development stage.

@@ -126,76 +196,42 @@ In the below summary you will find examplary views of the grafana dashboards. Th

- -## Developing - -Install `Python` requirements: - -```sh -python -m venv .venv -source .venv/bin/activate - -sh install_requirements.sh -``` - -Alternatively, you can use `pip install` and enter all needed requirements individually with `-r requirements.*.txt`. - -Now, you can start each stage, e.g. the inspector: - -```sh -python src/inspector/main.py -```

(back to top)

-### Configuration - -The following table lists the most important configuration parameters with their respective default values. -The full list of configuration parameters is available at the [documentation](https://heidgaf.readthedocs.io/en/latest/usage.html) - -| Path | Description | Default Value | -| :----------------------------------------- | :-------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | -| `pipeline.data_inspection.inspector.mode` | Mode of operation for the data inspector. | `univariate` (options: `multivariate`, `ensemble`) | -| `pipeline.data_inspection.inspector.ensemble.model` | Model to use when inspector mode is `ensemble`. | `WeightEnsemble` | -| `pipeline.data_inspection.inspector.ensemble.module` | Module name for the ensemble model. | `streamad.process` | -| `pipeline.data_inspection.inspector.models` | List of models to use for data inspection (e.g., anomaly detection). | Array of model definitions (e.g., `{"model": "ZScoreDetector", "module": "streamad.model", "model_args": {"is_global": false}}`)| -| `pipeline.data_inspection.inspector.anomaly_threshold` | Threshold for classifying an observation as an anomaly. | `0.01` | -| `pipeline.data_analysis.detector.model` | Model to use for data analysis (e.g., DGA detection). | `rf` (Random Forest) option: `XGBoost` | -| `pipeline.data_analysis.detector.checksum` | Checksum for the model file to ensure integrity. | `021af76b2385ddbc76f6e3ad10feb0bb081f9cf05cff2e52333e31040bbf36cc` | -| `pipeline.data_analysis.detector.base_url` | Base URL for downloading the model if not present locally. | `https://heibox.uni-heidelberg.de/d/0d5cbcbe16cd46a58021/` | -

(back to top)

- -### Insert Data +## Models and Training ->[!IMPORTANT] -> We rely on the following datasets to train and test our or your own models: +To train and test our and possibly your own models, we currently rely on the following datasets: -For training our models, we currently rely on the following data sets: - [CICBellDNS2021](https://www.unb.ca/cic/datasets/dns-2021.html) - [DGTA Benchmark](https://data.mendeley.com/datasets/2wzf9bz7xr/1) - [DNS Tunneling Queries for Binary Classification](https://data.mendeley.com/datasets/mzn9hvdcxg/1) - [UMUDGA - University of Murcia Domain Generation Algorithm Dataset](https://data.mendeley.com/datasets/y8ph45msv8/1) - [DGArchive](https://dgarchive.caad.fkie.fraunhofer.de/) -We compute all feature separately and only rely on the `domain` and `class` for binary classification. +We compute all features separately and only rely on the `domain` and `class` for binary classification. + +### Inserting Data for Testing -After downloading the dataset and storing it under `/data` you can run +For testing purposes, we provide multiple scripts in the `scripts` directory. Use `real_logs.dev.py` to send data from +the datasets into the pipeline. After downloading the dataset and storing it under `/data`, run ```sh python scripts/real_logs.dev.py ``` -to start inserting the dataset traffic. - -

(back to top)

+to start continuously inserting dataset traffic. - -### Train your own models +### Training Your Own Models > [!IMPORTANT] > This is only a brief wrap-up of a custom training process. > We highly encourage you to have a look at the [documentation](https://heidgaf.readthedocs.io/en/latest/training.html) > for a full description and explanation of the configuration parameters. -We feature two trained models: XGBoost (`src/train/model.py#XGBoostModel`) and RandomForest (`src/train/model.py#RandomForestModel`). +We feature two trained models: +1. XGBoost (`src/train/model.py#XGBoostModel`) and +2. RandomForest (`src/train/model.py#RandomForestModel`). + +After installing the requirements, use `src/train/train.py`: ```sh > python -m venv .venv @@ -215,56 +251,32 @@ Commands: train ``` -Setting up the [dataset directories](#insert-test-data) (and adding the code for your model class if applicable) let's you start the training process by running the following commands: +Setting up the [dataset directories](#insert-test-data) (and adding the code for your model class if applicable) lets you start +the training process by running the following commands: -**Model Training** +##### Model Training ```sh > python src/train/train.py train --dataset --dataset_path --model ``` -The results will be saved per default to `./results`, if not configured otherwise.
+The results will be saved per default to `./results`, if not configured otherwise. -**Model Tests** +##### Model Tests ```sh > python src/train/train.py test --dataset --dataset_path --model --model_path ``` -**Model Explain** +##### Model Explain ```sh > python src/train/train.py explain --dataset --dataset_path --model --model_path ``` -This will create a rules.txt file containing the innards of the model, explaining the rules it created. +This will create a `rules.txt` file containing the innards of the model, explaining the rules it created.

(back to top)

-### Data - -> [!IMPORTANT] -> We support custom schemes. - -Depending on your data and usecase, you can customize the data scheme to fit your needs. -The below configuration is part of the [main configuration file](./config.yaml) which is detailed in our [documentation](https://heidgaf.readthedocs.io/en/latest/usage.html#id2) - -```yml -loglines: - fields: - - [ "timestamp", RegEx, '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' ] - - [ "status_code", ListItem, [ "NOERROR", "NXDOMAIN" ], [ "NXDOMAIN" ] ] - - [ "client_ip", IpAddress ] - - [ "dns_server_ip", IpAddress ] - - [ "domain_name", RegEx, '^(?=.{1,253}$)((?!-)[A-Za-z0-9-]{1,63}(?(back to top)

- ## Contributing From e304d3f0a01812bd39745ab4cc28e2536b1d8370 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Wed, 8 Oct 2025 16:32:57 +0200 Subject: [PATCH 45/62] Update README.md (2) --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 989d85b6..7bf3599f 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ## Getting Started -##### Run **heiDGAF** using Docker Compose: +#### Run **heiDGAF** using Docker Compose: ```sh HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up @@ -69,7 +69,7 @@ HOST_IP=127.0.0.1 docker compose -f docker/docker-compose.yml up Terminal example

-##### Or run the modules locally on your machine: +#### Or run the modules locally on your machine: ```sh python -m venv .venv source .venv/bin/activate @@ -185,7 +185,6 @@ Have a look at the following pictures showing examples of how these dashboards m This dashboard is only active for the **_datatest_** mode. Users who want to test their own models can use this mode for inspecting confusion matrices on testing data. - > [!CAUTION] > This feature is in a very early development stage.

@@ -254,20 +253,20 @@ Commands: Setting up the [dataset directories](#insert-test-data) (and adding the code for your model class if applicable) lets you start the training process by running the following commands: -##### Model Training +#### Model Training ```sh > python src/train/train.py train --dataset --dataset_path --model ``` The results will be saved per default to `./results`, if not configured otherwise. -##### Model Tests +#### Model Tests ```sh > python src/train/train.py test --dataset --dataset_path --model --model_path ``` -##### Model Explain +#### Model Explain ```sh > python src/train/train.py explain --dataset --dataset_path --model --model_path From 7e7641f69978d2d32b8a946c1f9649d32ed71f49 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 15:58:13 +0200 Subject: [PATCH 46/62] Update docstrings for inspector.py --- src/inspector/inspector.py | 155 +++++++++++++++++++++++++------------ 1 file changed, 106 insertions(+), 49 deletions(-) diff --git a/src/inspector/inspector.py b/src/inspector/inspector.py index a7575800..3aa5de0b 100644 --- a/src/inspector/inspector.py +++ b/src/inspector/inspector.py @@ -72,6 +72,8 @@ @unique class EnsembleModels(str, Enum): + """Available ensemble models for combining multiple anomaly detectors""" + WEIGHT = "WeightEnsemble" VOTE = "VoteEnsemble" @@ -80,8 +82,9 @@ class Inspector: """Main component of the Data Inspection stage to detect anomalies in request batches Analyzes batches of DNS requests using configurable streaming anomaly detection models. - Processes batches to identify suspicious patterns and forwards anomalous batches to the - Detector for further analysis. Supports both univariate and multivariate detection models. + Supports univariate, multivariate, and ensemble detection modes. Processes time series + features from DNS request patterns to identify suspicious network behavior and forwards + anomalous batches to the Detector for further analysis. """ def __init__(self) -> None: @@ -118,7 +121,12 @@ def __init__(self) -> None: ) def get_and_fill_data(self) -> None: - """Consumes data from KafkaConsumeHandler and stores it for processing.""" + """Consumes data from Kafka and stores it for processing. + + Fetches batch data from the configured Kafka topic and stores it in internal data structures. + If the Inspector is already busy processing data, the consumption is skipped with a warning. + Logs batch information and updates database entries for monitoring purposes. + """ if self.messages: logger.warning( "Inspector is busy: Not consuming new messages. Wait for the Inspector to finish the " @@ -166,8 +174,12 @@ def get_and_fill_data(self) -> None: f" ⤷ Contains data field of {len(self.messages)} message(s). Belongs to subnet_id {key}." ) - def clear_data(self): - """Clears the data in the internal data structures.""" + def clear_data(self) -> None: + """Clears all data from internal data structures. + + Resets messages, anomalies, feature matrix, and timestamps to prepare + the Inspector for processing the next batch of data. + """ self.messages = [] self.anomalies = [] self.X = [] @@ -175,18 +187,22 @@ def clear_data(self): self.end_timestamp = None logger.debug("Cleared messages and timestamps. Inspector is now available.") - def _mean_packet_size(self, messages: list, begin_timestamp, end_timestamp): - """Returns mean of packet size of messages between two timestamps given a time step. - By default, 1 ms time step is applied. Time steps are adjustable by "time_type" and "time_range" - in config.yaml. + def _mean_packet_size( + self, messages: list, begin_timestamp, end_timestamp + ) -> np.ndarray: + """Calculates mean packet size per time step for time series analysis. + + Computes the average packet size for each time step in a given time window. + Time steps are configurable via "time_type" and "time_range" in config.yaml. + Default time step is 1 ms. Args: - messages (list): Messages from KafkaConsumeHandler. - begin_timestamp (datetime): Begin timestamp of batch. - end_timestamp (datetime): End timestamp of batch. + messages (list): Messages from KafkaConsumeHandler containing size information. + begin_timestamp (datetime): Start timestamp of the batch time window. + end_timestamp (datetime): End timestamp of the batch time window. Returns: - numpy.ndarray: 2-D numpy.ndarray including all steps. + numpy.ndarray: 2-D numpy array with mean packet sizes for each time step. """ logger.debug("Convert timestamps to numpy datetime64") timestamps = np.array( @@ -254,18 +270,22 @@ def _mean_packet_size(self, messages: list, begin_timestamp, end_timestamp): logger.debug("Reshape into the required shape (n, 1)") return mean_sizes.reshape(-1, 1) - def _count_errors(self, messages: list, begin_timestamp, end_timestamp): - """Counts occurances of messages between two timestamps given a time step. - By default, 1 ms time step is applied. Time steps are adjustable by "time_type" and "time_range" - in config.yaml. + def _count_errors( + self, messages: list, begin_timestamp, end_timestamp + ) -> np.ndarray: + """Counts message occurrences per time step for time series analysis. + + Counts the number of messages occurring in each time step within a given time window. + Time steps are configurable via "time_type" and "time_range" in config.yaml. + Default time step is 1 ms. Args: - messages (list): Messages from KafkaConsumeHandler. - begin_timestamp (datetime): Begin timestamp of batch. - end_timestamp (datetime): End timestamp of batch. + messages (list): Messages from KafkaConsumeHandler containing timestamp information. + begin_timestamp (datetime): Start timestamp of the batch time window. + end_timestamp (datetime): End timestamp of the batch time window. Returns: - numpy.ndarray: 2-D numpy.ndarray including all steps. + numpy.ndarray: 2-D numpy array with message counts for each time step. """ logger.debug("Convert timestamps to numpy datetime64") timestamps = np.array( @@ -318,8 +338,15 @@ def _count_errors(self, messages: list, begin_timestamp, end_timestamp): logger.debug("Reshape into the required shape (n, 1)") return counts.reshape(-1, 1) - def inspect(self): - """Runs anomaly detection on given StreamAD Model on either univariate, multivariate data, or as an ensemble.""" + def inspect(self) -> None: + """Runs anomaly detection using configured StreamAD models. + + Executes anomaly detection based on the configured mode (univariate, multivariate, or ensemble). + Validates model configuration and delegates to the appropriate inspection method. + + Raises: + NotImplementedError: If no models are configured or mode is unsupported. + """ if MODELS == None or len(MODELS) == 0: logger.warning("No model ist set!") raise NotImplementedError(f"No model is set!") @@ -340,14 +367,12 @@ def inspect(self): logger.warning(f"Mode {MODE} is not supported!") raise NotImplementedError(f"Mode {MODE} is not supported!") - def _inspect_multivariate(self): - """ - Method to inspect multivariate data for anomalies using a StreamAD Model - Errors are count in the time window and fit model to retrieve scores. - - Args: - model (str): Model name (should be capable of handling multivariate data) + def _inspect_multivariate(self) -> None: + """Performs multivariate anomaly detection using StreamAD model. + Combines mean packet size and message count time series to create a multivariate + feature matrix for anomaly detection. Computes anomaly scores for each time step + using the configured multivariate StreamAD model. """ logger.debug("Inspecting data...") @@ -375,10 +400,12 @@ def _inspect_multivariate(self): else: self.anomalies.append(0) - def _inspect_ensemble(self): - """ - Method to inspect data for anomalies using ensembles of two StreamAD models - Errors are count in the time window and fit model to retrieve scores. + def _inspect_ensemble(self) -> None: + """Performs ensemble anomaly detection using multiple StreamAD models. + + Uses message count time series and combines scores from multiple StreamAD models + through ensemble methods (Weight or Vote). Computes final ensemble scores + for each time step in the data. """ self.X = self._count_errors( self.messages, self.begin_timestamp, self.end_timestamp @@ -403,12 +430,12 @@ def _inspect_ensemble(self): else: self.anomalies.append(0) - def _inspect_univariate(self): - """Runs anomaly detection on given StreamAD Model on univariate data. - Errors are count in the time window and fit model to retrieve scores. + def _inspect_univariate(self) -> None: + """Performs univariate anomaly detection using StreamAD model. - Args: - model (str): StreamAD model name. + Uses message count time series as a single feature for anomaly detection. + Computes anomaly scores for each time step using the configured + univariate StreamAD model. """ logger.debug("Inspecting data...") @@ -431,7 +458,19 @@ def _inspect_univariate(self): else: self.anomalies.append(0) - def _get_models(self, models): + def _get_models(self, models: list) -> None: + """Loads and initializes StreamAD detection models. + + Dynamically imports and instantiates the configured StreamAD models based on the + detection mode (univariate, multivariate, or ensemble). Validates model compatibility + with the selected mode and initializes models with their configuration parameters. + + Args: + models (list): List of model configurations containing module and model information. + + Raises: + NotImplementedError: If a model is not compatible with the selected mode. + """ if hasattr(self, "models") and self.models != None and self.models != []: logger.info("All models have been successfully loaded!") return @@ -459,7 +498,16 @@ def _get_models(self, models): module_model = getattr(module, model["model"]) self.models.append(module_model(**model["model_args"])) - def _get_ensemble(self): + def _get_ensemble(self) -> None: + """Loads and initializes ensemble model for combining multiple detectors. + + Dynamically imports and instantiates the configured ensemble model (Weight or Vote) + that combines scores from multiple StreamAD models. Validates that the ensemble + model is supported and initializes it with configuration parameters. + + Raises: + NotImplementedError: If the ensemble model is not supported. + """ logger.debug(f"Load Model: {ENSEMBLE['model']} from {ENSEMBLE['module']}.") if not ENSEMBLE["model"] in VALID_ENSEMBLE_MODELS: logger.error(f"Model {ENSEMBLE} is not a valid ensemble model.") @@ -475,8 +523,14 @@ def _get_ensemble(self): module_model = getattr(module, ENSEMBLE["model"]) self.ensemble = module_model(**ENSEMBLE["model_args"]) - def send_data(self): - """Pass the anomalous data for the detector unit for further processing""" + def send_data(self) -> None: + """Forwards anomalous data to the Detector for further analysis. + + Evaluates anomaly scores against the configured thresholds. If the proportion of + anomalous time steps exceeds the threshold, groups messages by client IP and + forwards each group as a suspicious batch to the Detector via Kafka. Otherwise, + logs the batch as filtered out and updates monitoring databases. + """ total_anomalies = np.count_nonzero( np.greater_equal(np.array(self.anomalies), SCORE_THRESHOLD) ) @@ -567,16 +621,19 @@ def send_data(self): ) -def main(one_iteration: bool = False): - """ - Creates the :class:`Inspector` instance. Starts a loop that continuously fetches data. Actual functionality - follows. +def main(one_iteration: bool = False) -> None: + """Creates and runs the Inspector instance in a continuous processing loop. + + Initializes the Inspector and starts the main processing loop that continuously + fetches batches from Kafka, performs anomaly detection, and forwards suspicious + batches to the Detector. Handles various exceptions gracefully and ensures + proper cleanup of data structures. Args: - one_iteration (bool): For testing purposes: stops loop after one iteration + one_iteration (bool): For testing purposes - stops loop after one iteration. Raises: - KeyboardInterrupt: Execution interrupted by user. Closes down the :class:`LogCollector` instance. + KeyboardInterrupt: Execution interrupted by user. """ logger.info("Starting Inspector...") inspector = Inspector() From 052fdce78ac391d505aacafa6d29ed862e4547e4 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:09:24 +0200 Subject: [PATCH 47/62] Update docstrings for detector.py --- src/detector/detector.py | 87 ++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/src/detector/detector.py b/src/detector/detector.py index fc95a170..cf3109d7 100644 --- a/src/detector/detector.py +++ b/src/detector/detector.py @@ -36,9 +36,7 @@ class WrongChecksum(Exception): # pragma: no cover - """ - Exception if Checksum is not equal. - """ + """Raises when model checksum validation fails.""" pass @@ -46,9 +44,10 @@ class WrongChecksum(Exception): # pragma: no cover class Detector: """Main component of the Data Analysis stage to perform anomaly detection - Processes suspicious batches from the Inspector using machine learning models to detect - anomalous DNS requests. Downloads and validates models, calculates probability scores, and - generates alerts when anomalies are detected above the configured threshold. + Processes suspicious batches from the Inspector using configurable ML models to classify + DNS requests as benign or malicious. Downloads and validates models from a remote server, + extracts features from domain names, calculates probability scores, and generates alerts + when malicious requests are detected above the configured threshold. """ def __init__(self) -> None: @@ -87,7 +86,12 @@ def __init__(self) -> None: ) def get_and_fill_data(self) -> None: - """Consumes data from KafkaConsumeHandler and stores it for processing.""" + """Consumes suspicious batches from Kafka and stores them for analysis. + + Fetches suspicious batch data from the Inspector via Kafka and stores it in internal + data structures. If the Detector is already busy processing data, consumption is + skipped with a warning. Updates database entries for monitoring and logging purposes. + """ if self.messages: logger.warning( "Detector is busy: Not consuming new messages. Wait for the Detector to finish the " @@ -137,13 +141,13 @@ def get_and_fill_data(self) -> None: ) def _sha256sum(self, file_path: str) -> str: - """Return a SHA265 sum check to validate the model. + """Calculates SHA256 checksum for model file validation. Args: - file_path (str): File path of model. + file_path (str): Path to the model file to validate. Returns: - str: SHA256 sum + str: SHA256 hexadecimal digest of the file. """ h = hashlib.sha256() @@ -158,9 +162,17 @@ def _sha256sum(self, file_path: str) -> str: return h.hexdigest() def _get_model(self): - """ - Downloads model from server. If model already exists, it returns the current model. In addition, it checks the - sha256 sum in case a model has been updated. + """Downloads and loads ML model and scaler from remote server. + + Retrieves the configured model and scaler files from the remote server if not + already present locally. Validates model integrity using SHA256 checksum and + loads the pickled model and scaler objects for inference. + + Returns: + tuple: Trained ML model and data scaler objects. + + Raises: + WrongChecksum: If model checksum validation fails. """ logger.info(f"Get model: {MODEL} with checksum {CHECKSUM}") if not os.path.isfile(self.model_path): @@ -206,21 +218,29 @@ def _get_model(self): return clf, scaler - def clear_data(self): - """Clears the data in the internal data structures.""" + def clear_data(self) -> None: + """Clears all data from internal data structures. + + Resets messages, timestamps, and warnings to prepare the Detector + for processing the next suspicious batch. + """ self.messages = [] self.begin_timestamp = None self.end_timestamp = None self.warnings = [] - def _get_features(self, query: str): - """Transform a dataset with new features using numpy. + def _get_features(self, query: str) -> np.ndarray: + """Extracts feature vector from domain name for ML model inference. + + Computes various statistical and linguistic features from the domain name + including label lengths, character frequencies, entropy measures, and + counts of different character types across domain name levels. Args: - query (str): A string to process. + query (str): Domain name string to extract features from. Returns: - dict: Preprocessed data with computed features. + numpy.ndarray: Feature vector ready for ML model prediction. """ # Splitting by dots to calculate label length and max length @@ -309,7 +329,12 @@ def calculate_entropy(s: str) -> float: return all_features.reshape(1, -1) def detect(self) -> None: # pragma: no cover - """Method to detect malicious requests in the network flows""" + """Analyzes DNS requests and identifies malicious domains. + + Processes each DNS request in the current batch by extracting features, + running ML model prediction, and collecting warnings for requests that + exceed the configured maliciousness threshold. + """ logger.info("Start detecting malicious requests.") for message in self.messages: # TODO predict all messages @@ -328,7 +353,13 @@ def detect(self) -> None: # pragma: no cover self.warnings.append(warning) def send_warning(self) -> None: - """Dispatch warnings saved to the object's warning list""" + """Generates and stores alerts for detected malicious requests. + + Creates comprehensive alert records from accumulated warnings including + overall risk scores, individual predictions, and metadata. Stores alerts + in the database and updates batch processing status. If no warnings are + present, marks the batch as filtered out. + """ logger.info("Store alert.") if len(self.warnings) > 0: overall_score = median( @@ -420,15 +451,19 @@ def send_warning(self) -> None: ) -def main(one_iteration: bool = False): # pragma: no cover - """ - Creates the :class:`Detector` instance. Starts a loop that continously fetches data. +def main(one_iteration: bool = False) -> None: # pragma: no cover + """Creates and runs the Detector instance in a continuous processing loop. + + Initializes the Detector and starts the main processing loop that continuously + fetches suspicious batches from Kafka, performs malicious domain detection, + and generates alerts. Handles various exceptions gracefully and ensures + proper cleanup of data structures. Args: - one_iteration (bool): For testing purposes: stops loop after one iteration + one_iteration (bool): For testing purposes - stops loop after one iteration. Raises: - KeyboardInterrupt: Execution interrupted by user. Closes down the :class:`LogCollector` instance. + KeyboardInterrupt: Execution interrupted by user. """ logger.info("Starting Detector...") detector = Detector() From e0e2df75c6af4cd71c5afac2eebb7d9f38488a26 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:53:22 +0200 Subject: [PATCH 48/62] Update docstrings for dataset.py --- src/train/dataset.py | 121 +++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/src/train/dataset.py b/src/train/dataset.py index ed600423..cdb8ad81 100644 --- a/src/train/dataset.py +++ b/src/train/dataset.py @@ -12,14 +12,18 @@ logger = get_logger("train.dataset") -def preprocess(x: pl.DataFrame): - """Preprocesses a `pl.DataFrame` into a basic data set for later transformation. +def preprocess(x: pl.DataFrame) -> pl.DataFrame: + """Preprocesses DataFrame into structured dataset for feature extraction. + + Filters out empty queries, removes duplicates, splits domain names into labels, + and extracts top-level domain (TLD), second-level domain, and third-level domain + components for further analysis. Args: - x (pl.DataFrame): Data sets for preprocessing + x (pl.DataFrame): Raw dataset containing DNS queries for preprocessing. Returns: - pl.DataFrame: Preprocessed data set + pl.DataFrame: Preprocessed dataset with structured domain components. """ logger.debug("Start preprocessing data.") x = x.filter(pl.col("query").str.len_chars() > 0) @@ -79,14 +83,17 @@ def preprocess(x: pl.DataFrame): def cast_dga(data_path: str, max_rows: int) -> pl.DataFrame: - """Cast dga data set. + """Loads and processes DGA dataset from CSV file. + + Reads DGA domain dataset, renames columns to standard format, adds malicious + class label, and applies preprocessing to structure domain components. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (str): Path to the DGA dataset CSV file. + max_rows (int): Maximum number of rows to process. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Processed DGA dataset with structured domain information. """ logger.info(f"Start casting data set {data_path}.") df = pl.read_csv(data_path) @@ -103,14 +110,17 @@ def cast_dga(data_path: str, max_rows: int) -> pl.DataFrame: def cast_bambenek(data_path: str, max_rows: int) -> pl.DataFrame: - """Cast Bambenek data set. + """Loads and processes Bambenek DGA dataset from CSV file. + + Reads Bambenek DGA domain dataset, renames columns to standard format, adds + malicious class label, and applies preprocessing to structure domain components. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (str): Path to the Bambenek dataset CSV file. + max_rows (int): Maximum number of rows to process. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Processed Bambenek dataset with structured domain information. """ logger.info(f"Start casting data set {data_path}.") df = pl.read_csv(data_path) @@ -127,14 +137,17 @@ def cast_bambenek(data_path: str, max_rows: int) -> pl.DataFrame: def cast_cic(data_path: List[str], max_rows: int) -> pl.DataFrame: - """Cast CIC data set. + """Loads and processes CIC DNS dataset from multiple CSV files. + + Reads CIC DNS datasets (benign, malware, phishing, spam), assigns appropriate + class labels based on filename, and combines all datasets into a unified format. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (List[str]): List of paths to CIC dataset CSV files. + max_rows (int): Maximum number of rows to process per file. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Combined CIC dataset with structured domain information. """ dataframes = [] for data in data_path: @@ -157,14 +170,17 @@ def cast_cic(data_path: List[str], max_rows: int) -> pl.DataFrame: def cast_dgarchive(data_path: str, max_rows: int) -> pl.DataFrame: - """Cast DGArchive data set. + """Loads and processes DGArchive dataset from CSV file. + + Reads DGArchive domain dataset, extracts class label from filename, renames + columns to standard format, and applies preprocessing for domain analysis. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (str): Path to the DGArchive dataset CSV file. + max_rows (int): Maximum number of rows to process. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Processed DGArchive dataset with structured domain information. """ dataframes = [] logger.info(f"Start casting data set {data_path}.") @@ -186,14 +202,17 @@ def cast_dgarchive(data_path: str, max_rows: int) -> pl.DataFrame: def cast_dgta(data_path: str, max_rows: int) -> pl.DataFrame: - """Cast DGTA data set. + """Loads and processes DGTA benchmark dataset from Parquet file. + + Reads DGTA benchmark dataset, handles custom UTF-8 encoding, renames columns + to standard format, and applies preprocessing for domain structure analysis. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (str): Path to the DGTA dataset Parquet file. + max_rows (int): Maximum number of rows to process. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Processed DGTA dataset with structured domain information. """ def __custom_decode(data): @@ -226,14 +245,17 @@ def __custom_decode(data): def cast_heicloud(data_path: str, max_rows: int) -> pl.DataFrame: - """Cast heiCLOUD data set. + """Loads and processes heiCLOUD dataset from space-separated text file. + + Reads heiCLOUD DNS log dataset, parses space-separated columns, extracts + domain queries, and labels them as legitimate traffic for training. Args: - data_path (str): Data path to data set - max_rows (int): Maximum rows. + data_path (str): Path to the heiCLOUD dataset text file. + max_rows (int): Maximum number of rows to process. Returns: - pl.DataFrame: Loaded pl.DataFrame. + pl.DataFrame: Processed heiCLOUD dataset with legitimate domain labels. """ dataframes = [] logger.info(f"Start casting data set {data_path}.") @@ -269,14 +291,18 @@ def cast_heicloud(data_path: str, max_rows: int) -> pl.DataFrame: class DatasetLoader: - """DatasetLoader for Training.""" + """Manages loading and access to multiple DNS datasets for training. - def __init__(self, base_path: str = "", max_rows: int = -1) -> None: - """Initialise data sets. + Provides convenient access to various DNS datasets including DGA detection + benchmarks, legitimate traffic datasets, and combined multi-source datasets. + Handles dataset-specific loading and preprocessing requirements. + """ + def __init__(self, base_path: str = "", max_rows: int = -1) -> None: + """ Args: - base_path (str, optional): Base path to data set folder. Defaults to "". - max_rows (int, optional): Maximum rows to consider. Defaults to -1. + base_path (str): Base directory path containing all dataset folders. + max_rows (int): Maximum rows to load per dataset (default: -1 for unlimited). """ logger.info("Initialise DatasetLoader") self.base_path = base_path @@ -358,7 +384,12 @@ def dgarchive_dataset(self) -> list[Dataset]: @dataclass class Dataset: - """Dataset class.""" + """Single DNS dataset with loading and preprocessing capabilities + + Encapsulates dataset information including name, file paths, and data processing + functions. Supports flexible data loading from various sources including CSV, + Parquet, and text files with custom preprocessing functions. + """ def __init__( self, @@ -368,18 +399,20 @@ def __init__( cast_dataset: Callable = None, max_rows: int = -1, ) -> None: - """Initializes data. - - Either a valid data_path is given to load data or the provided data is set. If callback for preprocessing is set, the callback is run by cast_dataset(data_path). - + """ + Loads dataset either from file path using optional preprocessing function + or directly from provided DataFrame. Supports various data formats and + custom preprocessing callbacks for dataset-specific requirements. Args: - data_path (Any): _description_ - data (pl.DataFrame, optional): _description_. Defaults to None. - cast_dataset (Callable, optional): _description_. Defaults to None. + name (str): Unique identifier for the dataset. + data_path (List[str]): File paths to dataset files. + data (pl.DataFrame): Pre-loaded dataset (alternative to data_path). + cast_dataset (Callable): Custom preprocessing function for data loading. + max_rows (int): Maximum rows to load (default: -1 for unlimited). Raises: - NotImplementedError: _description_ + NotImplementedError: When neither data_path nor data is provided. """ self.name = name self.data_path = data_path @@ -397,9 +430,9 @@ def __init__( raise NotImplementedError("No data given") def __len__(self) -> int: - """Returns the length of data set. + """Returns number of rows in the dataset. Returns: - int: Length of the data set + int: Total number of records in the dataset. """ return len(self.data) From a587b9903022975efd4b96699c43609a7f3971b8 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:54:53 +0200 Subject: [PATCH 49/62] Update docstrings for explainer.py --- src/train/explainer.py | 109 +++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 36 deletions(-) diff --git a/src/train/explainer.py b/src/train/explainer.py index cf5f2db3..96a66f94 100644 --- a/src/train/explainer.py +++ b/src/train/explainer.py @@ -22,22 +22,30 @@ class Plotter: - def __init__(self, output_path: str = f"./{RESULT_FOLDER}/data"): - """ - Initialize the Plotter class for PCA visualization. + """Creates visualizations and plots for dataset analysis and model interpretation + + Generates various plots including PCA visualizations, t-SNE projections, label + distributions, and feature analysis plots to understand dataset characteristics + and model behavior in DGA detection tasks. + """ + def __init__(self, output_path: str = f"./{RESULT_FOLDER}/data") -> None: + """ Args: - output_path (str): Path to save the figures. Defaults to './results'. + output_path (str): Directory path to save generated visualization files. """ self.output_path = output_path def _plot_pca_2d(self, X: np.ndarray, y: np.ndarray, name: str) -> None: - """ - Perform PCA and plot the first two principal components in 2D. + """Creates 2D PCA visualization of feature data. + + Reduces dimensionality to 2D using PCA and generates scatter plot with + different colors and markers for each class to visualize data separation. Args: - X (np.ndarray): Feature matrix. - y (np.ndarray): Label array. + X (np.ndarray): Feature matrix for dimensionality reduction. + y (np.ndarray): Class labels for color coding. + name (str): Dataset name for output file naming. """ pca = PCA(n_components=2) @@ -77,12 +85,15 @@ def _plot_pca_2d(self, X: np.ndarray, y: np.ndarray, name: str) -> None: plt.close() def _plot_pca_3d(self, X: np.ndarray, y: np.ndarray, name: str) -> None: - """ - Perform PCA and plot the first three principal components in 3D. + """Creates 3D PCA visualization of feature data. + + Reduces dimensionality to 3D using PCA and generates 3D scatter plot + showing class separation across the first three principal components. Args: - X (np.ndarray): Feature matrix. - y (np.ndarray): Label array. + X (np.ndarray): Feature matrix for dimensionality reduction. + y (np.ndarray): Class labels for color coding. + name (str): Dataset name for output file naming. """ pca = PCA(n_components=3) pca.fit(X) @@ -164,10 +175,14 @@ def _plot_tsne( plt.close() def _plot_label_distribution(self, data: pl.DataFrame, name: str) -> None: - """Plots label distribution. + """Creates bar chart showing distribution of class labels in dataset. + + Visualizes the frequency distribution of different classes using logarithmic + scale to handle imbalanced datasets effectively. Args: - data (pl.DataFrame): DataFrame with all features. + data (pl.DataFrame): Dataset containing class labels in 'class' column. + name (str): Dataset name for output file naming. """ label_counts = data["class"].value_counts() label_distribution = dict(zip(label_counts["class"], label_counts["count"])) @@ -195,14 +210,17 @@ def _plot_label_distribution(self, data: pl.DataFrame, name: str) -> None: def _remove_feature( self, component: int, X: np.ndarray, y: np.ndarray, pca: PCA, name: str ) -> None: - """ - Visualize data after removing the projection onto a specific principal component. + """Visualizes data after removing specific principal component projection. + + Creates scatter plot showing how data appears when the influence of a particular + principal component is removed, helping to understand component contributions. Args: - component (int): Index of the principal component to remove (0-based). - X (np.ndarray): Feature matrix. - y (np.ndarray): Label array. - pca (PCA): Pre-fitted PCA object. + component (int): Index of principal component to remove (0-based). + X (np.ndarray): Original feature matrix. + y (np.ndarray): Class labels for color coding. + pca (PCA): Fitted PCA object containing component information. + name (str): Dataset name for output file naming. """ # Remove PC1 Xmean = X - X.mean(axis=0) @@ -224,12 +242,16 @@ def _remove_feature( def create_plots_binary( self, ds_X: list[np.ndarray], ds_y: list[np.ndarray], data: list[Dataset] ) -> None: - """ - Generate 2D and 3D PCA plots, and visualizations after removing PC1, PC2, and PC3. + """Generates comprehensive visualization suite for binary classification datasets. + + Creates PCA plots (2D/3D), t-SNE projections, principal component removal analysis, + and label distribution charts for multiple datasets to understand data characteristics + and class separability in binary DGA detection tasks. Args: - X (np.ndarray): Feature matrix. - y (np.ndarray): Label array. + ds_X (list[np.ndarray]): List of feature matrices for each dataset. + ds_y (list[np.ndarray]): List of label arrays for each dataset. + data (list[Dataset]): List of dataset objects containing metadata. """ for X, y, ds in zip(ds_X, ds_y, data): if "heicloud" in ds.name: @@ -288,12 +310,16 @@ def create_plots_binary( def create_plots_multiclass( self, ds_X: list[np.ndarray], ds_y: list[np.ndarray], data: list[Dataset] ) -> None: - """Create Plots for multiclass. + """Generates visualizations for multiclass DGA family classification datasets. + + Creates specialized plots for datasets containing multiple DGA families, + focusing on label distribution analysis to understand class imbalances + and dataset composition for multiclass classification tasks. Args: - ds_X (list[np.ndarray]): X - ds_y (list[np.ndarray]): y - data (list[Dataset]): pl.DataFrame + ds_X (list[np.ndarray]): List of feature matrices for each dataset. + ds_y (list[np.ndarray]): List of label arrays for each dataset. + data (list[Dataset]): List of dataset objects containing class information. """ # Plot label distribution from DGArchive df_dgarchive_list = [] @@ -456,22 +482,33 @@ def _plot_data_distribution( class Explainer: - """Explainer class to interpret sklearn.ensemble or XGBClassifier models after training.""" + """Interprets and explains trained machine learning models for DGA detection. - def __init__(self, output_path: str = f"./{RESULT_FOLDER}"): + Provides model interpretation capabilities including rule extraction, feature + importance analysis, and threshold rescaling for decision trees and ensemble + models used in domain generation algorithm detection tasks. + """ + + def __init__(self, output_path: str = f"./{RESULT_FOLDER}") -> None: + """ + Args: + output_path (str): Directory path to save interpretation results. + """ self.output_path = output_path def __rescale_rule(self, rule: str, scaler, feature_names: list[str]) -> str: - """ - Rescale feature thresholds in a rule back to their original (pre-scaled) values. + """Rescales feature thresholds in decision rules to original value ranges. + + Converts scaled feature thresholds back to their original (pre-scaled) values + for better interpretability of decision tree rules and feature importance. Args: - rule (str): A rule string (e.g., "feature1 > 0.5 and feature2 <= 1.3"). - scaler (sklearn.preprocessing): A fitted scaler object with an inverse_transform method. - feature_names (list[str]): List of original feature names. + rule (str): Decision rule string with scaled thresholds (e.g., "feature1 > 0.5 and feature2 <= 1.3"). + scaler (sklearn.preprocessing): Fitted scaler with inverse_transform method. + feature_names (list[str]): Names of features in original order. Returns: - str: Rule with scaled thresholds replaced by original (unscaled) values. + str: Decision rule with thresholds in original value ranges. """ # If scaler is none, no rescaling is needed if scaler is None: From ad4cbc7adfc32a6c2df0ad2217715dc96f41c836 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:55:20 +0200 Subject: [PATCH 50/62] Update docstrings for feature.py --- src/train/feature.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/train/feature.py b/src/train/feature.py index 37ee618e..d61623e4 100644 --- a/src/train/feature.py +++ b/src/train/feature.py @@ -12,24 +12,32 @@ class Processor: - """Processor for data set. Extracts features from data space.""" + """Extracts statistical and linguistic features from domain name datasets. - def __init__(self, features_to_drop: List): - """Init. + Computes comprehensive feature sets including domain label statistics, character + frequencies, entropy measures, and domain structure analysis for machine learning + model training and DGA detection tasks. + """ + def __init__(self, features_to_drop: List) -> None: + """ Args: - feature_to_drop (list): List of feature to drop + features_to_drop (List): List of column names to exclude from final features. """ self.features_to_drop = features_to_drop def transform(self, x: pl.DataFrame) -> pl.DataFrame: - """Transform our dataset with new features. + """Extracts comprehensive feature set from domain name dataset. + + Computes domain label statistics, character frequencies for all letters, + character type ratios, and entropy measures for different domain levels. + Handles missing values and removes specified columns from final output. Args: - x (pl.DataFrame): pl.DataFrame with our features. + x (pl.DataFrame): Input dataset with domain structure columns. Returns: - np.ndarray: Preprocessed dataframe. + pl.DataFrame: Feature-engineered dataset ready for ML model training. """ logger.debug("Start data transformation") x = x.with_columns( From a895c552904742f9fe9f4ad74b94f0e0869c6992 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:56:42 +0200 Subject: [PATCH 51/62] Update docstrings for model.py --- src/train/model.py | 111 +++++++++++++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/src/train/model.py b/src/train/model.py index d4463812..30e23db4 100644 --- a/src/train/model.py +++ b/src/train/model.py @@ -30,7 +30,12 @@ class Pipeline: - """Pipeline for training models.""" + """Manages end-to-end machine learning pipeline for DGA detection model training. + + Orchestrates data preprocessing, feature engineering, model training, and evaluation + for domain generation algorithm detection. Supports multiple datasets, model types, + and handles data scaling, splitting, and persistence operations. + """ def __init__( self, @@ -39,12 +44,20 @@ def __init__( model_output_path: str, scaler=None, ) -> None: - """Initializes preprocessors, encoder, and model. + """Initializes complete ML pipeline with datasets and model configuration. + + Sets up feature processing, data loading, train/validation/test splitting, + and model instantiation based on specified algorithm type. Handles data + persistence and visualization setup. Args: - mean_imputer (Imputer): Mean imputer to handle null values. - target_encoder (TargetEncoder): Target encoder for non-numeric values. - clf (torch.nn.Modul): torch.nn.Modul for training. + model (str): Model type identifier ('rf', 'xg', 'gbm'). + datasets (list[Dataset]): List of datasets for training and evaluation. + model_output_path (str): Directory path for saving trained models. + scaler: Optional data scaler for feature normalization. + + Raises: + NotImplementedError: If specified model type is not supported. """ self.plotting = False self.processor = Processor( @@ -142,8 +155,22 @@ def __init__( raise NotImplementedError(f"Model not implemented!") def _load_npy( - self, ds_name: str, output_path: str = f"./{RESULT_FOLDER}/data" + self, + ds_name: str, + output_path: str = f"./{RESULT_FOLDER}/data", ) -> tuple[np.ndarray, np.ndarray]: + """Loads preprocessed feature matrices and labels from cached NumPy files. + + Args: + ds_name (str): Name of the dataset to load. + output_path (str): Directory containing cached dataset files. + + Returns: + tuple[np.ndarray, np.ndarray]: Feature matrix and label array. + + Raises: + FileNotFoundError: If cached files don't exist for the dataset. + """ if os.path.exists( os.path.join(output_path, ds_name, "X.npy") ) and os.path.exists(os.path.join(output_path, ds_name, "y.npy")): @@ -161,6 +188,14 @@ def _save_npy( ds_name: str, output_path: str = f"./{RESULT_FOLDER}/data", ) -> None: + """Caches processed feature matrices and labels as NumPy files. + + Args: + X (np.ndarray): Processed feature matrix to cache. + y (np.ndarray): Processed label array to cache. + ds_name (str): Name of the dataset for file organization. + output_path (str): Directory to store cached files. + """ os.makedirs(os.path.join(output_path, ds_name), exist_ok=True) np.save(os.path.join(output_path, ds_name, "X.npy"), X) np.save(os.path.join(output_path, ds_name, "y.npy"), y) @@ -168,11 +203,15 @@ def _save_npy( def _label_encoder( self, labels: list[str], legit_label: str = "legit" ) -> tuple[list[int], dict, dict]: - """Encodes labels for correct stratification of training set. + """Encodes string labels to numeric values for model training. + + Maps string class labels to integers with legitimate domains assigned to 0 + and malicious domain families assigned sequential positive integers. + Creates bidirectional mappings for label conversion. Args: - labels (list[str]): List of labels, e.g. ["legit", "DGA", "tuns"] - legit_label (str, optional): Default legit label for benign domains. Defaults to "legit". + labels (list[str]): String class labels (e.g., ["legit", "DGA", "tuns"]). + legit_label (str): Label identifier for legitimate domains. Returns: tuple[list[int], dict, dict]: encoded, label_to_index, index_to_label @@ -216,14 +255,16 @@ def _load_column_list(self, output_path: str = f"./{RESULT_FOLDER}/data"): raise FileNotFoundError("Columns does not exist") def _clean_column_name(self, column: str) -> str: - """ - Clean column names to be compatible with ML models. + """Sanitizes column names for machine learning model compatibility. + + Replaces spaces and special characters with underscores, removes invalid + characters, and ensures column names don't start with digits. Args: - column (str): Original column name + column (str): Original column name to clean. Returns: - str: Cleaned column name + str: ML-compatible column name. """ # Replace spaces and hyphens with underscores cleaned = re.sub(r"[\s\-]+", "_", column) @@ -248,14 +289,19 @@ def train_test_val_split( np.ndarray, np.ndarray, ]: - """Splits data set in train, test, and validation set + """Splits dataset into training, validation, and test sets with stratification. + + Creates stratified splits maintaining class distribution across all subsets. + Training set gets specified fraction, validation and test sets split remaining data equally. Args: - train_frac (float, optional): Training fraction. Defaults to 0.8. - random_state (int, optional): Random state. Defaults to None. + X (np.ndarray): Feature matrix to split. + Y (np.ndarray): Label array to split. + train_frac (float): Proportion of data for training set. Default: 0.8 + random_state (int): Random seed for reproducible splits. Returns: - tuple[list, list, list, list, list, list]: X_train, X_val, X_test, Y_train, Y_val, Y_test + tuple: X_train, X_val, X_test, Y_train, Y_val, Y_test arrays. """ logger.info("Create train, validation, and test split.") @@ -270,12 +316,11 @@ def train_test_val_split( return X_train, X_val, X_test, Y_train, Y_val, Y_test - def hyperparam_fit(self): - """Fits models to training data. + def hyperparam_fit(self) -> None: + """Performs hyperparameter optimization and model training. - Args: - x_train (np.array): X data. - y_train (np.array): Y labels. + Uses Optuna to search optimal hyperparameters through Bayesian optimization, + then trains the model with best parameters found during the search process. """ if not os.path.exists(CV_RESULT_DIR): os.mkdir(CV_RESULT_DIR) @@ -300,23 +345,29 @@ def hyperparam_fit(self): y=self.y_train, ) - def predict(self, x): - """Predicts given X. + def predict(self, x: np.ndarray) -> np.ndarray: + """Generates predictions for input feature matrix. Args: - x (np.array): X data + x (np.ndarray): Feature matrix for prediction. Returns: - np.array: Model output. + np.ndarray: Model predictions. """ return self.model.predict(x) - def explain(self, x, y) -> list[str]: - """Explains models + def explain(self, x: np.ndarray, y: np.ndarray) -> list[str]: + """Generates interpretable explanations for trained model decisions. + + Creates human-readable decision rules and feature importance explanations + for supported model types (XGBoost, Random Forest). Args: - x (np.array): X data - y (np.array): Y data + x (np.ndarray): Feature matrix for explanation generation. + y (np.ndarray): True labels for explanation context. + + Returns: + list[str]: List of interpretable decision rules and explanations. """ if isinstance(self.model.clf, xgb.XGBClassifier) or isinstance( self.model.clf, RandomForestClassifier From 5de1114c70c272888a57e17a532eaa7fd4fd7e67 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:56:55 +0200 Subject: [PATCH 52/62] Fix argument in model.py --- src/train/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/train/model.py b/src/train/model.py index 30e23db4..93519f17 100644 --- a/src/train/model.py +++ b/src/train/model.py @@ -278,7 +278,7 @@ def _clean_column_name(self, column: str) -> str: def train_test_val_split( self, X: np.ndarray, - Y=np.ndarray, + Y: np.ndarray, train_frac: float = 0.8, random_state: int = SEED, ) -> tuple[ From 84957b2a478987936670f98fbb92ec13a74330a1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:58:28 +0200 Subject: [PATCH 53/62] Update docstrings for train.py --- src/train/train.py | 83 +++++++++++++++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 23 deletions(-) diff --git a/src/train/train.py b/src/train/train.py index 87d15699..07ab321a 100644 --- a/src/train/train.py +++ b/src/train/train.py @@ -36,6 +36,8 @@ def _add_options(func): @unique class DatasetEnum(str, Enum): + """Available dataset configurations for DGA detection model training""" + COMBINE = "combine" CIC = "cic" DGTA = "dgta" @@ -44,12 +46,21 @@ class DatasetEnum(str, Enum): @unique class ModelEnum(str, Enum): + """Available machine learning algorithms for DGA detection""" + RANDOM_FOREST_CLASSIFIER = "rf" XG_BOOST_CLASSIFIER = "xg" GBM_CLASSIFIER = "gbm" class DetectorTraining: + """Orchestrates end-to-end training of DGA detection models. + + Manages dataset loading, model selection, training pipeline execution, + and model persistence for domain generation algorithm detection. Supports + multiple datasets, model types, and handles checksum-based model versioning. + """ + def __init__( self, model_name: ModelEnum.RANDOM_FOREST_CLASSIFIER, @@ -58,17 +69,21 @@ def __init__( data_base_path: str = "./data", max_rows: int = -1, ) -> None: - """Trainer class to fit models on data sets. + """Initializes training configuration and dataset loading. + + Sets up model training pipeline with specified algorithm, datasets, and + output paths. Handles existing model detection and checksum validation + for incremental training workflows. Args: - model_name (ModelEnum.RANDOM_FOREST_CLASSIFIER): _description_ - model_output_path (str, optional): _description_. Defaults to "./". - dataset (DatasetEnum, optional): _description_. Defaults to DatasetEnum.COMBINE. - data_base_path (str, optional): _description_. Defaults to "./data". - max_rows (int, optional): _description_. Defaults to -1. + model_name (ModelEnum): ML algorithm type for training. + model_output_path (str): Directory path for saving trained models. + dataset (DatasetEnum): Dataset configuration for training. + data_base_path (str): Base directory containing raw datasets. + max_rows (int): Maximum rows per dataset (default: -1 for unlimited). Raises: - NotImplementedError: _description_ + NotImplementedError: If specified dataset configuration is not supported. """ logger.info("Get DatasetLoader.") self.dataset_loader = DatasetLoader(base_path=data_base_path, max_rows=max_rows) @@ -117,7 +132,12 @@ def __init__( ) self._load_model() - def explain(self): + def explain(self) -> None: + """Generates and saves interpretable explanations for the trained model. + + Extracts decision rules and model interpretations from the trained classifier + and saves them to text files for analysis and understanding of model behavior. + """ rules = self.model_pipeline.explain( self.model_pipeline.x_val, self.model_pipeline.y_val ) @@ -132,7 +152,13 @@ def explain(self): for i, rule in enumerate(rules, 1): f.write(f"Rule {i}: {rule}\n") - def test(self): + def test(self) -> None: + """Evaluates trained model on all datasets and generates comprehensive reports. + + Tests model performance across all loaded datasets, computes metrics including + classification reports, FDR, and FTTAR. Saves detailed error analysis and + misprediction information for model debugging and improvement. + """ for X, y, ds in zip( self.model_pipeline.ds_X, self.model_pipeline.ds_y, @@ -187,11 +213,15 @@ def test(self): results["fttar"] = self._fttar(y, y_pred) f.write(json.dumps(results) + "\n") - def train(self, seed=SEED): - """Starts training of the model. Checks prior if GPU is available. + def train(self, seed: int = SEED) -> None: + """Executes complete model training workflow with evaluation and persistence. + + Performs hyperparameter optimization, model training, evaluation on test set, + and generates comprehensive analysis including model interpretation and + performance reports across all datasets. Args: - seed (int, optional): _description_. Defaults to 42. + seed (int): Random seed for reproducible training results. """ if seed > 0: np.random.seed(seed) @@ -220,14 +250,18 @@ def train(self, seed=SEED): self.explain() def _fttar(self, y_actual: list[int], y_pred: list[int]) -> float: - """FTTAR metric + """Calculates False Positive to True Positive Ratio (FTTAR) metric. + + Computes the ratio of false positives to true positives, which is useful + for understanding the trade-off between detecting malicious domains and + generating false alarms in DGA detection systems. Args: - y_actual (list[int]): y true labels - y_pred (list[int]): y preds + y_actual (list[int]): Ground truth binary labels. + y_pred (list[int]): Predicted binary labels. Returns: - float: FTTAR + float: FTTAR ratio (0 if no true positives detected). """ _, FP, _, TP = confusion_matrix(y_actual, y_pred, labels=[0, 1]).ravel() if (TP) == 0: @@ -236,14 +270,17 @@ def _fttar(self, y_actual: list[int], y_pred: list[int]) -> float: return FP / TP def _fdr(self, y_actual: list[int], y_pred: list[int]) -> float: - """Returns False Discovery Rate + """Calculates False Discovery Rate (FDR) for model evaluation. + + Computes the proportion of false positives among all positive predictions, + which indicates the reliability of positive DGA detections in the model. Args: - y_actual (list[int]): y true labels - y_pred (list[int]): y preds + y_actual (list[int]): Ground truth binary labels. + y_pred (list[int]): Predicted binary labels. Returns: - float: FDR + float: FDR value (0 if no positive predictions made). """ _, FP, _, TP = confusion_matrix(y_actual, y_pred, labels=[0, 1]).ravel() if (FP + TP) == 0: @@ -317,13 +354,13 @@ def _save_scaler(self): pickle.dump(self.scaler, f) def _sha256sum(self, file_path: str) -> str: - """Return a SHA265 sum check to validate the model. + """Calculates SHA256 checksum for model file integrity verification. Args: - file_path (str): File path of model. + file_path (str): Path to the model file to checksum. Returns: - str: SHA256 sum + str: SHA256 hexadecimal digest for file validation. """ h = hashlib.sha256() From 017b04cc1f17eba78b7610d3e05b0177e3115d97 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Mon, 13 Oct 2025 16:59:49 +0200 Subject: [PATCH 54/62] Optimize imports for src/train files --- src/train/dataset.py | 2 +- src/train/explainer.py | 14 ++++++-------- src/train/feature.py | 5 +++-- src/train/model.py | 23 ++++++++++++----------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/train/dataset.py b/src/train/dataset.py index cdb8ad81..6325f51b 100644 --- a/src/train/dataset.py +++ b/src/train/dataset.py @@ -1,5 +1,5 @@ -import sys import os +import sys from dataclasses import dataclass from typing import Callable, List diff --git a/src/train/explainer.py b/src/train/explainer.py index 96a66f94..4b14c9c2 100644 --- a/src/train/explainer.py +++ b/src/train/explainer.py @@ -1,17 +1,15 @@ -import sys import os +import sys + +import matplotlib.pyplot as plt +import numpy as np +import polars as pl +import seaborn as sns from scipy.stats import ks_2samp from scipy.stats import wasserstein_distance -import numpy as np -import matplotlib.pyplot as plt from sklearn.decomposition import PCA -import seaborn as sns -import numpy as np -import matplotlib.pyplot as plt from sklearn.manifold import TSNE from te2rules.explainer import ModelExplainer -import polars as pl - sys.path.append(os.getcwd()) from src.base.log_config import get_logger diff --git a/src/train/feature.py b/src/train/feature.py index d61623e4..e415fd29 100644 --- a/src/train/feature.py +++ b/src/train/feature.py @@ -1,8 +1,9 @@ -import sys import os -import math +import sys from string import ascii_lowercase as alc from typing import List + +import math import polars as pl sys.path.append(os.getcwd()) diff --git a/src/train/model.py b/src/train/model.py index 93519f17..3353f2c3 100644 --- a/src/train/model.py +++ b/src/train/model.py @@ -1,20 +1,21 @@ -from abc import ABCMeta, abstractmethod +import os import re import sys -import os +from abc import ABCMeta, abstractmethod + import joblib -from sklearn.exceptions import NotFittedError -from sklearn.utils.validation import check_is_fitted -import sklearn.model_selection -from sklearn.metrics import confusion_matrix, make_scorer -import xgboost as xgb +import lightgbm as lgb +import numpy as np import optuna +import sklearn.model_selection import torch -import numpy as np +import xgboost as xgb from sklearn.ensemble import RandomForestClassifier +from sklearn.exceptions import NotFittedError +from sklearn.metrics import confusion_matrix, make_scorer from sklearn.model_selection import cross_val_score, train_test_split from sklearn.utils import class_weight -import lightgbm as lgb +from sklearn.utils.validation import check_is_fitted sys.path.append(os.getcwd()) from src.train.feature import Processor @@ -398,10 +399,10 @@ def __init__(self) -> None: if self.device.type == "cuda": logger.info("Memory Usage:") logger.info( - f"\tAllocated: {round(torch.cuda.memory_allocated(0)/1024**3,1)} GB" + f"\tAllocated: {round(torch.cuda.memory_allocated(0) / 1024 ** 3, 1)} GB" ) logger.info( - f"\tCached: {round(torch.cuda.memory_reserved(0)/1024**3,1)} GB" + f"\tCached: {round(torch.cuda.memory_reserved(0) / 1024 ** 3, 1)} GB" ) self.device = "gpu" else: From 823d925983f0168f0d837063353dd36c71c5da85 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 14 Oct 2025 11:40:45 +0200 Subject: [PATCH 55/62] Small docstring fix --- src/monitoring/clickhouse_batch_sender.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/monitoring/clickhouse_batch_sender.py b/src/monitoring/clickhouse_batch_sender.py index 41aa9f98..3c9f0dac 100644 --- a/src/monitoring/clickhouse_batch_sender.py +++ b/src/monitoring/clickhouse_batch_sender.py @@ -260,7 +260,7 @@ def insert_all(self): self.timer = None def _start_timer(self): - """Set the timer for batch processing of data insertion. + """Sets the timer for batch processing of data insertion. Cancels any existing timer and starts a new one that will trigger batch insertion after the configured timeout period. From 1a0b625e86e69d7a30d1855ccba0f96c5ed3a4f1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Tue, 14 Oct 2025 11:43:45 +0200 Subject: [PATCH 56/62] Small docstring fixes (2) --- src/monitoring/clickhouse_batch_sender.py | 2 +- src/monitoring/monitoring_agent.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/monitoring/clickhouse_batch_sender.py b/src/monitoring/clickhouse_batch_sender.py index 3c9f0dac..fa29a3f2 100644 --- a/src/monitoring/clickhouse_batch_sender.py +++ b/src/monitoring/clickhouse_batch_sender.py @@ -36,7 +36,7 @@ class Table: columns: dict[str, type] def verify(self, data: dict[str, Any]): - """Verify if the data has the correct columns and types. + """Verifies if the data has the correct columns and types. Validates that the provided data dictionary contains the expected columns with correct data types according to the table schema definition. diff --git a/src/monitoring/monitoring_agent.py b/src/monitoring/monitoring_agent.py index 50d4af0b..61c147ca 100644 --- a/src/monitoring/monitoring_agent.py +++ b/src/monitoring/monitoring_agent.py @@ -22,7 +22,7 @@ def prepare_all_tables(): - """Prepare and create all ClickHouse tables from SQL files. + """Prepares and creates all ClickHouse tables from SQL files. Reads all SQL files from the CREATE_TABLES_DIRECTORY and executes them to create the required database tables for monitoring data storage. @@ -80,7 +80,7 @@ def __init__(self): self.batch_sender = ClickHouseBatchSender() async def start(self): - """Start the monitoring agent to consume and process data continuously. + """Starts the monitoring agent to consume and process data continuously. Runs an infinite loop to consume messages from Kafka topics, deserialize the data according to table schemas, and forward it to the batch sender From e2cafcbcd0b1360dec3af11fe374ef8383cb5ef0 Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Tue, 14 Oct 2025 15:55:49 +0200 Subject: [PATCH 57/62] Fix detector feature calculation --- src/detector/detector.py | 113 +++++++++++++++++---------------------- 1 file changed, 48 insertions(+), 65 deletions(-) diff --git a/src/detector/detector.py b/src/detector/detector.py index cf3109d7..3613559c 100644 --- a/src/detector/detector.py +++ b/src/detector/detector.py @@ -244,86 +244,68 @@ def _get_features(self, query: str) -> np.ndarray: """ # Splitting by dots to calculate label length and max length + query = query.strip(".") label_parts = query.split(".") + + levels = { + "fqdn": query, + "secondleveldomain": label_parts[-2] if len(label_parts) >= 2 else "", + "thirdleveldomain": ".".join(label_parts[:-2]) if len(label_parts) > 2 else "", + } + label_length = len(label_parts) - label_max = max(len(part) for part in label_parts) - label_average = len(query.strip(".")) + parts = query.split(".") + label_max = len(max(parts, key=str)) if parts else 0 + label_average = len(query) + + basic_features = np.array([label_length, label_max, label_average], dtype=np.float64) - logger.debug("Get letter frequency") alc = "abcdefghijklmnopqrstuvwxyz" + query_len = len(query) freq = np.array( - [query.lower().count(i) / len(query) if len(query) > 0 else 0 for i in alc] + [query.lower().count(c) / query_len if query_len > 0 else 0.0 for c in alc], + dtype=np.float64 ) logger.debug("Get full, alpha, special, and numeric count.") - def calculate_counts(level: str) -> np.ndarray: - if len(level) == 0: - return np.array([0, 0, 0, 0]) - - full_count = len(level) - alpha_count = sum(c.isalpha() for c in level) / full_count - numeric_count = sum(c.isdigit() for c in level) / full_count - special_count = ( - sum(not c.isalnum() and not c.isspace() for c in level) / full_count - ) + if not level: + return np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float64) - return np.array([full_count, alpha_count, numeric_count, special_count]) + full_count = len(level) / len(level) + alpha_ratio = sum(c.isalpha() for c in level) / len(level) + numeric_ratio = sum(c.isdigit() for c in level) / len(level) + special_ratio = sum(not c.isalnum() and not c.isspace() for c in level) / len(level) - levels = { - "fqdn": query, - "thirdleveldomain": label_parts[0] if len(label_parts) > 2 else "", - "secondleveldomain": label_parts[1] if len(label_parts) > 1 else "", - } - counts = { - level: calculate_counts(level_value) - for level, level_value in levels.items() - } + return np.array([full_count, alpha_ratio, numeric_ratio, special_ratio], dtype=np.float64) - logger.debug( - "Get standard deviation, median, variance, and mean for full, alpha, special, and numeric count." - ) - stats = {} - for level, count_array in counts.items(): - stats[f"{level}_std"] = np.std(count_array) - stats[f"{level}_var"] = np.var(count_array) - stats[f"{level}_median"] = np.median(count_array) - stats[f"{level}_mean"] = np.mean(count_array) + fqdn_counts = calculate_counts(levels["fqdn"]) + third_counts = calculate_counts(levels["thirdleveldomain"]) + second_counts = calculate_counts(levels["secondleveldomain"]) - logger.debug("Start entropy calculation") + level_features = np.hstack([third_counts, second_counts, fqdn_counts]) def calculate_entropy(s: str) -> float: if len(s) == 0: - return 0 - probabilities = [float(s.count(c)) / len(s) for c in dict.fromkeys(list(s))] - entropy = -sum(p * math.log(p, 2) for p in probabilities) - return entropy - - entropy = {level: calculate_entropy(value) for level, value in levels.items()} - - logger.debug("Finished entropy calculation") - - # Final feature aggregation as a NumPy array - basic_features = np.array([label_length, label_max, label_average]) - - # Flatten counts and stats for each level into arrays - level_features = np.hstack([counts[level] for level in levels.keys()]) - - # Entropy features - entropy_features = np.array([entropy[level] for level in levels.keys()]) - - # Concatenate all features into a single numpy array - all_features = np.concatenate( - [ - basic_features, - freq, - # freq_features, - level_features, - # stats_features, - entropy_features, - ] - ) - + return 0.0 + probs = [s.count(c) / len(s) for c in dict.fromkeys(s)] + return -sum(p * math.log(p, 2) for p in probs) + logger.debug("Start entropy calculation") + entropy_features = np.array([ + calculate_entropy(levels["fqdn"]), + calculate_entropy(levels["thirdleveldomain"]), + calculate_entropy(levels["secondleveldomain"]), + ], dtype=np.float64) + + logger.debug("Entropy features calculated") + + all_features = np.concatenate([ + basic_features, + freq, + level_features, + entropy_features + ]) + logger.debug("Finished data transformation") return all_features.reshape(1, -1) @@ -338,8 +320,9 @@ def detect(self) -> None: # pragma: no cover logger.info("Start detecting malicious requests.") for message in self.messages: # TODO predict all messages + # TODO use scalar: self.scaler.transform(self._get_features(message["domain_name"])) y_pred = self.model.predict_proba( - self.scaler.transform(self._get_features(message["domain_name"])) + self._get_features(message["domain_name"]) ) logger.info(f"Prediction: {y_pred}") if np.argmax(y_pred, axis=1) == 1 and y_pred[0][1] > THRESHOLD: From 69fb4ad706538318f486129b4532765c2cd1049b Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Tue, 14 Oct 2025 15:58:13 +0200 Subject: [PATCH 58/62] Fix linting --- src/detector/detector.py | 45 +++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/detector/detector.py b/src/detector/detector.py index 3613559c..062b1f14 100644 --- a/src/detector/detector.py +++ b/src/detector/detector.py @@ -250,7 +250,9 @@ def _get_features(self, query: str) -> np.ndarray: levels = { "fqdn": query, "secondleveldomain": label_parts[-2] if len(label_parts) >= 2 else "", - "thirdleveldomain": ".".join(label_parts[:-2]) if len(label_parts) > 2 else "", + "thirdleveldomain": ( + ".".join(label_parts[:-2]) if len(label_parts) > 2 else "" + ), } label_length = len(label_parts) @@ -258,16 +260,19 @@ def _get_features(self, query: str) -> np.ndarray: label_max = len(max(parts, key=str)) if parts else 0 label_average = len(query) - basic_features = np.array([label_length, label_max, label_average], dtype=np.float64) + basic_features = np.array( + [label_length, label_max, label_average], dtype=np.float64 + ) alc = "abcdefghijklmnopqrstuvwxyz" query_len = len(query) freq = np.array( [query.lower().count(c) / query_len if query_len > 0 else 0.0 for c in alc], - dtype=np.float64 + dtype=np.float64, ) logger.debug("Get full, alpha, special, and numeric count.") + def calculate_counts(level: str) -> np.ndarray: if not level: return np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float64) @@ -275,9 +280,14 @@ def calculate_counts(level: str) -> np.ndarray: full_count = len(level) / len(level) alpha_ratio = sum(c.isalpha() for c in level) / len(level) numeric_ratio = sum(c.isdigit() for c in level) / len(level) - special_ratio = sum(not c.isalnum() and not c.isspace() for c in level) / len(level) + special_ratio = sum( + not c.isalnum() and not c.isspace() for c in level + ) / len(level) - return np.array([full_count, alpha_ratio, numeric_ratio, special_ratio], dtype=np.float64) + return np.array( + [full_count, alpha_ratio, numeric_ratio, special_ratio], + dtype=np.float64, + ) fqdn_counts = calculate_counts(levels["fqdn"]) third_counts = calculate_counts(levels["thirdleveldomain"]) @@ -290,22 +300,23 @@ def calculate_entropy(s: str) -> float: return 0.0 probs = [s.count(c) / len(s) for c in dict.fromkeys(s)] return -sum(p * math.log(p, 2) for p in probs) + logger.debug("Start entropy calculation") - entropy_features = np.array([ - calculate_entropy(levels["fqdn"]), - calculate_entropy(levels["thirdleveldomain"]), - calculate_entropy(levels["secondleveldomain"]), - ], dtype=np.float64) + entropy_features = np.array( + [ + calculate_entropy(levels["fqdn"]), + calculate_entropy(levels["thirdleveldomain"]), + calculate_entropy(levels["secondleveldomain"]), + ], + dtype=np.float64, + ) logger.debug("Entropy features calculated") - all_features = np.concatenate([ - basic_features, - freq, - level_features, - entropy_features - ]) - + all_features = np.concatenate( + [basic_features, freq, level_features, entropy_features] + ) + logger.debug("Finished data transformation") return all_features.reshape(1, -1) From 3f6cc5e7f9d02cdc379db1c6f34ad71d8286be9f Mon Sep 17 00:00:00 2001 From: maldwg Date: Tue, 14 Oct 2025 16:05:50 +0200 Subject: [PATCH 59/62] Adapt config.yaml to point at external kafka APIs --- config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config.yaml b/config.yaml index ba2dc06b..eefe9f8e 100644 --- a/config.yaml +++ b/config.yaml @@ -70,12 +70,12 @@ pipeline: environment: kafka_brokers: - - hostname: kafka1 - port: 19092 - - hostname: kafka2 - port: 19093 - - hostname: kafka3 - port: 19094 + - hostname: 127.0.0.1 + port: 8097 + - hostname: 127.0.0.1 + port: 8098 + - hostname: 127.0.0.1 + port: 8099 kafka_topics: pipeline: logserver_in: "pipeline-logserver_in" From f486aad0f97e1359e2d90edd925fe9f198f85727 Mon Sep 17 00:00:00 2001 From: Stefan Machmeier Date: Tue, 14 Oct 2025 16:27:53 +0200 Subject: [PATCH 60/62] Update inspector and detector docu --- docs/pipeline.rst | 57 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 5eb8610a..d8d0ea5a 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -433,17 +433,28 @@ Stage 4: Inspection Overview -------- -The `Inspector` stage is responsible to run time-series based anomaly detection on prefiltered batches. This stage -is essentiell to reduce the load on the `Detection` stage. Otherwise, resource complexity increases disproportionately. +The **Inspection** stage performs time-series-based anomaly detection on prefiltered DNS request batches. +Its primary purpose is to reduce the load on the `Detection` stage by filtering out non-suspicious traffic early. + +This stage uses StreamAD models—supporting univariate, multivariate, and ensemble techniques—to detect unusual patterns +in request volume and packet sizes. + Main Class ---------- .. py:currentmodule:: src.inspector.inspector .. autoclass:: Inspector + :members: + :undoc-members: + :show-inheritance: + +The :class:`Inspector` class is responsible for: -The :class:`Inspector` is the primary class to run StreamAD models for time-series based anomaly detection, such as the Z-Score outlier detection. -In addition, it features fine-tuning settings for models and anomaly thresholds. +- Loading batches from Kafka +- Extracting time-series features (e.g., frequency and packet size) +- Applying anomaly detection models +- Forwarding suspicious batches to the detector stage Usage ----- @@ -498,9 +509,9 @@ Stage 5: Detection Overview -------- -The `Detector` resembles the heart of heiDGAF. It runs pre-trained machine learning models to get a probability outcome for the DNS requests. -The pre-trained models are under the EUPL-1.2 license online available. -In total, we rely on the following data sets for the pre-trained models we offer: +The **Detection** stage is the core of the heiDGAF pipeline. It consumes **suspicious batches** passed from the `Inspector`, applies **pre-trained ML models** to classify individual DNS requests, and issues alerts based on aggregated probabilities. + +The pre-trained models used here are licensed under **EUPL‑1.2** and built from the following datasets: - `CIC-Bell-DNS-2021 `_ - `DGTA-BENCH - Domain Generation and Tunneling Algorithms for Benchmark `_ @@ -511,15 +522,39 @@ Main Class .. py:currentmodule:: src.detector.detector .. autoclass:: Detector + :members: + :undoc-members: + :show-inheritance: + +The :class:`Detector` class: + +- Consumes a batch flagged as suspicious. +- Downloads and validates the ML model (if necessary). +- Extracts features from domain names (e.g. character distributions, entropy, label statistics). +- Computes a probability per request and an overall risk score per batch. +- Emits alerts to ClickHouse and logs in ``/tmp/warnings.json`` where applicable. Usage ----- -The :class:`Detector` consumes anomalous batches of requests. -It calculates a probability score for each request, and at last, an overall score of the batch. -Alerts are log to ``/tmp/warnings.json``. +1. The `Detector` listens on the Kafka topic from the Inspector (``inspector_to_detector``). +2. For each suspicious batch: + - Extracts features for every domain request. + - Applies the loaded ML model (after scaling) to compute class probabilities. + - Marks a request as malicious if its probability exceeds the configured `threshold`. +3. Computes an **overall score** (e.g. median of malicious probabilities) for the batch. +4. If malicious requests exist, issues an **alert** record and logs it; otherwise, the batch is filtered. + +Alerts are recorded in ClickHouse and also appended to a local JSON file (`warnings.json`) for external monitoring. Configuration ------------- -In case you want to load self-trained models, the :class:`Detector` needs a URL path, model name, and SHA256 checksum to download the model during start-up. +You may use the provided, pre-trained models or supply your own. To use a custom model, specify: + +- `base_url`: URL from which to fetch model artifacts +- `model`: model name +- `checksum`: SHA256 digest for integrity validation +- `threshold`: probability threshold for classifying a request as malicious + +These parameters are loaded at startup and used to download, verify, and load the model/scaler if not already cached locally (in temp directory). \ No newline at end of file From b96543bd778eb316b550e2bbe1eba1bb01ecd1a5 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Fri, 17 Oct 2025 14:02:19 +0200 Subject: [PATCH 61/62] Remove too detailed information in pipeline.rst --- docs/pipeline.rst | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index d8d0ea5a..62fdcfab 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -445,9 +445,6 @@ Main Class .. py:currentmodule:: src.inspector.inspector .. autoclass:: Inspector - :members: - :undoc-members: - :show-inheritance: The :class:`Inspector` class is responsible for: @@ -522,9 +519,6 @@ Main Class .. py:currentmodule:: src.detector.detector .. autoclass:: Detector - :members: - :undoc-members: - :show-inheritance: The :class:`Detector` class: @@ -537,7 +531,7 @@ The :class:`Detector` class: Usage ----- -1. The `Detector` listens on the Kafka topic from the Inspector (``inspector_to_detector``). +1. The `Detector` listens on the Kafka topic from the Inspector (``inspector_to_detector``). 2. For each suspicious batch: - Extracts features for every domain request. - Applies the loaded ML model (after scaling) to compute class probabilities. @@ -552,9 +546,9 @@ Configuration You may use the provided, pre-trained models or supply your own. To use a custom model, specify: -- `base_url`: URL from which to fetch model artifacts -- `model`: model name -- `checksum`: SHA256 digest for integrity validation -- `threshold`: probability threshold for classifying a request as malicious +- `base_url`: URL from which to fetch model artifacts +- `model`: model name +- `checksum`: SHA256 digest for integrity validation +- `threshold`: probability threshold for classifying a request as malicious -These parameters are loaded at startup and used to download, verify, and load the model/scaler if not already cached locally (in temp directory). \ No newline at end of file +These parameters are loaded at startup and used to download, verify, and load the model/scaler if not already cached locally (in temp directory). From 12c5387da664aeec51f06294805e6436bb05b0d1 Mon Sep 17 00:00:00 2001 From: Manuel Fuchs Date: Fri, 17 Oct 2025 14:11:06 +0200 Subject: [PATCH 62/62] Update Inspector usage section in pipeline.rst --- docs/pipeline.rst | 126 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 98 insertions(+), 28 deletions(-) diff --git a/docs/pipeline.rst b/docs/pipeline.rst index 62fdcfab..388c2938 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -456,46 +456,116 @@ The :class:`Inspector` class is responsible for: Usage ----- -The :class:`Inspector` loads the StreamAD model to perform anomaly detection. -It consumes batches on the topic ``inspect``, usually produced by the ``Prefilter``. -For a new batch, it derives the timestamps ``begin_timestamp`` and ``end_timestamp``. -Based on time type (e.g. ``s``, ``ms``) and time range (e.g. ``5``) the sliding non-overlapping window is created. -For univariate time-series, it counts the number of occurances, whereas for multivariate, it considers the number of occurances and packet size. :cite:`schuppen_fanci_2018` +Data Flow and Processing +........................ + +The :class:`Inspector` consumes batches from the Kafka topic ``prefilter_to_inspector`` and processes them through +the following workflow: + +1. **Batch Reception**: Receives batches with metadata (batch_id, begin_timestamp, end_timestamp) from the Prefilter +2. **Time Series Construction**: Creates time series features based on configurable time windows +3. **Anomaly Detection**: Applies StreamAD models to detect suspicious patterns +4. **Threshold Evaluation**: Evaluates anomaly scores against configured thresholds +5. **Suspicious Batch Forwarding**: Groups and forwards anomalous data by client IP to the Detector + +Time Series Feature Extraction +.............................. + +The Inspector creates time series features using sliding non-overlapping windows: + +- **Time Window Configuration**: Based on ``time_type`` (e.g., ``ms``) and ``time_range`` (e.g., ``20``) from configuration +- **Univariate Mode**: Counts message occurrences per time step for single-feature anomaly detection +- **Multivariate Mode**: Combines message counts and mean packet sizes for two-dimensional feature analysis +- **Ensemble Mode**: Uses message counts with multiple models combined through ensemble methods + +Anomaly Detection Logic +....................... + +The anomaly detection process evaluates suspicious patterns through a two-level threshold system: -An anomaly is noted when it is greater than a ``score_threshold``. -In addition, we support a relative anomaly threshold. -So, if the anomaly threshold is ``0.01``, it sends anomalies for further detection, if the amount of anomlies divided by the total amount of requests in the batch is greater than ``0.01``. +- **Score Threshold**: Individual time steps are flagged as anomalous when scores exceed ``score_threshold`` (default: 0.5) +- **Anomaly Threshold**: Batches are considered suspicious when the proportion of anomalous time steps exceeds ``anomaly_threshold`` (default: 0.01) +- **Client IP Grouping**: Suspicious batches are grouped by client IP and forwarded as separate suspicious batches to the Detector + +Error Handling and Monitoring +............................. + +The implementation includes comprehensive monitoring and error handling: + +- **Busy State Management**: Prevents new batch consumption while processing current data +- **Model Validation**: Validates model compatibility with selected detection mode +- **Fill Level Tracking**: Monitors data volumes throughout the processing pipeline +- **Graceful Degradation**: Handles empty batches and model loading failures appropriately Configuration ------------- -All StreamAD models are supported. This includes univariate, multivariate, and ensemble methods. -In case special arguments are desired for your environment, the ``model_args`` as a dictionary ``dict`` can be passed for each model. +The Inspector supports comprehensive configuration through the ``data_inspection.inspector`` section in ``config.yaml``. +All StreamAD models are supported, including univariate, multivariate, and ensemble methods. -Univariate models in `streamad.model`: +Detection Modes +................ + +Three detection modes are available: -- :class:`ZScoreDetector` -- :class:`KNNDetector` -- :class:`SpotDetector` -- :class:`SRDetector` -- :class:`OCSVMDetector` +- **Univariate Mode** (``mode: univariate``): Uses message count time series for anomaly detection +- **Multivariate Mode** (``mode: multivariate``): Combines message counts and mean packet sizes +- **Ensemble Mode** (``mode: ensemble``): Uses multiple models with ensemble combination methods -Multivariate models in `streamad.model`: -Currently, we rely on the packet size and number occurances for multivariate processing. +Model Configuration +................... -- :class:`xStreamDetector` -- :class:`RShashDetector` -- :class:`HSTreeDetector` -- :class:`LodaDetector` -- :class:`OCSVMDetector` -- :class:`RrcfDetector` +**Univariate Models** (``streamad.model``): -Ensemble prediction in ``streamad.process``: +- ``ZScoreDetector``: Statistical anomaly detection using z-scores +- ``KNNDetector``: K-nearest neighbors based detection +- ``SpotDetector``: Streaming peaks-over-threshold detection +- ``SRDetector``: Spectral residual based detection +- ``OCSVMDetector``: One-class SVM for anomaly detection +- ``MadDetector``: Median absolute deviation detection +- ``SArimaDetector``: Streaming ARIMA-based detection -- :class:`WeightEnsemble` -- :class:`VoteEnsemble` +**Multivariate Models** (``streamad.model``): + +- ``xStreamDetector``: Multi-dimensional streaming detection +- ``RShashDetector``: Random projection hash-based detection +- ``HSTreeDetector``: Half-space tree based detection +- ``LodaDetector``: Lightweight online detector of anomalies +- ``OCSVMDetector``: One-class SVM (supports multivariate) +- ``RrcfDetector``: Robust random cut forest detection + +**Ensemble Methods** (``streamad.process``): + +- ``WeightEnsemble``: Weighted combination of multiple detectors +- ``VoteEnsemble``: Voting-based ensemble prediction + +Configuration Parameters +......................... -It takes a list of ``streamad.model`` for perform the ensemble prediction. +.. code-block:: yaml + + data_inspection: + inspector: + mode: univariate # Detection mode: univariate, multivariate, ensemble + models: # List of models to use + - model: ZScoreDetector + module: streamad.model + model_args: + is_global: false + ensemble: # Ensemble configuration (when mode: ensemble) + model: WeightEnsemble + module: streamad.process + model_args: {} + anomaly_threshold: 0.01 # Proportion of anomalous time steps required + score_threshold: 0.5 # Individual score threshold for anomaly detection + time_type: ms # Time unit for window creation + time_range: 20 # Time range for each window step + +**Model Arguments**: Custom arguments for specific models can be provided via the ``model_args`` dictionary. +This allows fine-tuning of model parameters for specific deployment requirements. + +**Time Window Settings**: The ``time_type`` and ``time_range`` parameters control the granularity of time series analysis. +Current configuration uses 20-millisecond windows for high-resolution anomaly detection. .. _stage-5-detection: