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() 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}")