Skip to content

Commit f2b1808

Browse files
feat: propagate error categories to SGP spans (#486)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 72732b7 commit f2b1808

9 files changed

Lines changed: 166 additions & 18 deletions

File tree

adk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ dependencies = [
5353
"pydantic-ai-slim>=1.0,<2",
5454
"langgraph-checkpoint>=2.0.0",
5555
"scale-gp>=0.1.0a59",
56-
"scale-gp-beta>=0.2.0",
56+
"scale-gp-beta>=0.5.0",
5757
"mcp>=1.4.1",
5858
# Observability
5959
"ddtrace>=3.13.0",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
StartSpanParams,
2020
TracingActivityName,
2121
)
22-
from agentex.lib.core.tracing.tracer import AsyncTracer
2322
from agentex.lib.core.tracing.span_error import set_span_error
23+
from agentex.lib.core.tracing.tracer import AsyncTracer
2424
from agentex.lib.core.harness.types import TurnUsage
2525
from agentex.types.span import Span
2626
from agentex.lib.utils.logging import make_logger

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from agentex.types.span import Span
22
from agentex.lib.core.tracing.trace import Trace, AsyncTrace
33
from agentex.lib.core.tracing.tracer import Tracer, AsyncTracer
4+
from agentex.lib.core.tracing.span_error import (
5+
ErrorCategory,
6+
PlatformError,
7+
ApplicationError,
8+
CategorizedError,
9+
)
410
from agentex.lib.core.tracing.span_queue import (
511
AsyncSpanQueue,
612
get_default_span_queue,
@@ -13,6 +19,10 @@
1319
"Span",
1420
"Tracer",
1521
"AsyncTracer",
22+
"CategorizedError",
23+
"ApplicationError",
24+
"PlatformError",
25+
"ErrorCategory",
1626
"AsyncSpanQueue",
1727
"get_default_span_queue",
1828
"shutdown_default_span_queue",

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: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from typing import Any, cast
4+
5+
from scale_gp_beta.lib.tracing import (
6+
PlatformError as PlatformError,
7+
ApplicationError as ApplicationError,
8+
CategorizedError,
9+
)
10+
from scale_gp_beta.lib.tracing.types import ErrorCategory
411

512
from agentex.types.span import Span
613

@@ -13,14 +20,48 @@
1320
# SGP and agentex-native span stores.
1421
SPAN_ERROR_KEY = "__error__"
1522

23+
ERROR_CATEGORY_UNKNOWN: ErrorCategory = "unknown"
24+
_ERROR_CATEGORIES = frozenset({"application", "platform", "unknown"})
25+
26+
27+
def _normalize_error_category(value: object) -> ErrorCategory | None:
28+
if isinstance(value, str):
29+
normalized = value.strip().lower()
30+
if normalized in _ERROR_CATEGORIES:
31+
return cast(ErrorCategory, normalized)
32+
return None
33+
34+
35+
def _error_category(
36+
exc: BaseException,
37+
explicit_category: ErrorCategory | str | None = None,
38+
) -> ErrorCategory:
39+
"""Return an explicit producer classification, defaulting safely to unknown."""
40+
return (
41+
_normalize_error_category(explicit_category)
42+
or (exc.error_category if isinstance(exc, CategorizedError) else None)
43+
or ERROR_CATEGORY_UNKNOWN
44+
)
45+
1646

17-
def set_span_error(span: Span, exc: BaseException) -> None:
47+
def set_span_error(
48+
span: Span,
49+
exc: BaseException,
50+
*,
51+
error_category: ErrorCategory | str | None = None,
52+
) -> None:
1853
"""Record an exception on ``span`` under ``data[SPAN_ERROR_KEY]``.
1954
55+
An explicit ``error_category`` takes precedence over a ``CategorizedError``
56+
classification. Invalid or absent categories become unknown.
2057
No-op when ``span.data`` is a list (matching ``_add_source_to_span``, which
2158
only attaches metadata to dict-shaped data).
2259
"""
23-
error = {"type": type(exc).__name__, "message": str(exc)}
60+
error = {
61+
"type": type(exc).__name__,
62+
"message": str(exc),
63+
"category": _error_category(exc, error_category),
64+
}
2465
if span.data is None:
2566
span.data = {}
2667
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: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
from unittest.mock import MagicMock, patch
77

88
import pytest
9+
from scale_gp_beta.lib.tracing import (
10+
PlatformError as SGPPlatformError,
11+
ApplicationError as SGPApplicationError,
12+
CategorizedError as SGPCategorizedError,
13+
)
914

1015
from agentex.types.span import Span
1116
from agentex.lib.core.tracing.trace import Trace, AsyncTrace
1217
from agentex.lib.core.tracing.span_error import (
1318
SPAN_ERROR_KEY,
19+
PlatformError,
20+
ApplicationError,
21+
CategorizedError,
1422
get_span_error,
1523
set_span_error,
1624
)
@@ -34,12 +42,52 @@ def _make_span(data=None) -> Span:
3442

3543

3644
class TestSpanErrorHelpers:
45+
def test_uses_canonical_sgp_error_types(self):
46+
assert CategorizedError is SGPCategorizedError
47+
assert ApplicationError is SGPApplicationError
48+
assert PlatformError is SGPPlatformError
49+
3750
def test_set_then_get_on_none_data(self):
3851
span = _make_span(data=None)
3952
set_span_error(span, ValueError("boom"))
40-
assert get_span_error(span) == {"type": "ValueError", "message": "boom"}
53+
assert get_span_error(span) == {
54+
"type": "ValueError",
55+
"message": "boom",
56+
"category": "unknown",
57+
}
4158
assert isinstance(span.data, dict)
42-
assert span.data[SPAN_ERROR_KEY] == {"type": "ValueError", "message": "boom"}
59+
assert span.data[SPAN_ERROR_KEY] == {
60+
"type": "ValueError",
61+
"message": "boom",
62+
"category": "unknown",
63+
}
64+
65+
def test_set_uses_explicit_exception_category(self):
66+
span = _make_span(data=None)
67+
set_span_error(span, PlatformError("unavailable"))
68+
assert get_span_error(span) == {
69+
"type": "PlatformError",
70+
"message": "unavailable",
71+
"category": "platform",
72+
}
73+
74+
def test_explicit_category_takes_precedence(self):
75+
span = _make_span(data=None)
76+
set_span_error(span, PlatformError("bad input"), error_category="application")
77+
assert get_span_error(span)["category"] == "application" # type: ignore[index]
78+
79+
def test_set_uses_application_error_category(self):
80+
span = _make_span(data=None)
81+
set_span_error(span, ApplicationError("bad input"))
82+
assert get_span_error(span)["category"] == "application" # type: ignore[index]
83+
84+
def test_bare_exception_attribute_does_not_opt_in(self):
85+
class ImplicitlyCategorizedError(RuntimeError):
86+
error_category = "platform"
87+
88+
span = _make_span(data=None)
89+
set_span_error(span, ImplicitlyCategorizedError("boom"))
90+
assert get_span_error(span)["category"] == "unknown" # type: ignore[index]
4391

4492
def test_set_preserves_existing_dict_keys(self):
4593
span = _make_span(data={"__span_type__": "LLM"})
@@ -76,7 +124,11 @@ def test_sync_span_records_error_and_reraises(self):
76124
captured["span"] = span
77125
raise ValueError("boom")
78126
err = get_span_error(captured["span"])
79-
assert err == {"type": "ValueError", "message": "boom"}
127+
assert err == {
128+
"type": "ValueError",
129+
"message": "boom",
130+
"category": "unknown",
131+
}
80132

81133
def test_sync_span_success_has_no_error(self):
82134
trace = Trace(processors=[], client=MagicMock(), trace_id="t1")
@@ -93,7 +145,11 @@ async def test_async_span_records_error_and_reraises(self):
93145
captured["span"] = span
94146
raise RuntimeError("kaboom")
95147
err = get_span_error(captured["span"])
96-
assert err == {"type": "RuntimeError", "message": "kaboom"}
148+
assert err == {
149+
"type": "RuntimeError",
150+
"message": "kaboom",
151+
"category": "unknown",
152+
}
97153

98154

99155
# ---------------------------------------------------------------------------
@@ -111,7 +167,7 @@ def set_error(
111167
self,
112168
error_type: str | None = None,
113169
error_message: str | None = None,
114-
exception: BaseException | None = None,
170+
exception: BaseException | None = None, # noqa: ARG002
115171
) -> None:
116172
self.status = "ERROR"
117173
self.metadata["error"] = True
@@ -131,14 +187,23 @@ def _env():
131187
def test_error_maps_to_status_error(self):
132188
from agentex.lib.core.tracing.processors.sgp_tracing_processor import _build_sgp_span
133189

134-
span = _make_span(data={SPAN_ERROR_KEY: {"type": "ValueError", "message": "boom"}})
190+
span = _make_span(
191+
data={
192+
SPAN_ERROR_KEY: {
193+
"type": "ValueError",
194+
"message": "boom",
195+
"category": "application",
196+
}
197+
}
198+
)
135199
with patch(f"{PROCESSOR_MODULE}.create_span", side_effect=_fake_create_span):
136200
sgp_span = _build_sgp_span(span, self._env())
137201

138202
assert sgp_span.status == "ERROR"
139203
assert sgp_span.metadata["error"] is True
140204
assert sgp_span.metadata["error_type"] == "ValueError"
141205
assert sgp_span.metadata["error_message"] == "boom"
206+
assert sgp_span.metadata["error_category"] == "application"
142207

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

tests/test_adk_tracing_span_error.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ async def test_span_records_error_and_reraises() -> None:
4848
raise ValueError("boom")
4949

5050
error = get_span_error(span)
51-
assert error == {"type": "ValueError", "message": "boom"}
51+
assert error == {
52+
"type": "ValueError",
53+
"message": "boom",
54+
"category": "unknown",
55+
}
5256

5357
# end_span still ran (finally) and saw the span with the error already set,
5458
# so the failure is what gets persisted -- not a false green.
5559
end_span.assert_awaited_once()
5660
persisted_span = end_span.await_args.kwargs["span"]
57-
assert get_span_error(persisted_span) == {"type": "ValueError", "message": "boom"}
61+
assert get_span_error(persisted_span) == {
62+
"type": "ValueError",
63+
"message": "boom",
64+
"category": "unknown",
65+
}
5866

5967

6068
async def test_span_success_records_no_error() -> None:
@@ -104,5 +112,9 @@ async def test_turn_span_records_error_and_reraises() -> None:
104112
assert turn.span is span
105113
raise ValueError("boom")
106114

107-
assert get_span_error(span) == {"type": "ValueError", "message": "boom"}
115+
assert get_span_error(span) == {
116+
"type": "ValueError",
117+
"message": "boom",
118+
"category": "unknown",
119+
}
108120
end_span.assert_awaited_once()

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)