mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
729 lines
25 KiB
Python
729 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
|
|
def test_gateway_diagnostic_event_preserves_positional_error_class():
|
|
from agent.monitoring.events import GatewayDiagnosticEvent
|
|
|
|
event = GatewayDiagnosticEvent("gateway.log.warning", "gateway", "auth_failed")
|
|
|
|
assert event.error_class == "auth_failed"
|
|
assert event.source_logger is None
|
|
|
|
|
|
def test_default_config_keeps_gateway_health_export_disabled():
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
cfg = DEFAULT_CONFIG["monitoring"]["gateway_health_export"]
|
|
|
|
assert cfg["enabled"] is False
|
|
assert cfg["metrics_enabled"] is True
|
|
assert cfg["diagnostic_events_enabled"] is True
|
|
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
|
|
|
|
|
|
def test_gateway_health_snapshot_maps_runtime_status_to_low_cardinality_metrics():
|
|
from agent.monitoring.gateway_health import build_gateway_health_snapshot
|
|
|
|
runtime = {
|
|
"gateway_state": "running",
|
|
"pid": 1234,
|
|
"active_agents": "2",
|
|
"restart_requested": False,
|
|
"platforms": {
|
|
"slack": {"state": "running"},
|
|
"telegram": {
|
|
"state": "fatal",
|
|
"error_code": "auth_failed",
|
|
"error_message": "token xoxb-secret rejected for user 123",
|
|
},
|
|
},
|
|
}
|
|
|
|
snapshot = build_gateway_health_snapshot(
|
|
runtime,
|
|
gateway_running=True,
|
|
profile="default",
|
|
install_id="install-1",
|
|
version="2026.7.test",
|
|
supervision_mode="manual",
|
|
)
|
|
|
|
metric_names = {m.name for m in snapshot.metrics}
|
|
assert {
|
|
"hermes.gateway.up",
|
|
"hermes.gateway.active_agents",
|
|
"hermes.gateway.busy",
|
|
"hermes.gateway.drainable",
|
|
"hermes.gateway.restart_requested",
|
|
"hermes.platform.up",
|
|
"hermes.platform.degraded",
|
|
} <= metric_names
|
|
|
|
active = next(m for m in snapshot.metrics if m.name == "hermes.gateway.active_agents")
|
|
assert active.value == 2
|
|
assert active.attributes == {
|
|
"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")
|
|
assert busy.value == 1
|
|
assert drainable.value == 1
|
|
|
|
degraded = next(
|
|
m for m in snapshot.metrics
|
|
if m.name == "hermes.platform.degraded" and m.attributes["hermes.platform"] == "telegram"
|
|
)
|
|
assert degraded.value == 1
|
|
assert degraded.attributes["hermes.error_code"] == "auth_failed"
|
|
assert all("secret" not in str(v).lower() for v in degraded.attributes.values())
|
|
|
|
|
|
def test_gateway_health_snapshot_emits_content_free_diagnostic_event():
|
|
from agent.monitoring.gateway_health import build_gateway_health_snapshot
|
|
|
|
snapshot = build_gateway_health_snapshot(
|
|
{
|
|
"gateway_state": "running",
|
|
"active_agents": 1,
|
|
"platforms": {
|
|
"slack": {"state": "fatal", "error_code": "auth_failed", "error_message": "Bearer sk-live-secret"},
|
|
},
|
|
},
|
|
gateway_running=True,
|
|
profile="default",
|
|
install_id="install-1",
|
|
version="v-test",
|
|
supervision_mode="container",
|
|
)
|
|
|
|
events = [event.to_dict() for event in snapshot.events]
|
|
health = next(e for e in events if e["event"] == "gateway_health")
|
|
platform = next(e for e in events if e["event"] == "gateway_diagnostic" and e["name"] == "platform.fatal")
|
|
|
|
assert health["gateway_state"] == "running"
|
|
assert health["active_agents"] == 1
|
|
assert health["gateway_busy"] is True
|
|
assert health["gateway_drainable"] is True
|
|
assert health["fatal_platform_count"] == 1
|
|
assert platform["platform"] == "slack"
|
|
assert platform["error_code"] == "auth_failed"
|
|
assert "redacted_message" not in platform
|
|
assert "Bearer" not in str(platform)
|
|
|
|
|
|
def test_gateway_health_snapshot_preserves_real_bounded_platform_states():
|
|
from agent.monitoring.gateway_health import build_gateway_health_snapshot
|
|
|
|
expected = {
|
|
"connecting",
|
|
"connected",
|
|
"disconnected",
|
|
"disabled",
|
|
"fatal",
|
|
"paused",
|
|
"retrying",
|
|
}
|
|
snapshot = build_gateway_health_snapshot(
|
|
{
|
|
"gateway_state": "running",
|
|
"platforms": {state: {"state": state} for state in expected},
|
|
},
|
|
gateway_running=True,
|
|
profile="default",
|
|
install_id="install-1",
|
|
version="v-test",
|
|
supervision_mode="container",
|
|
)
|
|
|
|
observed = {
|
|
metric.attributes["hermes.platform.state"]
|
|
for metric in snapshot.metrics
|
|
if metric.name == "hermes.platform.up"
|
|
}
|
|
assert observed == expected
|
|
|
|
|
|
def test_gateway_diagnostic_log_handler_never_carries_rendered_message(caplog):
|
|
from agent.monitoring import emitter
|
|
from agent.monitoring.gateway_health import GatewayDiagnosticLogHandler
|
|
|
|
captured = []
|
|
|
|
class DummyEmitter:
|
|
def emit(self, event):
|
|
captured.append(event.to_dict())
|
|
|
|
old = emitter.get_emitter
|
|
emitter.get_emitter = lambda: DummyEmitter() # type: ignore[assignment]
|
|
try:
|
|
handler = GatewayDiagnosticLogHandler(profile="default", version="v-test")
|
|
logger = logging.getLogger("gateway.platforms.slack")
|
|
logger.setLevel(logging.DEBUG)
|
|
logger.addHandler(handler)
|
|
try:
|
|
logger.info("ignore info token sk-live-secret")
|
|
logger.warning(
|
|
"Unauthorized user: acct_7f3a (Alice Smith) on slack; "
|
|
"token «redacted:sk-…»"
|
|
)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
finally:
|
|
emitter.get_emitter = old # type: ignore[assignment]
|
|
|
|
assert len(captured) == 1
|
|
event = captured[0]
|
|
assert event["event"] == "gateway_diagnostic"
|
|
assert event["name"] == "gateway.log.warning"
|
|
assert event["subsystem"] == "platform.slack"
|
|
assert event["source_logger"] == "gateway.platforms.slack"
|
|
assert event["error_class"] == "auth_failed"
|
|
assert "redacted_message" not in event
|
|
assert "acct_7f3a" not in str(event)
|
|
assert "Alice Smith" not in str(event)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"Connect call failed ('127.0.0.1', 9)",
|
|
"failed to connect to relay",
|
|
"connection refused",
|
|
"network is unreachable",
|
|
"temporary failure in name resolution",
|
|
],
|
|
)
|
|
def test_gateway_error_classifier_recognizes_bounded_network_failures(message):
|
|
from agent.monitoring.gateway_health import classify_gateway_error
|
|
|
|
assert classify_gateway_error(message) == "network_error"
|
|
|
|
|
|
def test_gateway_diagnostic_log_handler_enriches_relay_scope_without_message_content(
|
|
monkeypatch,
|
|
):
|
|
from agent.monitoring import emitter
|
|
from agent.monitoring.gateway_health import GatewayDiagnosticLogHandler
|
|
|
|
captured = []
|
|
|
|
class DummyEmitter:
|
|
def emit(self, event):
|
|
captured.append(event.to_dict())
|
|
|
|
monkeypatch.setattr(emitter, "get_emitter", lambda: DummyEmitter())
|
|
handler = GatewayDiagnosticLogHandler(profile="default", version="v-test")
|
|
logger = logging.getLogger("gateway.relay.adapter")
|
|
logger.addHandler(handler)
|
|
try:
|
|
logger.warning(
|
|
"Connect call failed for ws://alice@example.com/private; "
|
|
"credential=«redacted:sk-…»"
|
|
)
|
|
finally:
|
|
logger.removeHandler(handler)
|
|
|
|
assert len(captured) == 1
|
|
event = captured[0]
|
|
assert event["subsystem"] == "platform.relay"
|
|
assert event["platform"] == "relay"
|
|
assert event["source_logger"] == "gateway.relay.adapter"
|
|
assert event["error_class"] == "network_error"
|
|
assert event["error_code"] == "network_error"
|
|
assert "alice@example.com" not in str(event)
|
|
assert "private" not in str(event)
|
|
|
|
|
|
def test_runtime_status_transition_emits_lifecycle_and_platform_events(monkeypatch):
|
|
from agent.monitoring import emitter
|
|
from agent.monitoring.gateway_health import emit_runtime_status_transition
|
|
|
|
captured = []
|
|
|
|
class DummyEmitter:
|
|
def emit(self, event):
|
|
captured.append(event.to_dict())
|
|
|
|
old = emitter.emit
|
|
monkeypatch.setattr(emitter, "emit", lambda event: captured.append(event.to_dict()))
|
|
|
|
previous = {"gateway_state": "starting", "platforms": {"slack": {"state": "running"}}}
|
|
current = {
|
|
"gateway_state": "running",
|
|
"pid": 123,
|
|
"active_agents": 1,
|
|
"platforms": {
|
|
"slack": {
|
|
"state": "fatal",
|
|
"error_code": "auth_failed",
|
|
"error_message": "Bearer *** failed",
|
|
}
|
|
},
|
|
}
|
|
|
|
emit_runtime_status_transition(previous, current)
|
|
|
|
names = [e["name"] for e in captured]
|
|
assert "gateway.lifecycle" in names
|
|
assert "platform.state_change" in names
|
|
assert "platform.fatal" in names
|
|
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"
|
|
assert platform["error_code"] == "auth_failed"
|
|
assert "redacted_message" not in platform
|
|
|
|
|
|
def test_runtime_status_transition_emits_startup_failed_and_exit():
|
|
from agent.monitoring.gateway_health import emit_runtime_status_transition
|
|
from agent.monitoring import emitter
|
|
|
|
captured = []
|
|
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": "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]
|
|
|
|
names = [e["name"] for e in captured]
|
|
assert "gateway.startup_failed" in names
|
|
assert "gateway.exit" in names
|
|
failed = next(e for e in captured if e["name"] == "gateway.startup_failed")
|
|
assert "redacted_message" not in failed
|
|
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():
|
|
from agent.monitoring.otlp_exporter import _span_attrs
|
|
|
|
attrs = _span_attrs({
|
|
"event": "gateway_health",
|
|
"name": "gateway.lifecycle",
|
|
"old_state": "starting",
|
|
"new_state": "running",
|
|
"exit_reason": "restart",
|
|
"restart_requested": True,
|
|
})
|
|
|
|
assert attrs["hermes.old_state"] == "starting"
|
|
assert attrs["hermes.new_state"] == "running"
|
|
assert attrs["hermes.exit_reason"] == "restart"
|
|
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_runtime_resource_attributes_include_stable_hashed_instance():
|
|
from agent.monitoring.gateway_health_export import _runtime_resource_attributes
|
|
|
|
config = {
|
|
"monitoring": {
|
|
"install_id": "private-install-id",
|
|
"gateway_health_export": {
|
|
"resource_attributes": {"deployment.environment.name": "staging"}
|
|
},
|
|
}
|
|
}
|
|
|
|
attrs = _runtime_resource_attributes(config, telemetry_scope="gateway_health")
|
|
|
|
assert attrs["service.name"] == "hermes-gateway"
|
|
assert attrs["service.instance.id"].startswith("sha256:")
|
|
assert len(attrs["service.instance.id"]) == len("sha256:") + 24
|
|
assert "private-install-id" not in str(attrs)
|
|
assert attrs["deployment.environment.name"] == "staging"
|
|
assert attrs["telemetry.scope"] == "gateway_health"
|
|
|
|
|
|
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_diagnostic_log_streamer_uses_validated_source_as_otel_scope():
|
|
from types import SimpleNamespace
|
|
|
|
from agent.monitoring.gateway_health_export import GatewayDiagnosticLogStreamer
|
|
|
|
class FakeLogger:
|
|
def __init__(self):
|
|
self.records = []
|
|
|
|
def emit(self, record):
|
|
self.records.append(record)
|
|
|
|
class FakeProvider:
|
|
def __init__(self):
|
|
self.loggers = {}
|
|
|
|
def get_logger(self, name):
|
|
return self.loggers.setdefault(name, FakeLogger())
|
|
|
|
provider = FakeProvider()
|
|
streamer = object.__new__(GatewayDiagnosticLogStreamer)
|
|
streamer._provider = provider
|
|
streamer._logger = provider.get_logger("hermes.gateway.diagnostics")
|
|
streamer._LogRecord = lambda **kwargs: SimpleNamespace(**kwargs)
|
|
streamer._sdk = {
|
|
"INVALID_TRACE_ID": 0,
|
|
"INVALID_SPAN_ID": 0,
|
|
"TraceFlags": SimpleNamespace(DEFAULT=0),
|
|
"SeverityNumber": SimpleNamespace(
|
|
FATAL="fatal", ERROR="error", WARN="warn", INFO="info", DEBUG="debug"
|
|
),
|
|
}
|
|
streamer.exported = 0
|
|
|
|
streamer([
|
|
{
|
|
"event": "gateway_diagnostic",
|
|
"name": "gateway.log.warning",
|
|
"subsystem": "platform.relay",
|
|
"platform": "relay",
|
|
"source_logger": "gateway.relay.adapter",
|
|
"error_class": "network_error",
|
|
"severity": "warning",
|
|
},
|
|
{
|
|
"event": "gateway_diagnostic",
|
|
"name": "gateway.log.warning",
|
|
"subsystem": "gateway",
|
|
"source_logger": "gateway.relay.adapter\nalice@example.com",
|
|
"error_class": "unknown",
|
|
"severity": "warning",
|
|
},
|
|
])
|
|
|
|
precise = provider.loggers["gateway.relay.adapter"].records
|
|
fallback = provider.loggers["hermes.gateway.diagnostics"].records
|
|
assert len(precise) == 1
|
|
assert len(fallback) == 1
|
|
assert precise[0].body == "gateway diagnostic"
|
|
assert "source_logger" not in precise[0].attributes
|
|
assert "alice@example.com" not in str(provider.loggers)
|
|
|
|
|
|
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
|
|
|
|
monkeypatch.setattr(gateway_health_export, "_require_metrics_sdk", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("missing sdk")))
|
|
|
|
runtime = gateway_health_export.start_gateway_health_export({
|
|
"monitoring": {
|
|
"gateway_health_export": {"enabled": True},
|
|
"export": {"otlp": {"enabled": True, "endpoint": "http://collector:4317"}},
|
|
}
|
|
})
|
|
|
|
assert isinstance(runtime, GatewayHealthExportRuntime)
|
|
assert runtime.enabled is False
|
|
assert runtime.reason == "otlp_unavailable"
|
|
|
|
|
|
def test_gateway_health_export_shutdown_flushes_before_unsubscribe(monkeypatch):
|
|
from agent.monitoring import emitter
|
|
from agent.monitoring.gateway_health_export import GatewayHealthExportRuntime
|
|
|
|
calls = []
|
|
|
|
class FakeEmitter:
|
|
def flush(self, timeout):
|
|
calls.append(("flush", timeout))
|
|
|
|
def unsubscribe(self, subscriber):
|
|
calls.append(("unsubscribe", subscriber))
|
|
|
|
class Streamer:
|
|
def shutdown(self):
|
|
calls.append(("shutdown", self))
|
|
|
|
streamer = Streamer()
|
|
log_streamer = Streamer()
|
|
monkeypatch.setattr(emitter, "get_emitter", lambda: FakeEmitter())
|
|
|
|
runtime = GatewayHealthExportRuntime(
|
|
enabled=True,
|
|
streamer=streamer,
|
|
log_streamer=log_streamer,
|
|
)
|
|
runtime.shutdown()
|
|
|
|
assert calls[0][0] == "flush"
|
|
assert calls[1:3] == [
|
|
("unsubscribe", streamer),
|
|
("unsubscribe", log_streamer),
|
|
]
|
|
|
|
|
|
def test_gateway_health_export_streams_only_gateway_events(monkeypatch):
|
|
from agent.monitoring import gateway_health_export
|
|
|
|
captured = {}
|
|
|
|
def fake_start_streaming(config, *, event_filter=None):
|
|
captured["filter"] = event_filter
|
|
return object()
|
|
|
|
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)
|
|
from agent.monitoring import otlp_exporter
|
|
monkeypatch.setattr(otlp_exporter, "start_streaming", fake_start_streaming)
|
|
|
|
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 True
|
|
event_filter = captured["filter"]
|
|
assert event_filter({"event": "gateway_health"}) is True
|
|
assert event_filter({"event": "gateway_diagnostic"}) is False
|
|
assert event_filter({"event": "run"}) is False
|
|
assert event_filter({"event": "model_call"}) is False
|
|
assert event_filter({"event": "tool_call"}) is False
|
|
|
|
|
|
def test_gateway_health_export_metric_failure_does_not_start_streamer(monkeypatch):
|
|
from agent.monitoring import gateway_health_export, otlp_exporter
|
|
|
|
started = []
|
|
monkeypatch.setattr(gateway_health_export, "_require_metrics_sdk", lambda *a, **k: {})
|
|
monkeypatch.setattr(gateway_health_export, "_start_metric_provider", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
monkeypatch.setattr(otlp_exporter, "start_streaming", lambda *a, **k: started.append(True))
|
|
|
|
runtime = gateway_health_export.start_gateway_health_export({
|
|
"monitoring": {
|
|
"gateway_health_export": {"enabled": True},
|
|
"export": {"otlp": {"enabled": True, "endpoint": "http://collector:4318/v1/traces"}},
|
|
}
|
|
})
|
|
|
|
assert runtime.enabled is False
|
|
assert runtime.reason == "metrics_start_failed"
|
|
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
|
|
|
|
class Dummy:
|
|
def force_flush(self):
|
|
pass
|
|
def shutdown(self):
|
|
pass
|
|
|
|
e = emitter.get_emitter()
|
|
streamer = OTLPStreamer.__new__(OTLPStreamer)
|
|
streamer._processor = Dummy()
|
|
streamer._provider = Dummy()
|
|
streamer._event_filter = None
|
|
streamer.exported = 0
|
|
e.subscribe(streamer)
|
|
assert streamer in e._subscribers
|
|
|
|
streamer.shutdown()
|
|
|
|
assert streamer not in e._subscribers
|
|
|
|
|
|
def test_gateway_diagnostic_log_handler_never_raises_on_malformed_record():
|
|
from agent.monitoring.gateway_health import GatewayDiagnosticLogHandler
|
|
|
|
handler = GatewayDiagnosticLogHandler(profile="default", version="v-test")
|
|
record = logging.LogRecord(
|
|
"gateway.platforms.slack",
|
|
logging.WARNING,
|
|
__file__,
|
|
1,
|
|
"broken %s %s",
|
|
("one",),
|
|
None,
|
|
)
|
|
|
|
handler.emit(record)
|
|
|
|
|
|
def test_install_id_persists_across_calls(tmp_path, monkeypatch):
|
|
"""A minted install id must survive restarts (service.instance.id continuity)."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
(tmp_path / "config.yaml").write_text("{}\n")
|
|
|
|
import hermes_cli.config as cfg_mod
|
|
from agent.monitoring.policy import ensure_install_id
|
|
|
|
first = ensure_install_id(cfg_mod.load_config())
|
|
assert first and first != "unknown"
|
|
# Persisted: a fresh load (simulating a new gateway process) returns the same id.
|
|
second = ensure_install_id(cfg_mod.load_config())
|
|
assert second == first
|
|
assert first in (tmp_path / "config.yaml").read_text()
|
|
|
|
|
|
def test_install_id_existing_value_wins(monkeypatch):
|
|
from agent.monitoring.policy import ensure_install_id
|
|
|
|
assert ensure_install_id({"monitoring": {"install_id": "keep-me"}}) == "keep-me"
|