Files
hermes-agent/tests/gateway/test_platform_shared_metrics.py
teknium1 b67d9ed6fe fix(telemetry): platform.delivery lands in the profile that owns the chat (M6)
In a multiplexed gateway the final send runs after the routed profile
scope is reset, so delivery rows went to the launch profile while the
same turn's reply_latency went to the owner. start_reply_clock now also
remembers the owning home per (platform, chat) in a bounded map that
outlives the clock, and records_delivery submits through it.
2026-09-28 12:43:03 -07:00

216 lines
10 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()
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, Platform.TELEGRAM, **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_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"}]