Files
routeros-suricata-tzsp/app/metrics.py
T
2026-08-16 15:34:53 +02:00

391 lines
14 KiB
Python

from __future__ import annotations
import math
import re
from datetime import datetime
from typing import Any, Callable, Mapping
from .state import RuntimeStats
_METRIC_RE = re.compile(r"[^a-zA-Z0-9_:]")
def _metric_name(value: str) -> str:
name = _METRIC_RE.sub("_", str(value)).strip("_")
return re.sub(r"_+", "_", name)
def _label_value(value: Any) -> str:
return str(value).replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
def _number(value: Any) -> str | None:
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "+Inf" if value > 0 else "-Inf"
return repr(value)
return None
def _timestamp(value: Any) -> float | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
class PrometheusMetrics:
"""Render a scrape from already available in-memory state only.
Collectors registered here must expose cheap in-memory ``status()`` data.
The renderer deliberately does not call the application's health provider,
SQLite, Redis, RouterOS, filesystem scans, or analytics routines.
"""
def __init__(
self,
stats: RuntimeStats,
*,
version: str,
mode: str,
started_at: datetime,
state_provider: Callable[[], Mapping[str, Any]] | None = None,
flow_tracker: Any | None = None,
event_bus: Any | None = None,
live_pipeline: Any | None = None,
ndr_analyzer: Any | None = None,
notifier: Any | None = None,
forensic_pcap: Any | None = None,
) -> None:
self.stats = stats
self.version = str(version or "unknown")
self.mode = str(mode or "unknown")
self.started_at = started_at
self.state_provider = state_provider
self.flow_tracker = flow_tracker
self.event_bus = event_bus
self.live_pipeline = live_pipeline
self.ndr_analyzer = ndr_analyzer
self.notifier = notifier
self.forensic_pcap = forensic_pcap
def render(self) -> str:
lines: list[str] = []
self._emit(
lines,
"mikrosuricata_build_info",
1,
metric_type="gauge",
help_text="MikroSuricata build information.",
labels={"version": self.version, "mode": self.mode},
)
self._emit(
lines,
"mikrosuricata_process_start_time_seconds",
self.started_at.timestamp(),
metric_type="gauge",
help_text="Unix timestamp when the MikroSuricata process started.",
)
runtime = self.stats.metrics_snapshot()
suricata = runtime.pop("suricata", {}) or {}
suricata_stats_at = runtime.pop("suricata_stats_at", None)
for key in sorted(runtime):
value = runtime[key]
if key.endswith("_at"):
timestamp = _timestamp(value)
if timestamp is not None:
self._emit(
lines,
f"mikrosuricata_{_metric_name(key[:-3])}_timestamp_seconds",
timestamp,
metric_type="gauge",
)
continue
if _number(value) is not None:
self._emit(
lines,
f"mikrosuricata_{_metric_name(key)}_total",
value,
metric_type="counter",
)
timestamp = _timestamp(suricata_stats_at)
if timestamp is not None:
self._emit(
lines,
"mikrosuricata_suricata_stats_timestamp_seconds",
timestamp,
metric_type="gauge",
help_text="Unix timestamp of the latest Suricata stats event.",
)
# Suricata already computes these values and publishes them through EVE.
# Exporting the cached numeric snapshot avoids any control-socket request
# or work triggered specifically by a Prometheus scrape.
for key in sorted(suricata):
value = suricata[key]
if _number(value) is None:
continue
self._emit(
lines,
f"mikrosuricata_suricata_{_metric_name(key)}",
value,
)
self._emit_runtime_state(lines)
self._emit_flow_tracker_status(lines)
self._emit_status(
lines,
"event_bus",
self.event_bus,
gauges={"history_events", "subscribers"},
counters={"subscriber_dropped_events"},
)
self._emit_status(
lines,
"live_pipeline",
self.live_pipeline,
gauges={"writer_queue"},
counters={"writer_dropped", "writer_written", "throughput_written", "writer_batches", "writer_redis_errors"},
)
self._emit_status(
lines,
"ndr",
self.ndr_analyzer,
gauges={"enabled", "running", "queue", "auto_block", "auto_block_risk", "routeros_inventory_assets"},
counters={"dropped", "processed", "signals", "ioc_hits", "behavior_hits", "routeros_inventory_syncs"},
)
self._emit_status(
lines,
"notifier",
self.notifier,
gauges={"enabled", "running", "min_risk", "queue"},
counters={"sent", "failed", "dropped"},
)
self._emit_forensic_status(lines)
return "\n".join(lines) + "\n"
def _emit_runtime_state(self, lines: list[str]) -> None:
if self.state_provider is None:
return
state = dict(self.state_provider())
components = state.get("components") or {}
if isinstance(components, Mapping) and components:
lines.append("# HELP mikrosuricata_component_up Whether a core MikroSuricata component is running.")
lines.append("# TYPE mikrosuricata_component_up gauge")
for component, up in sorted(components.items()):
self._emit(
lines,
"mikrosuricata_component_up",
bool(up),
labels={"component": component},
)
features = state.get("features") or {}
if isinstance(features, Mapping) and features:
lines.append("# HELP mikrosuricata_feature_enabled Whether an optional MikroSuricata feature is enabled/configured.")
lines.append("# TYPE mikrosuricata_feature_enabled gauge")
for feature, enabled in sorted(features.items()):
self._emit(
lines,
"mikrosuricata_feature_enabled",
bool(enabled),
labels={"feature": feature},
)
def _emit_status(
self,
lines: list[str],
prefix: str,
source: Any | None,
*,
gauges: set[str],
counters: set[str],
) -> None:
if source is None:
return
status = source.status()
for key in sorted(gauges):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_{prefix}_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in sorted(counters):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_{prefix}_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
def _emit_flow_tracker_status(self, lines: list[str]) -> None:
if self.flow_tracker is None:
return
status = self.flow_tracker.status()
is_rust = str(status.get("engine") or "").lower() == "rust"
if is_rust:
self._emit(
lines,
"mikrosuricata_tzsp_receiver_info",
1,
metric_type="gauge",
help_text="TZSP packet data-plane implementation.",
labels={"engine": "rust"},
)
for key in (
"rcvbuf_bytes",
"batch_size",
"datagram_bytes",
"queue_capacity_batches",
"queue_capacity_bytes",
"queue_depth_batches",
"queue_high_water_batches",
"capture_efficiency_pct",
"rx_thread_alive",
"worker_thread_alive",
"telemetry_age_ms",
"process_alive",
"ready",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_tzsp_receiver_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in (
"kernel_udp_drops",
"queue_dropped_datagrams",
"truncated_datagrams",
"tzsp_datagrams",
"tzsp_rx_bytes",
"rx_batches",
"telemetry_errors",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_tzsp_receiver_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
else:
for key in ("active_flows", "max_flows", "update_interval_seconds"):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_flow_tracker_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
for key in ("published_updates", "evicted_flows", "parse_errors", "throughput_samples"):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_flow_tracker_{_metric_name(key)}_total",
status[key],
metric_type="counter",
)
traffic = status.get("traffic_counters") or {}
if not isinstance(traffic, Mapping):
return
directions = {
"total": ("bytes_total", "packets_total"),
"inbound": ("bytes_in", "packets_in"),
"outbound": ("bytes_out", "packets_out"),
"internal": ("bytes_internal", "packets_internal"),
"external": ("bytes_external", "packets_external"),
}
lines.append("# HELP mikrosuricata_traffic_bytes_total Captured TZSP traffic bytes by direction.")
lines.append("# TYPE mikrosuricata_traffic_bytes_total counter")
lines.append("# HELP mikrosuricata_traffic_packets_total Captured TZSP packets by direction.")
lines.append("# TYPE mikrosuricata_traffic_packets_total counter")
for direction, (bytes_key, packets_key) in directions.items():
if _number(traffic.get(bytes_key)) is not None:
self._emit(
lines,
"mikrosuricata_traffic_bytes_total",
traffic[bytes_key],
labels={"direction": direction},
)
if _number(traffic.get(packets_key)) is not None:
self._emit(
lines,
"mikrosuricata_traffic_packets_total",
traffic[packets_key],
labels={"direction": direction},
)
def _emit_forensic_status(self, lines: list[str]) -> None:
if self.forensic_pcap is None:
return
status = self.forensic_pcap.status()
for key in (
"buffered_frames",
"buffered_bytes",
"window_seconds",
"memory_bytes",
"max_files",
"max_total_bytes",
):
if key in status and _number(status[key]) is not None:
self._emit(
lines,
f"mikrosuricata_forensic_pcap_{_metric_name(key)}",
status[key],
metric_type="gauge",
)
mode = status.get("mode")
if mode:
self._emit(
lines,
"mikrosuricata_forensic_pcap_mode_info",
1,
metric_type="gauge",
labels={"mode": mode},
)
@staticmethod
def _emit(
lines: list[str],
name: str,
value: Any,
*,
metric_type: str | None = None,
help_text: str | None = None,
labels: Mapping[str, Any] | None = None,
) -> None:
number = _number(value)
if number is None:
return
if help_text is not None:
lines.append(f"# HELP {name} {help_text}")
if metric_type is not None:
lines.append(f"# TYPE {name} {metric_type}")
if labels:
rendered = ",".join(
f'{_metric_name(str(key))}="{_label_value(label_value)}"'
for key, label_value in sorted(labels.items())
)
lines.append(f"{name}{{{rendered}}} {number}")
else:
lines.append(f"{name} {number}")