poc3
This commit is contained in:
+341
@@ -0,0 +1,341 @@
|
||||
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}")
|
||||
Reference in New Issue
Block a user