Files
hermes-agent/tests/gateway/test_platform_shared_metrics.py
Teknium 19b57f11d6 fix(telemetry): latch a failed-connect episode only when it was recorded, per real adapter
Review fixes for the once-per-episode connect_failed dedupe:

- The episode was latched before the collection-consent gate, so a failure
  seen while shared metrics were off suppressed the first consented day's
  connect_failed. _record_connect now returns without latching when
  collection is off: only a recorded failure opens an episode.
- Local episode state was keyed by the privacy-projected platform name, so
  two custom adapters (both emitted as "plugin") shared one episode and one
  adapter's recovery closed the other's outage. The key is now the adapter's
  real platform value; projection happens only at emission.
- Docs: the hermes.platform.health row describes the episode semantics.
2026-10-05 16:01:43 -07:00

242 lines
12 KiB
Python

"""Gateway platform health / delivery / first-reply shared metrics through the real runner seams."""
import asyncio
import warnings
from types import SimpleNamespace
import pytest
from gateway.platforms.base import BasePlatformAdapter, Platform, PlatformConfig, SendResult
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run_adapters import GatewayAdapterLifecycleMixin
from gateway.stream_consumer import GatewayStreamConsumer, StreamConsumerConfig
from hermes_cli.observability import relay_shared_metrics as rsm
from hermes_cli.observability import shared_metrics_contract as contract
from hermes_cli.observability import shared_metrics_gateway as smg
from hermes_constants import get_hermes_home
@pytest.fixture
def rows(monkeypatch):
got = []
monkeypatch.setattr(rsm, "enabled", lambda: True)
monkeypatch.setattr(rsm, "record_process_mark",
lambda mark, data: got.append((mark, dict(data), str(get_hermes_home()))))
monkeypatch.setattr("gateway.platforms.base.random.uniform", lambda *_: 0.0)
smg._reply_clocks.clear()
smg._chat_homes.clear()
smg._failing_connects.clear()
def read(metric, *, with_home=False):
smg.drain()
found = [(home, data) for mark, data, home in got if contract._DECISION_MARK_METRICS[mark] == metric]
assert all(contract.counter_dimensions_are_valid(metric, data) for _, data in found)
return found if with_home else [data for _, data in found]
return read
class _Adapter(BasePlatformAdapter):
def __init__(self, results=(), connect: object = True, platform=Platform.TELEGRAM):
super().__init__(PlatformConfig(enabled=True, token="test"), platform)
self.results, self._connect = list(results), connect
async def connect(self, *, is_reconnect: bool = False) -> bool:
if isinstance(self._connect, BaseException):
raise self._connect
if not self._connect:
self._set_fatal_error("telegram_missing_dependency", "python-telegram-bot missing", retryable=False)
return self._connect
async def disconnect(self) -> None:
self._mark_disconnected()
async def send(self, chat_id, content, reply_to=None, metadata=None):
return self.results.pop(0) if self.results else SendResult(success=True, message_id="m1")
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "dm"}
class _Runner(GatewayAdapterLifecycleMixin):
def _platform_connect_timeout_secs(self, platform=None, *, initial=False):
return 5.0
def _connect(adapter, **kw):
try:
return asyncio.run(_Runner()._connect_adapter_with_timeout(adapter, adapter.platform, **kw))
except Exception as exc:
return type(exc).__name__
def test_every_connect_attempt_records_one_health_row_with_a_closed_error_class(rows):
assert _connect(_Adapter()) is True
assert _connect(_Adapter(), is_reconnect=True) is True
assert _connect(_Adapter(connect=False)) is False
assert _connect(_Adapter(connect=ConnectionResetError("peer reset by 10.0.0.1"))) == "ConnectionResetError"
assert rows("hermes.platform.health") == [
{"error_class": "none", "event": "connect_ok", "platform": "telegram"},
{"error_class": "none", "event": "reconnect", "platform": "telegram"},
{"error_class": "config", "event": "connect_failed", "platform": "telegram"},
{"error_class": "network", "event": "connect_failed", "platform": "telegram"},
]
def test_a_reconnect_backoff_loop_is_one_failed_connect_until_it_recovers(rows, monkeypatch):
outage = ConnectionResetError("network down")
monkeypatch.setattr(rsm, "enabled", lambda: False) # collection off: nothing recorded, so nothing latched
assert _connect(_Adapter(connect=outage)) == "ConnectionResetError"
smg.drain()
monkeypatch.setattr(rsm, "enabled", lambda: True)
assert _connect(_Adapter(connect=outage)) == "ConnectionResetError" # opted in mid-outage: counts
for _ in range(5): # the watcher's backoff retries during the same outage
assert _connect(_Adapter(connect=outage), is_reconnect=True) == "ConnectionResetError"
assert _connect(_Adapter(platform=Platform.DISCORD, connect=outage)) == "ConnectionResetError"
assert _connect(_Adapter(), is_reconnect=True) is True
assert _connect(_Adapter(connect=outage), is_reconnect=True) == "ConnectionResetError" # a new outage
smg.drain()
smg._failing_connects.update(dict.fromkeys(smg._failing_connects, "2000-01-01")) # ...still down a day later
assert _connect(_Adapter(connect=outage), is_reconnect=True) == "ConnectionResetError"
# Two custom adapters both emit "plugin" but are separate outages: beta's recovery leaves alpha's open.
for name, result in (("alpha", outage), ("beta", outage), ("beta", True), ("alpha", outage)):
_connect(_Adapter(platform=name, connect=result), is_reconnect=True)
assert [(r["event"], r["platform"]) for r in rows("hermes.platform.health")] == [
("connect_failed", "telegram"), ("connect_failed", "discord"),
("reconnect", "telegram"), ("connect_failed", "telegram"), ("connect_failed", "telegram"),
("connect_failed", "plugin"), ("connect_failed", "plugin"), ("reconnect", "plugin"),
]
def test_one_logical_delivery_is_one_row_whatever_the_retries(rows):
flaky = _Adapter([SendResult(success=False, error="ConnectionError: reset", retryable=True)])
assert asyncio.run(flaky._send_with_retry("42", "hi", base_delay=0)).success
blocked = _Adapter([SendResult(success=False, error="Forbidden: bot was blocked", error_kind="forbidden")] * 3)
assert not asyncio.run(blocked._send_with_retry("42", "hi", base_delay=0)).success
assert rows("hermes.platform.delivery") == [
{"failure_class": "none", "outcome": "sent", "platform": "telegram"},
{"failure_class": "forbidden", "outcome": "failed", "platform": "telegram"},
]
def test_first_reply_latency_is_one_row_per_turn_stopped_only_by_reply_text(rows):
adapter = _Adapter()
smg.start_reply_clock(SimpleNamespace(platform=Platform.TELEGRAM, chat_id="42"))
asyncio.run(adapter._send_with_retry("42", "⏳ still working on the previous message")) # busy ack
assert rows("hermes.gateway.reply_latency") == []
consumer = GatewayStreamConsumer(adapter, "42", StreamConsumerConfig(cursor=""))
assert asyncio.run(consumer._send_or_edit("first streamed chunk"))
asyncio.run(consumer._send_or_edit("first streamed chunk, more")) # later edits are not the first reply
event = MessageEvent(text="hi", message_type=MessageType.TEXT, message_id="in-1",
source=adapter.build_source(chat_id="8", user_id="u1"))
smg.start_reply_clock(event.source)
asyncio.run(adapter.send_final_ledgered(event, "k", "final answer", {}, reply_to=None))
smg.start_reply_clock(SimpleNamespace(platform=Platform.TELEGRAM, chat_id="7"), internal=True)
asyncio.run(adapter.send_final_ledgered(event, "k", "background notice", {}, reply_to=None))
assert rows("hermes.gateway.reply_latency") == [{"first_response_bucket": "lt_2s", "platform": "telegram"}] * 2
def test_a_relay_delivered_turn_stops_the_clock_its_inbound_started(rows):
# The connector's inbound names the platform it came from; the reply leaves through the relay.
relay = _Adapter(platform=Platform.RELAY)
smg.start_reply_clock(SimpleNamespace(platform=Platform.DISCORD, chat_id="c1"))
consumer = GatewayStreamConsumer(relay, "c1", StreamConsumerConfig(cursor=""))
assert asyncio.run(consumer._send_or_edit("first streamed chunk"))
event = MessageEvent(text="hi", message_type=MessageType.TEXT, message_id="in-2",
source=SimpleNamespace(platform=Platform.SLACK, chat_id="C2", thread_id=None))
smg.start_reply_clock(event.source)
asyncio.run(relay.send_final_ledgered(event, "k", "final answer", {}, reply_to=None))
assert rows("hermes.gateway.reply_latency") == [
{"first_response_bucket": "lt_2s", "platform": "discord"}, {"first_response_bucket": "lt_2s", "platform": "slack"}]
assert not smg._reply_clocks
def test_a_delivery_sent_after_the_routed_scope_resets_lands_in_the_owning_profile(rows, tmp_path):
# Multiplexed gateway: the turn starts in profile B's scope, the final send runs after it is reset.
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
home_b = tmp_path / "profiles" / "b"
token = set_hermes_home_override(str(home_b))
try:
smg.start_reply_clock(SimpleNamespace(platform=Platform.SLACK, chat_id="C9"))
finally:
reset_hermes_home_override(token)
asyncio.run(_Adapter(platform=Platform.SLACK)._send_with_retry("C9", "final answer", base_delay=0))
asyncio.run(_Adapter()._send_with_retry("unseen", "hi", base_delay=0)) # no turn: the caller's scope
assert rows("hermes.platform.delivery", with_home=True) == [
(str(home_b), {"failure_class": "none", "outcome": "sent", "platform": "slack"}),
(str(get_hermes_home()), {"failure_class": "none", "outcome": "sent", "platform": "telegram"}),
]
class _FatalRunner(GatewayAdapterLifecycleMixin):
def __init__(self):
self.adapters, self._failed_platforms, self._running = {}, {}, True
self.delivery_router, self.config = SimpleNamespace(adapters={}), SimpleNamespace(platforms={})
self._profile_adapters, self._profile_failed_platforms = {}, {}
def _update_platform_runtime_status(self, *args, **kwargs):
pass
async def _safe_adapter_disconnect(self, *args, **kwargs):
pass
async def stop(self):
pass
def _fatal(adapter, code, *, retryable=False):
adapter._set_fatal_error(code, "boom", retryable=retryable)
return adapter
def test_fatal_handlers_count_lost_connections_in_the_owning_profile_only(rows, tmp_path, monkeypatch):
home_b = tmp_path / "profiles" / "b"
monkeypatch.setattr(_FatalRunner, "_routed_profile_home", staticmethod(lambda name: home_b))
runner = _FatalRunner()
secondary = _fatal(_Adapter(), "telegram_network_error")
runner._profile_adapters["b"] = {Platform.TELEGRAM: secondary}
# The secondary's notification arrives on a task scoped to the launch profile, not to b.
asyncio.run(runner._handle_profile_adapter_fatal_error("b", Platform.TELEGRAM, secondary))
for code in ("relay_disabled", "telegram_auth_error"): # the user's relay opt-out is not a disconnect
primary = _fatal(_Adapter(platform=Platform.RELAY), code)
runner.adapters[Platform.RELAY] = primary
asyncio.run(runner._handle_adapter_fatal_error_impl(primary))
launch_home = str(get_hermes_home())
assert rows("hermes.platform.health", with_home=True) == [
(str(home_b), {"error_class": "network", "event": "disconnect", "platform": "telegram"}),
(launch_home, {"error_class": "auth", "event": "disconnect", "platform": "relay"}),
]
class _ClosedWithDeprecatedCode(ConnectionError):
"""websockets-style: the status lives behind a deprecated property."""
@property
def code(self):
warnings.warn("ConnectionClosed.code is deprecated", DeprecationWarning, stacklevel=2)
return 1011
@property
def status_code(self):
raise RuntimeError("no response attached")
def test_classifying_a_connect_failure_never_replaces_the_adapters_exception(rows):
raised = _ClosedWithDeprecatedCode()
async def attempt():
try:
await _Runner()._connect_adapter_with_timeout(_Adapter(connect=raised), Platform.TELEGRAM)
except BaseException as exc:
return exc
with warnings.catch_warnings():
warnings.simplefilter("error")
seen = asyncio.run(attempt())
assert seen is raised and seen.__context__ is None
assert rows("hermes.platform.health") == [
{"error_class": "network", "event": "connect_failed", "platform": "telegram"}]