|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import uuid |
| 4 | +from typing import Any |
| 5 | +from datetime import UTC, datetime |
| 6 | +from unittest.mock import MagicMock, patch |
| 7 | + |
| 8 | +import pytest |
| 9 | + |
| 10 | +from agentex.types.span import Span |
| 11 | +from agentex.lib.core.tracing.trace import Trace, AsyncTrace |
| 12 | +from agentex.lib.core.tracing.span_error import ( |
| 13 | + SPAN_ERROR_KEY, |
| 14 | + get_span_error, |
| 15 | + set_span_error, |
| 16 | +) |
| 17 | + |
| 18 | +PROCESSOR_MODULE = "agentex.lib.core.tracing.processors.sgp_tracing_processor" |
| 19 | + |
| 20 | + |
| 21 | +def _make_span(data=None) -> Span: |
| 22 | + return Span( |
| 23 | + id=str(uuid.uuid4()), |
| 24 | + name="test-span", |
| 25 | + start_time=datetime.now(UTC), |
| 26 | + trace_id="trace-1", |
| 27 | + data=data, |
| 28 | + ) |
| 29 | + |
| 30 | + |
| 31 | +# --------------------------------------------------------------------------- |
| 32 | +# Helpers: set_span_error / get_span_error |
| 33 | +# --------------------------------------------------------------------------- |
| 34 | + |
| 35 | + |
| 36 | +class TestSpanErrorHelpers: |
| 37 | + def test_set_then_get_on_none_data(self): |
| 38 | + span = _make_span(data=None) |
| 39 | + set_span_error(span, ValueError("boom")) |
| 40 | + assert get_span_error(span) == {"type": "ValueError", "message": "boom"} |
| 41 | + assert isinstance(span.data, dict) |
| 42 | + assert span.data[SPAN_ERROR_KEY] == {"type": "ValueError", "message": "boom"} |
| 43 | + |
| 44 | + def test_set_preserves_existing_dict_keys(self): |
| 45 | + span = _make_span(data={"__span_type__": "LLM"}) |
| 46 | + set_span_error(span, RuntimeError("nope")) |
| 47 | + assert isinstance(span.data, dict) |
| 48 | + assert span.data["__span_type__"] == "LLM" |
| 49 | + err = get_span_error(span) |
| 50 | + assert err is not None |
| 51 | + assert err["type"] == "RuntimeError" |
| 52 | + |
| 53 | + def test_get_returns_none_when_no_error(self): |
| 54 | + assert get_span_error(_make_span(data={"foo": "bar"})) is None |
| 55 | + assert get_span_error(_make_span(data=None)) is None |
| 56 | + |
| 57 | + def test_set_is_noop_on_list_data(self): |
| 58 | + span = _make_span(data=[{"a": 1}]) |
| 59 | + set_span_error(span, ValueError("boom")) |
| 60 | + # list-shaped data is left untouched (mirrors _add_source_to_span) |
| 61 | + assert span.data == [{"a": 1}] |
| 62 | + assert get_span_error(span) is None |
| 63 | + |
| 64 | + |
| 65 | +# --------------------------------------------------------------------------- |
| 66 | +# Capture: the context managers record body exceptions onto the span |
| 67 | +# --------------------------------------------------------------------------- |
| 68 | + |
| 69 | + |
| 70 | +class TestContextManagerCapture: |
| 71 | + def test_sync_span_records_error_and_reraises(self): |
| 72 | + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") |
| 73 | + captured = {} |
| 74 | + with pytest.raises(ValueError, match="boom"): |
| 75 | + with trace.span("op") as span: |
| 76 | + captured["span"] = span |
| 77 | + raise ValueError("boom") |
| 78 | + err = get_span_error(captured["span"]) |
| 79 | + assert err == {"type": "ValueError", "message": "boom"} |
| 80 | + |
| 81 | + def test_sync_span_success_has_no_error(self): |
| 82 | + trace = Trace(processors=[], client=MagicMock(), trace_id="t1") |
| 83 | + with trace.span("op") as span: |
| 84 | + pass |
| 85 | + assert get_span_error(span) is None |
| 86 | + |
| 87 | + @pytest.mark.asyncio |
| 88 | + async def test_async_span_records_error_and_reraises(self): |
| 89 | + trace = AsyncTrace(processors=[], client=MagicMock(), trace_id="t1") |
| 90 | + captured = {} |
| 91 | + with pytest.raises(RuntimeError, match="kaboom"): |
| 92 | + async with trace.span("op") as span: |
| 93 | + captured["span"] = span |
| 94 | + raise RuntimeError("kaboom") |
| 95 | + err = get_span_error(captured["span"]) |
| 96 | + assert err == {"type": "RuntimeError", "message": "kaboom"} |
| 97 | + |
| 98 | + |
| 99 | +# --------------------------------------------------------------------------- |
| 100 | +# Map: _build_sgp_span translates the recorded error into SGP status=ERROR |
| 101 | +# --------------------------------------------------------------------------- |
| 102 | + |
| 103 | + |
| 104 | +class _FakeSGPSpan: |
| 105 | + def __init__(self, metadata: dict[str, Any] | None) -> None: |
| 106 | + self.status = "SUCCESS" |
| 107 | + self.metadata: dict[str, Any] = metadata if metadata is not None else {} |
| 108 | + self.start_time = None |
| 109 | + |
| 110 | + def set_error( |
| 111 | + self, |
| 112 | + error_type: str | None = None, |
| 113 | + error_message: str | None = None, |
| 114 | + exception: BaseException | None = None, |
| 115 | + ) -> None: |
| 116 | + self.status = "ERROR" |
| 117 | + self.metadata["error"] = True |
| 118 | + self.metadata["error_type"] = error_type |
| 119 | + self.metadata["error_message"] = error_message |
| 120 | + |
| 121 | + |
| 122 | +def _fake_create_span(**kwargs: Any) -> _FakeSGPSpan: |
| 123 | + return _FakeSGPSpan(kwargs.get("metadata")) |
| 124 | + |
| 125 | + |
| 126 | +class TestBuildSGPSpanMapping: |
| 127 | + @staticmethod |
| 128 | + def _env(): |
| 129 | + return MagicMock(ACP_TYPE=None, AGENT_NAME=None, AGENT_ID=None) |
| 130 | + |
| 131 | + def test_error_maps_to_status_error(self): |
| 132 | + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span |
| 133 | + |
| 134 | + span = _make_span(data={SPAN_ERROR_KEY: {"type": "ValueError", "message": "boom"}}) |
| 135 | + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): |
| 136 | + sgp_span = _build_sgp_span(span, self._env()) |
| 137 | + |
| 138 | + assert sgp_span.status == "ERROR" |
| 139 | + assert sgp_span.metadata["error"] is True |
| 140 | + assert sgp_span.metadata["error_type"] == "ValueError" |
| 141 | + assert sgp_span.metadata["error_message"] == "boom" |
| 142 | + |
| 143 | + def test_no_error_leaves_status_success(self): |
| 144 | + from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span |
| 145 | + |
| 146 | + span = _make_span(data={"__span_type__": "LLM"}) |
| 147 | + with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span): |
| 148 | + sgp_span = _build_sgp_span(span, self._env()) |
| 149 | + |
| 150 | + assert sgp_span.status == "SUCCESS" |
| 151 | + assert "error" not in sgp_span.metadata |
0 commit comments