mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(monitoring): harden gateway health OTLP egress
This commit is contained in:
parent
87a15733c0
commit
16e774f223
8 changed files with 379 additions and 58 deletions
|
|
@ -114,12 +114,15 @@ class MonitoringEmitter:
|
|||
"""Register a live batch subscriber (callable(batch: list[dict]))."""
|
||||
if callback not in self._subscribers:
|
||||
self._subscribers.append(callback)
|
||||
self._enabled = True
|
||||
|
||||
def unsubscribe(self, callback) -> None:
|
||||
try:
|
||||
self._subscribers.remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
if not self._subscribers:
|
||||
self._enabled = False
|
||||
|
||||
# ── introspection / shutdown (tests, CLI) ───────────────────────────────
|
||||
def flush(self, timeout: float = 2.0) -> None:
|
||||
|
|
@ -160,7 +163,9 @@ def get_emitter() -> MonitoringEmitter:
|
|||
return _EMITTER
|
||||
with _EMITTER_LOCK:
|
||||
if _EMITTER is None:
|
||||
_EMITTER = MonitoringEmitter()
|
||||
# Collection is opt-in. A plane exporter enables the singleton by
|
||||
# attaching its first subscriber; until then producers are no-ops.
|
||||
_EMITTER = MonitoringEmitter(enabled=False)
|
||||
return _EMITTER
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ session history, audit records, or product analytics belong here.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -30,6 +31,10 @@ class GatewayHealthSnapshot:
|
|||
|
||||
_RUNNING_PLATFORM_STATES = {"running", "connected", "ok", "ready"}
|
||||
_FATAL_PLATFORM_STATES = {"fatal", "degraded", "error", "failed"}
|
||||
_KNOWN_STATES = _RUNNING_PLATFORM_STATES | _FATAL_PLATFORM_STATES | {
|
||||
"starting", "draining", "stopping", "stopped", "startup_failed", "unknown"
|
||||
}
|
||||
_SUPERVISION_MODES = {"systemd", "s6", "container", "launchd", "manual", "unknown"}
|
||||
|
||||
|
||||
def _allowed_logger(name: str) -> bool:
|
||||
|
|
@ -70,6 +75,46 @@ def classify_gateway_error(raw: Any) -> str:
|
|||
return "unknown"
|
||||
|
||||
|
||||
def classify_exit_reason(
|
||||
raw: Any, *, state: Any, restart_requested: bool
|
||||
) -> Optional[str]:
|
||||
"""Reduce free-form shutdown text to a bounded operational class."""
|
||||
if restart_requested:
|
||||
return "restart_requested"
|
||||
state_name = str(state or "").lower()
|
||||
if raw is None and state_name != "startup_failed":
|
||||
return None
|
||||
classified = classify_gateway_error(raw)
|
||||
if state_name == "startup_failed":
|
||||
return classified if classified != "unknown" else "startup_failed"
|
||||
text = str(raw or "").lower()
|
||||
if "signal" in text or "sigterm" in text or "sigint" in text:
|
||||
return "signal"
|
||||
if state_name == "stopped" and any(word in text for word in ("shutdown", "stop")):
|
||||
return "planned_stop"
|
||||
return classified
|
||||
|
||||
|
||||
def _bounded_state(raw: Any) -> str:
|
||||
state = str(raw or "unknown").lower()
|
||||
return state if state in _KNOWN_STATES else "unknown"
|
||||
|
||||
|
||||
def _safe_metric_value(raw: Any, *, limit: int = 128) -> str:
|
||||
try:
|
||||
from agent.monitoring.redaction import redact_for_export
|
||||
value = redact_for_export(str(raw or "")) or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return value[:limit]
|
||||
|
||||
|
||||
def _safe_instance_id(raw: Any) -> str:
|
||||
"""Return a stable opaque instance key without exporting the source ID."""
|
||||
value = str(raw or "unknown").encode("utf-8", errors="replace")
|
||||
return f"sha256:{hashlib.sha256(value).hexdigest()[:24]}"
|
||||
|
||||
|
||||
def subsystem_for_logger(logger_name: str) -> str:
|
||||
if logger_name.startswith("gateway.platforms."):
|
||||
parts = logger_name.split(".")
|
||||
|
|
@ -120,11 +165,11 @@ def _derive_drainable(gateway_running: bool, gateway_state: Any) -> bool:
|
|||
|
||||
|
||||
def _base_attrs(*, profile: str, install_id: str, version: str, supervision_mode: str) -> Dict[str, str]:
|
||||
mode = str(supervision_mode or "unknown").lower()
|
||||
return {
|
||||
"hermes.profile": str(profile),
|
||||
"service.instance.id": str(install_id),
|
||||
"service.version": str(version),
|
||||
"hermes.supervision_mode": str(supervision_mode),
|
||||
"service.instance.id": _safe_instance_id(install_id),
|
||||
"service.version": _safe_metric_value(version, limit=64),
|
||||
"hermes.supervision_mode": mode if mode in _SUPERVISION_MODES else "unknown",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -132,7 +177,7 @@ def _metric(name: str, value: int | float, attrs: Dict[str, str], **extra: str)
|
|||
out = dict(attrs)
|
||||
for key, val in extra.items():
|
||||
if val is not None:
|
||||
out[key] = str(val)
|
||||
out[key] = _safe_metric_value(val)
|
||||
return GatewayMetric(name=name, value=value, attributes=out)
|
||||
|
||||
|
||||
|
|
@ -147,7 +192,7 @@ def build_gateway_health_snapshot(
|
|||
) -> GatewayHealthSnapshot:
|
||||
"""Convert gateway_state.json-compatible runtime state into P0 signals."""
|
||||
runtime = runtime or {}
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_state = _bounded_state(runtime.get("gateway_state"))
|
||||
active_agents = _parse_active_agents(runtime.get("active_agents", 0))
|
||||
busy = _derive_busy(gateway_running, gateway_state, active_agents)
|
||||
drainable = _derive_drainable(gateway_running, gateway_state)
|
||||
|
|
@ -168,7 +213,7 @@ def build_gateway_health_snapshot(
|
|||
events: list[GatewayHealthEvent | GatewayDiagnosticEvent] = []
|
||||
for platform, pdata in platforms.items():
|
||||
pdata = pdata if isinstance(pdata, dict) else {}
|
||||
state = str(pdata.get("state") or "unknown").lower()
|
||||
state = _bounded_state(pdata.get("state"))
|
||||
raw_error = pdata.get("error_code") or pdata.get("error_message")
|
||||
error_code = classify_gateway_error(raw_error)
|
||||
is_up = state in _RUNNING_PLATFORM_STATES
|
||||
|
|
@ -244,15 +289,19 @@ def emit_runtime_status_transition(previous: Optional[dict[str, Any]], current:
|
|||
out: list[GatewayHealthEvent | GatewayDiagnosticEvent] = []
|
||||
profile = _safe_profile()
|
||||
version = _safe_version()
|
||||
old_gateway_state = str((previous or {}).get("gateway_state")) if (previous or {}).get("gateway_state") is not None else None
|
||||
new_gateway_state = str(current.get("gateway_state")) if current.get("gateway_state") is not None else None
|
||||
old_gateway_state = _bounded_state((previous or {}).get("gateway_state")) if (previous or {}).get("gateway_state") is not None else None
|
||||
new_gateway_state = _bounded_state(current.get("gateway_state")) if current.get("gateway_state") is not None else None
|
||||
if old_gateway_state != new_gateway_state and new_gateway_state:
|
||||
out.append(GatewayHealthEvent(
|
||||
name="gateway.lifecycle",
|
||||
gateway_state=new_gateway_state,
|
||||
old_state=old_gateway_state,
|
||||
new_state=new_gateway_state,
|
||||
exit_reason=current.get("exit_reason"),
|
||||
exit_reason=classify_exit_reason(
|
||||
current.get("exit_reason"),
|
||||
state=new_gateway_state,
|
||||
restart_requested=bool(current.get("restart_requested")),
|
||||
),
|
||||
restart_requested=bool(current.get("restart_requested")),
|
||||
active_agents=_parse_active_agents(current.get("active_agents", 0)),
|
||||
profile=profile,
|
||||
|
|
@ -276,7 +325,11 @@ def emit_runtime_status_transition(previous: Optional[dict[str, Any]], current:
|
|||
gateway_state=new_gateway_state,
|
||||
old_state=old_gateway_state,
|
||||
new_state=new_gateway_state,
|
||||
exit_reason=current.get("exit_reason"),
|
||||
exit_reason=classify_exit_reason(
|
||||
current.get("exit_reason"),
|
||||
state=new_gateway_state,
|
||||
restart_requested=bool(current.get("restart_requested")),
|
||||
),
|
||||
restart_requested=bool(current.get("restart_requested")),
|
||||
active_agents=_parse_active_agents(current.get("active_agents", 0)),
|
||||
profile=profile,
|
||||
|
|
@ -292,8 +345,8 @@ def emit_runtime_status_transition(previous: Optional[dict[str, Any]], current:
|
|||
pdata = pdata if isinstance(pdata, dict) else {}
|
||||
prev_raw = old_platforms.get(platform, {})
|
||||
prev = prev_raw if isinstance(prev_raw, dict) else {}
|
||||
old_state = str(prev.get("state")) if prev.get("state") is not None else None
|
||||
new_state = str(pdata.get("state")) if pdata.get("state") is not None else None
|
||||
old_state = _bounded_state(prev.get("state")) if prev.get("state") is not None else None
|
||||
new_state = _bounded_state(pdata.get("state")) if pdata.get("state") is not None else None
|
||||
if old_state == new_state or not new_state:
|
||||
continue
|
||||
error_code = classify_gateway_error(pdata.get("error_code") or pdata.get("error_message"))
|
||||
|
|
|
|||
|
|
@ -10,12 +10,77 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RESOURCE_ATTRIBUTE_KEYS = frozenset({
|
||||
"service.name",
|
||||
"service.namespace",
|
||||
"service.version",
|
||||
"service.instance.id",
|
||||
"deployment.environment.name",
|
||||
"cloud.provider",
|
||||
"cloud.platform",
|
||||
"cloud.region",
|
||||
"telemetry.scope",
|
||||
})
|
||||
_DIAGNOSTIC_ATTRIBUTE_KEYS = frozenset({
|
||||
"name",
|
||||
"subsystem",
|
||||
"error_class",
|
||||
"error_code",
|
||||
"platform",
|
||||
"old_state",
|
||||
"new_state",
|
||||
"version",
|
||||
"severity",
|
||||
})
|
||||
_SAFE_RESOURCE_VALUE = re.compile(r"^[A-Za-z0-9._:/-]{1,128}$")
|
||||
|
||||
|
||||
def _redact_string(raw: Any, *, limit: int = 500) -> str:
|
||||
try:
|
||||
from agent.monitoring.redaction import redact_for_export
|
||||
return (redact_for_export(str(raw or "")) or "[redacted]")[:limit]
|
||||
except Exception:
|
||||
return "[redaction-unavailable]"
|
||||
|
||||
|
||||
def _safe_resource_attributes(raw: Any) -> Dict[str, str]:
|
||||
"""Allowlist bounded resource labels and reject values changed by redaction."""
|
||||
attrs: Dict[str, str] = {}
|
||||
if not isinstance(raw, dict):
|
||||
return attrs
|
||||
for key, value in raw.items():
|
||||
key = str(key)
|
||||
if key not in _RESOURCE_ATTRIBUTE_KEYS or value is None:
|
||||
continue
|
||||
if key == "service.instance.id":
|
||||
from agent.monitoring.gateway_health import _safe_instance_id
|
||||
attrs[key] = _safe_instance_id(value)
|
||||
continue
|
||||
text = str(value)
|
||||
if not _SAFE_RESOURCE_VALUE.fullmatch(text):
|
||||
continue
|
||||
if _redact_string(text, limit=128) != text:
|
||||
continue
|
||||
attrs[key] = text
|
||||
return attrs
|
||||
|
||||
|
||||
def _diagnostic_log_attributes(event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
attrs: Dict[str, Any] = {}
|
||||
for key in _DIAGNOSTIC_ATTRIBUTE_KEYS:
|
||||
value = event.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
attrs[f"hermes.{key}"] = _redact_string(value) if isinstance(value, str) else value
|
||||
return attrs
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GatewayHealthExportRuntime:
|
||||
|
|
@ -32,27 +97,52 @@ class GatewayHealthExportRuntime:
|
|||
if self.stop_event is not None:
|
||||
self.stop_event.set()
|
||||
if self.thread is not None:
|
||||
self.thread.join(timeout=2.0)
|
||||
self.thread.join(timeout=0.25)
|
||||
if self.log_handler is not None:
|
||||
try:
|
||||
logging.getLogger().removeHandler(self.log_handler)
|
||||
except Exception:
|
||||
pass
|
||||
if self.streamer is not None:
|
||||
try:
|
||||
self.streamer.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
if self.log_streamer is not None:
|
||||
try:
|
||||
self.log_streamer.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
if self.metric_provider is not None:
|
||||
try:
|
||||
self.metric_provider.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Detach synchronously so no new records are collected during a slow
|
||||
# exporter shutdown. Network flush/close then runs under one bounded
|
||||
# daemon-thread deadline and can never delay gateway teardown.
|
||||
try:
|
||||
from agent.monitoring.emitter import get_emitter
|
||||
emitter = get_emitter()
|
||||
if self.streamer is not None:
|
||||
emitter.unsubscribe(self.streamer)
|
||||
if self.log_streamer is not None:
|
||||
emitter.unsubscribe(self.log_streamer)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
closeables = [
|
||||
item for item in (self.streamer, self.log_streamer, self.metric_provider)
|
||||
if item is not None
|
||||
]
|
||||
|
||||
def _close() -> None:
|
||||
for item in closeables:
|
||||
try:
|
||||
item.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if closeables:
|
||||
worker = threading.Thread(
|
||||
target=_close,
|
||||
name="hermes-gateway-health-export-shutdown",
|
||||
daemon=True,
|
||||
)
|
||||
worker.start()
|
||||
worker.join(timeout=2.0)
|
||||
|
||||
self.streamer = None
|
||||
self.log_streamer = None
|
||||
self.metric_provider = None
|
||||
self.thread = None
|
||||
self.stop_event = None
|
||||
|
||||
|
||||
def _gateway_health_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -209,9 +299,9 @@ def _start_metric_provider(config: Dict[str, Any], sdk: Dict[str, Any]) -> Any:
|
|||
exporter = sdk["OTLPMetricExporter"](endpoint=endpoint, headers=headers or None)
|
||||
interval_ms = max(5, int(gh.get("export_interval_seconds", 60))) * 1000
|
||||
reader = sdk["PeriodicExportingMetricReader"](exporter, export_interval_millis=interval_ms)
|
||||
resource_attrs = dict((gh.get("resource_attributes") or {}))
|
||||
resource_attrs.setdefault("service.name", "hermes-gateway")
|
||||
resource_attrs.setdefault("telemetry.scope", "gateway_health")
|
||||
resource_attrs = _safe_resource_attributes(gh.get("resource_attributes"))
|
||||
resource_attrs["service.name"] = "hermes-gateway"
|
||||
resource_attrs["telemetry.scope"] = "gateway_health"
|
||||
provider = sdk["MeterProvider"](
|
||||
metric_readers=[reader],
|
||||
resource=sdk["Resource"].create(resource_attrs),
|
||||
|
|
@ -267,9 +357,9 @@ class GatewayDiagnosticLogStreamer:
|
|||
gh = _gateway_health_config(config)
|
||||
headers = _resolve_headers(otlp.get("headers_env"))
|
||||
endpoint = _logs_endpoint(str(otlp.get("endpoint")))
|
||||
resource_attrs = dict((gh.get("resource_attributes") or {}))
|
||||
resource_attrs.setdefault("service.name", "hermes-gateway")
|
||||
resource_attrs.setdefault("telemetry.scope", "gateway_diagnostics")
|
||||
resource_attrs = _safe_resource_attributes(gh.get("resource_attributes"))
|
||||
resource_attrs["service.name"] = "hermes-gateway"
|
||||
resource_attrs["telemetry.scope"] = "gateway_diagnostics"
|
||||
self._provider = sdk["LoggerProvider"](resource=sdk["Resource"].create(resource_attrs))
|
||||
self._processor = sdk["BatchLogRecordProcessor"](
|
||||
sdk["OTLPLogExporter"](endpoint=endpoint, headers=headers or None)
|
||||
|
|
@ -284,11 +374,7 @@ class GatewayDiagnosticLogStreamer:
|
|||
for ev in batch:
|
||||
if ev.get("event") != "gateway_diagnostic":
|
||||
continue
|
||||
attrs = {
|
||||
f"hermes.{key}": val
|
||||
for key, val in ev.items()
|
||||
if key not in {"event", "redacted_message", "ts_ns"} and val is not None
|
||||
}
|
||||
attrs = _diagnostic_log_attributes(ev)
|
||||
body = ev.get("redacted_message") or ev.get("name") or "gateway diagnostic"
|
||||
record = self._LogRecord(
|
||||
timestamp=ev.get("ts_ns"),
|
||||
|
|
@ -297,7 +383,7 @@ class GatewayDiagnosticLogStreamer:
|
|||
trace_flags=self._sdk["TraceFlags"].DEFAULT,
|
||||
severity_text=str(ev.get("severity") or "warning").upper(),
|
||||
severity_number=_severity_number(self._sdk, ev.get("severity")),
|
||||
body=str(body),
|
||||
body=_redact_string(body),
|
||||
attributes=attrs,
|
||||
)
|
||||
self._logger.emit(record)
|
||||
|
|
@ -337,7 +423,7 @@ def _start_snapshot_thread(config: Dict[str, Any], stop_event: threading.Event)
|
|||
|
||||
def _attach_log_handler(config: Dict[str, Any]) -> Any:
|
||||
gh = _gateway_health_config(config)
|
||||
if not gh.get("warning_error_events_enabled", True):
|
||||
if not gh.get("diagnostic_events_enabled", True) or not gh.get("warning_error_events_enabled", True):
|
||||
return None
|
||||
from agent.monitoring.gateway_health import GatewayDiagnosticLogHandler
|
||||
handler = GatewayDiagnosticLogHandler(profile=_profile(), version=_version())
|
||||
|
|
@ -382,20 +468,25 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
|
|||
try:
|
||||
from agent.monitoring import otlp_exporter
|
||||
runtime.streamer = otlp_exporter.start_streaming(config, event_filter=_gateway_health_event)
|
||||
if runtime.streamer is None:
|
||||
raise RuntimeError("gateway health span streamer did not start")
|
||||
runtime.log_streamer = _start_diagnostic_log_streamer(config, sdk)
|
||||
except Exception:
|
||||
logger.debug("gateway diagnostic OTLP export failed to start", exc_info=True)
|
||||
runtime.shutdown()
|
||||
return GatewayHealthExportRuntime(enabled=False, reason="diagnostics_start_failed")
|
||||
|
||||
try:
|
||||
runtime.log_handler = _attach_log_handler(config)
|
||||
except Exception:
|
||||
logger.debug("gateway diagnostic log handler failed to attach", exc_info=True)
|
||||
try:
|
||||
_emit_snapshot_events(config)
|
||||
runtime.stop_event = threading.Event()
|
||||
runtime.thread = _start_snapshot_thread(config, runtime.stop_event)
|
||||
except Exception:
|
||||
logger.debug("gateway health snapshot thread failed to start", exc_info=True)
|
||||
if gh.get("diagnostic_events_enabled", True):
|
||||
try:
|
||||
_emit_snapshot_events(config)
|
||||
runtime.stop_event = threading.Event()
|
||||
runtime.thread = _start_snapshot_thread(config, runtime.stop_event)
|
||||
except Exception:
|
||||
logger.debug("gateway health snapshot thread failed to start", exc_info=True)
|
||||
return runtime
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -136,15 +136,21 @@ def _span_attrs(ev: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"gateway_health": ("name", "gateway_state", "old_state", "new_state",
|
||||
"exit_reason", "restart_requested", "active_agents",
|
||||
"gateway_busy", "gateway_drainable", "platform_count",
|
||||
"fatal_platform_count", "profile", "install_id", "version",
|
||||
"fatal_platform_count", "version",
|
||||
"supervision_mode", "pid"),
|
||||
"gateway_diagnostic": ("name", "subsystem", "error_class", "error_code",
|
||||
"redacted_message", "platform", "old_state", "new_state",
|
||||
"profile", "version", "severity"),
|
||||
"version", "severity"),
|
||||
}
|
||||
for col in keep_by_kind.get(kind, ()): # type: ignore[arg-type]
|
||||
v = ev.get(col)
|
||||
if v is not None:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
from agent.monitoring.redaction import redact_for_export
|
||||
v = (redact_for_export(v) or "[redacted]")[:500]
|
||||
except Exception:
|
||||
v = "[redaction-unavailable]"
|
||||
attrs[f"hermes.{col}"] = v
|
||||
return attrs
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ trajectory capture is a separate plane served by the NeMo Relay integration
|
|||
| Health/lifecycle events | `/v1/traces` | `gateway.lifecycle` state transitions (`starting -> running -> draining -> stopped`, `startup_failed`, exit), `gateway.health_snapshot`, platform state changes |
|
||||
| Diagnostics | `/v1/logs` | Warning/error gateway log events with secrets AND PII scrubbed in-process before egress (`[redacted]` / `[email]`), bounded error classes |
|
||||
|
||||
Every signal carries resource attributes (`service.name`, profile, version,
|
||||
install id, supervision mode) so an operator can tell instances apart.
|
||||
Signals carry `service.name`, version, supervision mode, and a stable one-way
|
||||
hash of the install id so an operator can distinguish instances without
|
||||
exporting account/profile identity or the raw install identifier.
|
||||
|
||||
## Enabling
|
||||
|
||||
|
|
|
|||
|
|
@ -3051,7 +3051,7 @@ DEFAULT_CONFIG = {
|
|||
"logs_export_interval_seconds": 5,
|
||||
"resource_attributes": {
|
||||
"service.name": "hermes-gateway",
|
||||
"deployment.environment": "production",
|
||||
"deployment.environment.name": "production",
|
||||
},
|
||||
},
|
||||
# OTLP destination. headers_env maps header names to ENVIRONMENT
|
||||
|
|
|
|||
|
|
@ -15,6 +15,25 @@ def test_emit_never_raises_when_disabled():
|
|||
em.close()
|
||||
|
||||
|
||||
def test_process_singleton_stays_dormant_until_subscribed():
|
||||
from agent.monitoring import emitter
|
||||
|
||||
emitter.reset_emitter_for_tests()
|
||||
try:
|
||||
emitter.emit({"event": "gateway_health", "name": "gateway.lifecycle"})
|
||||
singleton = emitter.get_emitter()
|
||||
assert singleton.stats()["queued"] == 0
|
||||
assert singleton._started is False
|
||||
|
||||
subscriber = lambda _batch: None # noqa: E731
|
||||
singleton.subscribe(subscriber)
|
||||
emitter.emit({"event": "gateway_health", "name": "gateway.lifecycle"})
|
||||
assert singleton._started is True
|
||||
singleton.unsubscribe(subscriber)
|
||||
finally:
|
||||
emitter.reset_emitter_for_tests()
|
||||
|
||||
|
||||
def test_emit_accepts_dataclass_and_dict(tmp_path):
|
||||
em = MonitoringEmitter()
|
||||
seen: list = []
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ def test_default_config_keeps_gateway_health_export_disabled():
|
|||
assert cfg["warning_error_events_enabled"] is True
|
||||
assert cfg["export_interval_seconds"] == 60
|
||||
assert cfg["logs_export_interval_seconds"] == 5
|
||||
assert cfg["resource_attributes"]["deployment.environment.name"] == "production"
|
||||
assert "deployment.environment" not in cfg["resource_attributes"]
|
||||
# Redaction is always-on and deliberately NOT configurable.
|
||||
assert "redaction" not in cfg
|
||||
|
||||
|
|
@ -59,11 +61,12 @@ def test_gateway_health_snapshot_maps_runtime_status_to_low_cardinality_metrics(
|
|||
active = next(m for m in snapshot.metrics if m.name == "hermes.gateway.active_agents")
|
||||
assert active.value == 2
|
||||
assert active.attributes == {
|
||||
"hermes.profile": "default",
|
||||
"service.instance.id": "install-1",
|
||||
"service.instance.id": active.attributes["service.instance.id"],
|
||||
"service.version": "2026.7.test",
|
||||
"hermes.supervision_mode": "manual",
|
||||
}
|
||||
assert active.attributes["service.instance.id"].startswith("sha256:")
|
||||
assert "install-1" not in active.attributes["service.instance.id"]
|
||||
|
||||
busy = next(m for m in snapshot.metrics if m.name == "hermes.gateway.busy")
|
||||
drainable = next(m for m in snapshot.metrics if m.name == "hermes.gateway.drainable")
|
||||
|
|
@ -183,6 +186,7 @@ def test_runtime_status_transition_emits_lifecycle_and_platform_events(monkeypat
|
|||
lifecycle = next(e for e in captured if e["name"] == "gateway.lifecycle")
|
||||
assert lifecycle["old_state"] == "starting"
|
||||
assert lifecycle["new_state"] == "running"
|
||||
assert lifecycle["exit_reason"] is None
|
||||
platform = next(e for e in captured if e["name"] == "platform.state_change")
|
||||
assert platform["old_state"] == "running"
|
||||
assert platform["new_state"] == "fatal"
|
||||
|
|
@ -198,8 +202,21 @@ def test_runtime_status_transition_emits_startup_failed_and_exit():
|
|||
old = emitter.emit
|
||||
emitter.emit = lambda event: captured.append(event.to_dict()) # type: ignore[assignment]
|
||||
try:
|
||||
emit_runtime_status_transition({"gateway_state": "starting"}, {"gateway_state": "startup_failed", "exit_reason": "startup token ***"})
|
||||
emit_runtime_status_transition({"gateway_state": "running"}, {"gateway_state": "stopped", "exit_reason": "shutdown", "restart_requested": True})
|
||||
emit_runtime_status_transition(
|
||||
{"gateway_state": "starting"},
|
||||
{
|
||||
"gateway_state": "startup_failed",
|
||||
"exit_reason": "Bearer top-secret-token rejected for user@example.com",
|
||||
},
|
||||
)
|
||||
emit_runtime_status_transition(
|
||||
{"gateway_state": "running"},
|
||||
{
|
||||
"gateway_state": "stopped",
|
||||
"exit_reason": "shutdown requested by user@example.com",
|
||||
"restart_requested": True,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
emitter.emit = old # type: ignore[assignment]
|
||||
|
||||
|
|
@ -208,8 +225,11 @@ def test_runtime_status_transition_emits_startup_failed_and_exit():
|
|||
assert "gateway.exit" in names
|
||||
failed = next(e for e in captured if e["name"] == "gateway.startup_failed")
|
||||
assert "***" not in failed["redacted_message"]
|
||||
lifecycle = next(e for e in captured if e["name"] == "gateway.lifecycle")
|
||||
assert lifecycle["exit_reason"] == "auth_failed"
|
||||
exit_event = next(e for e in captured if e["name"] == "gateway.exit")
|
||||
assert exit_event["restart_requested"] is True
|
||||
assert exit_event["exit_reason"] == "restart_requested"
|
||||
|
||||
|
||||
def test_otlp_attrs_include_gateway_transition_fields():
|
||||
|
|
@ -230,6 +250,72 @@ def test_otlp_attrs_include_gateway_transition_fields():
|
|||
assert attrs["hermes.restart_requested"] is True
|
||||
|
||||
|
||||
def test_otlp_attrs_redact_strings_and_never_export_profile():
|
||||
from agent.monitoring.otlp_exporter import _span_attrs
|
||||
|
||||
attrs = _span_attrs({
|
||||
"event": "gateway_health",
|
||||
"name": "gateway.lifecycle",
|
||||
"profile": "user@example.com",
|
||||
"exit_reason": "Bearer top-secret-token for user@example.com",
|
||||
})
|
||||
|
||||
assert "hermes.profile" not in attrs
|
||||
assert "top-secret-token" not in str(attrs)
|
||||
assert "user@example.com" not in str(attrs)
|
||||
|
||||
|
||||
def test_resource_attributes_are_allowlisted_and_sanitized():
|
||||
from agent.monitoring.gateway_health_export import _safe_resource_attributes
|
||||
|
||||
attrs = _safe_resource_attributes({
|
||||
"service.name": "hermes-gateway",
|
||||
"service.instance.id": "install-1",
|
||||
"deployment.environment.name": "staging",
|
||||
"user.email": "user@example.com",
|
||||
"authorization": "Bearer top-secret-token",
|
||||
"custom.request.id": "unbounded",
|
||||
})
|
||||
|
||||
assert attrs == {
|
||||
"service.name": "hermes-gateway",
|
||||
"service.instance.id": attrs["service.instance.id"],
|
||||
"deployment.environment.name": "staging",
|
||||
}
|
||||
assert attrs["service.instance.id"].startswith("sha256:")
|
||||
assert "install-1" not in attrs["service.instance.id"]
|
||||
|
||||
|
||||
def test_instance_id_hash_is_stable_and_distinguishes_instances():
|
||||
from agent.monitoring.gateway_health import _safe_instance_id
|
||||
|
||||
first = _safe_instance_id("install-1")
|
||||
repeat = _safe_instance_id("install-1")
|
||||
second = _safe_instance_id("install-2")
|
||||
|
||||
assert first == repeat
|
||||
assert first != second
|
||||
assert first.startswith("sha256:")
|
||||
assert "install-1" not in first
|
||||
|
||||
|
||||
def test_diagnostic_log_attributes_are_allowlisted_redacted_and_profile_free():
|
||||
from agent.monitoring.gateway_health_export import _diagnostic_log_attributes
|
||||
|
||||
attrs = _diagnostic_log_attributes({
|
||||
"event": "gateway_diagnostic",
|
||||
"name": "platform.fatal",
|
||||
"subsystem": "platform.slack",
|
||||
"profile": "user@example.com",
|
||||
"error_code": "Bearer top-secret-token",
|
||||
"custom": "must-not-egress",
|
||||
})
|
||||
|
||||
assert "hermes.profile" not in attrs
|
||||
assert "hermes.custom" not in attrs
|
||||
assert "top-secret-token" not in str(attrs)
|
||||
|
||||
|
||||
def test_gateway_health_export_start_is_fail_open_when_otlp_missing(monkeypatch):
|
||||
from agent.monitoring import gateway_health_export
|
||||
from agent.monitoring.gateway_health_export import GatewayHealthExportRuntime
|
||||
|
|
@ -259,6 +345,7 @@ def test_gateway_health_export_streams_only_gateway_events(monkeypatch):
|
|||
|
||||
monkeypatch.setattr(gateway_health_export, "_start_metric_provider", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway_health_export, "_require_metrics_sdk", lambda *a, **k: {})
|
||||
monkeypatch.setattr(gateway_health_export, "_start_diagnostic_log_streamer", lambda *a, **k: object())
|
||||
monkeypatch.setattr(gateway_health_export, "_attach_log_handler", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway_health_export, "_emit_snapshot_events", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway_health_export, "_start_snapshot_thread", lambda *a, **k: None)
|
||||
|
|
@ -301,6 +388,65 @@ def test_gateway_health_export_metric_failure_does_not_start_streamer(monkeypatc
|
|||
assert started == []
|
||||
|
||||
|
||||
def test_gateway_health_export_diagnostic_partial_start_cleans_up(monkeypatch):
|
||||
from agent.monitoring import emitter, gateway_health_export, otlp_exporter
|
||||
|
||||
class Streamer:
|
||||
def __call__(self, _batch):
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
streamer = Streamer()
|
||||
monkeypatch.setattr(gateway_health_export, "_require_metrics_sdk", lambda *a, **k: {})
|
||||
monkeypatch.setattr(gateway_health_export, "_start_metric_provider", lambda *a, **k: None)
|
||||
monkeypatch.setattr(otlp_exporter, "start_streaming", lambda *a, **k: streamer)
|
||||
monkeypatch.setattr(
|
||||
gateway_health_export,
|
||||
"_start_diagnostic_log_streamer",
|
||||
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
emitter.get_emitter().subscribe(streamer)
|
||||
|
||||
runtime = gateway_health_export.start_gateway_health_export({
|
||||
"monitoring": {
|
||||
"gateway_health_export": {"enabled": True, "metrics_enabled": False},
|
||||
"export": {"otlp": {"enabled": True, "endpoint": "http://collector:4318/v1/traces"}},
|
||||
}
|
||||
})
|
||||
|
||||
assert runtime.enabled is False
|
||||
assert runtime.reason == "diagnostics_start_failed"
|
||||
assert streamer not in emitter.get_emitter()._subscribers
|
||||
|
||||
|
||||
def test_gateway_health_export_shutdown_is_bounded():
|
||||
import threading
|
||||
import time
|
||||
|
||||
from agent.monitoring.gateway_health_export import GatewayHealthExportRuntime
|
||||
|
||||
release = threading.Event()
|
||||
|
||||
class Blocking:
|
||||
def shutdown(self):
|
||||
release.wait(10)
|
||||
|
||||
runtime = GatewayHealthExportRuntime(
|
||||
enabled=True,
|
||||
streamer=Blocking(),
|
||||
log_streamer=Blocking(),
|
||||
metric_provider=Blocking(),
|
||||
)
|
||||
started = time.monotonic()
|
||||
runtime.shutdown()
|
||||
elapsed = time.monotonic() - started
|
||||
release.set()
|
||||
|
||||
assert elapsed < 2.5
|
||||
|
||||
|
||||
def test_otlp_streamer_shutdown_unsubscribes(monkeypatch):
|
||||
from agent.monitoring import emitter
|
||||
from agent.monitoring.otlp_exporter import OTLPStreamer
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue