Skip to content

Commit 6c23d76

Browse files
fix(tracing): capture span body exceptions and export SGP status=ERROR (#460)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0afaae4 commit 6c23d76

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

src/agentex/lib/core/tracing/processors/sgp_tracing_processor.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from agentex.lib.utils.logging import make_logger
1616
from agentex.lib.core.observability import tracing_metrics_recording as _metrics
1717
from agentex.lib.environment_variables import EnvironmentVariables
18+
from agentex.lib.core.tracing.span_error import get_span_error
1819
from agentex.lib.core.tracing.processors.tracing_processor_interface import (
1920
SyncTracingProcessor,
2021
AsyncTracingProcessor,
@@ -83,6 +84,9 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:
8384
),
8485
)
8586
sgp_span.start_time = span.start_time.isoformat() # type: ignore[union-attr]
87+
error = get_span_error(span)
88+
if error is not None:
89+
sgp_span.set_error(error_type=error["type"], error_message=error["message"])
8690
return sgp_span
8791

8892

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from agentex.types.span import Span
6+
7+
# Reserved key under ``Span.data`` carrying failure info for a span whose
8+
# context-manager body raised. Mirrors the existing ``__span_type__`` /
9+
# ``__source__`` reserved-key convention already read/written by the SGP
10+
# processor. Stored in ``data`` because the Span model is generated from the
11+
# OpenAPI spec and has no first-class status/error field; ``data`` is a real
12+
# field, so it survives ``model_copy(deep=True)`` and round-trips to both the
13+
# SGP and agentex-native span stores.
14+
SPAN_ERROR_KEY = "__error__"
15+
16+
17+
def set_span_error(span: Span, exc: BaseException) -> None:
18+
"""Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``.
19+
20+
No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which
21+
only attaches metadata to dict-shaped data).
22+
"""
23+
error = {"type": type(exc).__name__, "message": str(exc)}
24+
if span.data is None:
25+
span.data = {}
26+
if isinstance(span.data, dict):
27+
span.data[SPAN_ERROR_KEY] = error
28+
29+
30+
def get_span_error(span: Span) -> dict[str, Any] | None:
31+
"""Return the error recorded by :func:`set_span_error`, or ``None``."""
32+
if isinstance(span.data, dict):
33+
value = span.data.get(SPAN_ERROR_KEY)
34+
if isinstance(value, dict):
35+
return value
36+
return None

src/agentex/lib/core/tracing/trace.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from agentex.types.span import Span
1212
from agentex.lib.utils.logging import make_logger
1313
from agentex.lib.utils.model_utils import recursive_model_dump
14+
from agentex.lib.core.tracing.span_error import set_span_error
1415
from agentex.lib.core.tracing.span_queue import (
1516
SpanEventType,
1617
AsyncSpanQueue,
@@ -165,6 +166,9 @@ def span(
165166
span = self.start_span(name, parent_id, input, data, task_id=task_id)
166167
try:
167168
yield span
169+
except Exception as exc:
170+
set_span_error(span, exc)
171+
raise
168172
finally:
169173
self.end_span(span)
170174

@@ -321,5 +325,8 @@ async def span(
321325
span = await self.start_span(name, parent_id, input, data, task_id=task_id)
322326
try:
323327
yield span
328+
except Exception as exc:
329+
set_span_error(span, exc)
330+
raise
324331
finally:
325332
await self.end_span(span)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)