Skip to content

Commit d62ef7d

Browse files
feat: propagate error categories to SGP spans
Capture ADK failures and preserve producer ownership metadata so SGP can distinguish application, platform, and unknown errors. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 37abff8 commit d62ef7d

5 files changed

Lines changed: 121 additions & 9 deletions

File tree

src/agentex/lib/adk/_modules/tracing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
StartSpanParams,
2020
TracingActivityName,
2121
)
22+
from agentex.lib.core.tracing.span_error import set_span_error
2223
from agentex.lib.core.tracing.tracer import AsyncTracer
2324
from agentex.lib.core.harness.types import TurnUsage
2425
from agentex.types.span import Span
@@ -236,6 +237,10 @@ async def span(
236237
)
237238
try:
238239
yield span
240+
except Exception as exc:
241+
if span:
242+
set_span_error(span, exc)
243+
raise
239244
finally:
240245
if span:
241246
await self.end_span(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def _build_sgp_span(span: Span, env_vars: EnvironmentVariables) -> SGPSpan:
8787
error = get_span_error(span)
8888
if error is not None:
8989
sgp_span.set_error(error_type=error["type"], error_message=error["message"])
90+
sgp_span.metadata["error_category"] = error.get("category", "unknown")
9091
return sgp_span
9192

9293

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, Literal, cast
44

55
from agentex.types.span import Span
66

@@ -13,14 +13,49 @@
1313
# SGP and agentex-native span stores.
1414
SPAN_ERROR_KEY = "__error__"
1515

16+
ErrorCategory = Literal["application", "platform", "unknown"]
17+
ERROR_CATEGORY_UNKNOWN: ErrorCategory = "unknown"
18+
_ERROR_CATEGORIES = frozenset({"application", "platform", "unknown"})
1619

17-
def set_span_error(span: Span, exc: BaseException) -> None:
20+
21+
def _normalize_error_category(value: object) -> ErrorCategory | None:
22+
if isinstance(value, str):
23+
normalized = value.strip().lower()
24+
if normalized in _ERROR_CATEGORIES:
25+
return cast(ErrorCategory, normalized)
26+
return None
27+
28+
29+
def _error_category(
30+
exc: BaseException,
31+
explicit_category: ErrorCategory | str | None = None,
32+
) -> ErrorCategory:
33+
"""Return an explicit producer classification, defaulting safely to unknown."""
34+
return (
35+
_normalize_error_category(explicit_category)
36+
or _normalize_error_category(getattr(exc, "error_category", None))
37+
or ERROR_CATEGORY_UNKNOWN
38+
)
39+
40+
41+
def set_span_error(
42+
span: Span,
43+
exc: BaseException,
44+
*,
45+
error_category: ErrorCategory | str | None = None,
46+
) -> None:
1847
"""Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``.
1948
49+
An explicit ``error_category`` takes precedence over an exception's
50+
``error_category`` attribute. Invalid or absent categories become unknown.
2051
No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which
2152
only attaches metadata to dict-shaped data).
2253
"""
23-
error = {"type": type(exc).__name__, "message": str(exc)}
54+
error = {
55+
"type": type(exc).__name__,
56+
"message": str(exc),
57+
"category": _error_category(exc, error_category),
58+
}
2459
if span.data is None:
2560
span.data = {}
2661
if isinstance(span.data, dict):

tests/lib/adk/test_tracing_module.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from agentex.types.span import Span
1111
from agentex.lib.core.harness.types import TurnUsage
1212
from agentex.lib.adk._modules.tracing import TurnSpan, TracingModule
13+
from agentex.lib.core.tracing.span_error import get_span_error
1314
from agentex.lib.core.services.adk.tracing import TracingService
1415

1516

@@ -249,6 +250,24 @@ async def test_span_context_manager_forwards_task_id(self):
249250
assert mock_service.start_span.call_args.kwargs["task_id"] == "task-abc"
250251
mock_service.end_span.assert_called_once()
251252

253+
async def test_span_context_manager_records_and_reraises_body_error(self):
254+
mock_service, module = _make_module()
255+
started = _make_span()
256+
mock_service.start_span.return_value = started
257+
mock_service.end_span.return_value = started
258+
259+
with patch.object(_tracing_mod, "in_temporal_workflow", return_value=False):
260+
with pytest.raises(RuntimeError, match="boom"):
261+
async with module.span(trace_id="trace-123", name="test-span"):
262+
raise RuntimeError("boom")
263+
264+
assert get_span_error(started) == {
265+
"type": "RuntimeError",
266+
"message": "boom",
267+
"category": "unknown",
268+
}
269+
mock_service.end_span.assert_called_once_with(trace_id="trace-123", span=started)
270+
252271
async def test_span_context_manager_noop_when_no_trace_id(self):
253272
mock_service, module = _make_module()
254273

tests/lib/core/tracing/test_span_error.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,44 @@ class TestSpanErrorHelpers:
3737
def test_set_then_get_on_none_data(self):
3838
span = _make_span(data=None)
3939
set_span_error(span, ValueError("boom"))
40-
assert get_span_error(span) == {"type": "ValueError", "message": "boom"}
40+
assert get_span_error(span) == {
41+
"type": "ValueError",
42+
"message": "boom",
43+
"category": "unknown",
44+
}
4145
assert isinstance(span.data, dict)
42-
assert span.data[SPAN_ERROR_KEY] == {"type": "ValueError", "message": "boom"}
46+
assert span.data[SPAN_ERROR_KEY] == {
47+
"type": "ValueError",
48+
"message": "boom",
49+
"category": "unknown",
50+
}
51+
52+
def test_set_uses_explicit_exception_category(self):
53+
class PlatformFailure(RuntimeError):
54+
error_category = " PLATFORM "
55+
56+
span = _make_span(data=None)
57+
set_span_error(span, PlatformFailure("unavailable"))
58+
assert get_span_error(span) == {
59+
"type": "PlatformFailure",
60+
"message": "unavailable",
61+
"category": "platform",
62+
}
63+
64+
def test_explicit_category_takes_precedence(self):
65+
class PlatformFailure(RuntimeError):
66+
error_category = "platform"
67+
68+
span = _make_span(data=None)
69+
set_span_error(span, PlatformFailure("bad input"), error_category="application")
70+
assert get_span_error(span)["category"] == "application" # type: ignore[index]
71+
72+
def test_set_rejects_invalid_exception_category(self):
73+
exc = RuntimeError("boom")
74+
exc.error_category = "infrastructure" # type: ignore[attr-defined]
75+
span = _make_span(data=None)
76+
set_span_error(span, exc)
77+
assert get_span_error(span)["category"] == "unknown" # type: ignore[index]
4378

4479
def test_set_preserves_existing_dict_keys(self):
4580
span = _make_span(data={"__span_type__": "LLM"})
@@ -76,7 +111,11 @@ def test_sync_span_records_error_and_reraises(self):
76111
captured["span"] = span
77112
raise ValueError("boom")
78113
err = get_span_error(captured["span"])
79-
assert err == {"type": "ValueError", "message": "boom"}
114+
assert err == {
115+
"type": "ValueError",
116+
"message": "boom",
117+
"category": "unknown",
118+
}
80119

81120
def test_sync_span_success_has_no_error(self):
82121
trace = Trace(processors=[], client=MagicMock(), trace_id="t1")
@@ -93,7 +132,11 @@ async def test_async_span_records_error_and_reraises(self):
93132
captured["span"] = span
94133
raise RuntimeError("kaboom")
95134
err = get_span_error(captured["span"])
96-
assert err == {"type": "RuntimeError", "message": "kaboom"}
135+
assert err == {
136+
"type": "RuntimeError",
137+
"message": "kaboom",
138+
"category": "unknown",
139+
}
97140

98141

99142
# ---------------------------------------------------------------------------
@@ -111,7 +154,7 @@ def set_error(
111154
self,
112155
error_type: str | None = None,
113156
error_message: str | None = None,
114-
exception: BaseException | None = None,
157+
exception: BaseException | None = None, # noqa: ARG002
115158
) -> None:
116159
self.status = "ERROR"
117160
self.metadata["error"] = True
@@ -131,14 +174,23 @@ def _env():
131174
def test_error_maps_to_status_error(self):
132175
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span
133176

134-
span = _make_span(data={SPAN_ERROR_KEY: {"type": "ValueError", "message": "boom"}})
177+
span = _make_span(
178+
data={
179+
SPAN_ERROR_KEY: {
180+
"type": "ValueError",
181+
"message": "boom",
182+
"category": "application",
183+
}
184+
}
185+
)
135186
with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span):
136187
sgp_span = _build_sgp_span(span, self._env())
137188

138189
assert sgp_span.status == "ERROR"
139190
assert sgp_span.metadata["error"] is True
140191
assert sgp_span.metadata["error_type"] == "ValueError"
141192
assert sgp_span.metadata["error_message"] == "boom"
193+
assert sgp_span.metadata["error_category"] == "application"
142194

143195
def test_no_error_leaves_status_success(self):
144196
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span

0 commit comments

Comments
 (0)