"""Tests for #219 โ€” web.agent_telemetry structured agent-platform events. Verifies the emitter contract the recovery-loop observability relies on: - ``emit`` stamps ts/v/event/outcome and drops ``None`` fields. - ``instrument`` lifts standard + result keys, times the call, and derives the outcome (ok / noop for ``{"ok": False}`` / error on exception). - an exception is re-raised after the error event โ€” the wrapped op is **never silently swallowed** (constitution ยง6 / INV-G4). - the decorator is transparent: wrapped functions stay coroutine functions. Pure-stdlib module (no Paperclip/web deps), so this runs without importorskip. """ from __future__ import annotations import asyncio import inspect import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # web/ from agent_telemetry import SCHEMA_VERSION, emit, instrument # noqa: E402 import agent_telemetry # noqa: E402 @pytest.fixture def captured(monkeypatch): """Intercept the sink; yield the list of emitted event dicts.""" events: list[dict] = [] monkeypatch.setattr(agent_telemetry, "_SINK", events.append) return events def test_emit_stamps_schema_and_drops_none(captured): emit("x.test", role="ceo", case_number="8125-09-24", issue_id=None) (e,) = captured assert e["event"] == "x.test" assert e["v"] == SCHEMA_VERSION assert e["outcome"] == "ok" assert e["ts"].endswith("Z") assert e["case_number"] == "8125-09-24" assert "issue_id" not in e # None fields dropped def test_instrument_success_lifts_result_keys(captured): @instrument("interaction.reaped", result_keys=("cancelled",)) async def reap(): return {"ok": True, "cancelled": 3} result = asyncio.run(reap()) assert result == {"ok": True, "cancelled": 3} (e,) = captured assert e["event"] == "interaction.reaped" assert e["outcome"] == "ok" assert e["cancelled"] == 3 assert "duration_ms" in e def test_instrument_ok_false_is_noop(captured): @instrument("interaction.cancelled", keys=("issue_id",)) async def cancel(issue_id, interaction_id): return {"ok": False, "error": "already resolved"} asyncio.run(cancel("iss-1", "int-9")) (e,) = captured assert e["outcome"] == "noop" assert e["issue_id"] == "iss-1" def test_instrument_reraises_after_error_event(captured): @instrument("agent.wakeup", role="ceo") async def wake(case_number, company_id=""): raise RuntimeError("boom") with pytest.raises(RuntimeError, match="boom"): asyncio.run(wake("1043-04-26", company_id="cmp")) (e,) = captured assert e["outcome"] == "error" assert "boom" in e["error"] assert e["agent_role"] == "ceo" assert e["case_number"] == "1043-04-26" assert e["company_id"] == "cmp" def test_decorator_is_transparent(): @instrument("agent.wakeup", role="analyst") async def wake(case_number, company_id=""): return {"ok": True} assert inspect.iscoroutinefunction(wake) assert wake.__name__ == "wake" def test_emit_never_raises_on_sink_failure(monkeypatch): def boom(_payload): raise ValueError("sink down") monkeypatch.setattr(agent_telemetry, "_SINK", boom) # Must not propagate โ€” telemetry is best-effort, never fatal to the op. emit("agent.wakeup", role="ceo")