Skip to content

Commit a2fa8d5

Browse files
NiteshDhanpalclaude
andcommitted
fix(tracing): capture span body exceptions and export SGP status=ERROR
Spans exported to SGP always showed status=SUCCESS even when the operation they represent failed. The SGP span defaults status to "SUCCESS" and only flips to "ERROR" inside its own __exit__ context manager, but the agentex processor builds SGP spans via create_span(...) and flushes them directly, so __exit__ never runs. The Trace.span()/AsyncTrace.span() context managers also ended spans in a bare finally, so a body exception was never recorded. Capture the exception in both context managers and carry it on the span so the SGP processor can map it: - add span_error.py with set_span_error/get_span_error, storing the failure under the reserved span.data["__error__"] key (the Span model is generated from the OpenAPI spec and has no status/error field; data is a real field that survives model_copy(deep=True) and round-trips to both stores) - Trace.span()/AsyncTrace.span(): except -> set_span_error(span, exc); raise - _build_sgp_span(): when an error is present, set sgp_span.status = "ERROR" plus error/error.type/error.message metadata (matching SGP's native __exit__ shape) Exceptions still propagate; asyncio.CancelledError/KeyboardInterrupt are not flagged (control flow, not failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 65abf78 commit a2fa8d5

4 files changed

Lines changed: 185 additions & 0 deletions

File tree

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

Lines changed: 7 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,12 @@ 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.status = "ERROR"
90+
sgp_span.metadata["error"] = True
91+
sgp_span.metadata["error.type"] = error["type"]
92+
sgp_span.metadata["error.message"] = error["message"]
8693
return sgp_span
8794

8895

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

0 commit comments

Comments
 (0)