trodo-python 2.8.0__tar.gz → 2.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trodo_python-2.8.0 → trodo_python-2.10.0}/PKG-INFO +1 -1
- {trodo_python-2.8.0 → trodo_python-2.10.0}/pyproject.toml +1 -1
- trodo_python-2.10.0/tests/test_anon_distinct_id.py +100 -0
- trodo_python-2.10.0/tests/test_error_enrichment.py +107 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/__init__.py +1 -1
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/auto_instrument.py +35 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/helpers.py +1 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/processor.py +12 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/wrap_agent.py +158 -13
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/SOURCES.txt +2 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/README.md +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/setup.cfg +0 -0
- /trodo_python-2.8.0/tests/test_anon_distinct_id.py → /trodo_python-2.10.0/tests/test_anon_distinct_id 2.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/types.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/user_context.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Anonymous distinct_id minting on agent surfaces.
|
|
2
|
+
|
|
3
|
+
When the caller doesn't pass ``distinct_id``, the SDK mints an
|
|
4
|
+
``anon_<ts>_python_<uuid>_<rand>`` id so:
|
|
5
|
+
|
|
6
|
+
* the agent_runs row always lands with a non-null distinct_id,
|
|
7
|
+
* the RunHandle exposes the same id so callers can bind
|
|
8
|
+
``trodo.feedback(distinct_id=...)`` later,
|
|
9
|
+
* older backends without identity-resolution still attribute the row.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from trodo.otel.wrap_agent import _mint_anon_distinct_id, start_run, wrap_agent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ANON_RE = re.compile(r"^anon_\d+_python_")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_mint_anon_distinct_id_shape():
|
|
22
|
+
a = _mint_anon_distinct_id()
|
|
23
|
+
assert ANON_RE.match(a)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_mint_anon_distinct_id_unique_under_load():
|
|
27
|
+
ids = {_mint_anon_distinct_id() for _ in range(1000)}
|
|
28
|
+
assert len(ids) == 1000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_wrap_agent_mints_anon_when_distinct_id_omitted(processor, http):
|
|
32
|
+
observed = {}
|
|
33
|
+
with wrap_agent(
|
|
34
|
+
processor=processor,
|
|
35
|
+
team_site_id="site-x",
|
|
36
|
+
agent_name="chat",
|
|
37
|
+
) as run:
|
|
38
|
+
observed["distinct_id"] = run.distinct_id
|
|
39
|
+
run.set_output("done")
|
|
40
|
+
|
|
41
|
+
assert ANON_RE.match(observed["distinct_id"])
|
|
42
|
+
assert len(http.run_ingest) == 1
|
|
43
|
+
assert http.run_ingest[0]["run"]["distinct_id"] == observed["distinct_id"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_wrap_agent_respects_explicit_distinct_id(processor, http):
|
|
47
|
+
with wrap_agent(
|
|
48
|
+
processor=processor,
|
|
49
|
+
team_site_id="site-x",
|
|
50
|
+
agent_name="chat",
|
|
51
|
+
distinct_id="user-42",
|
|
52
|
+
) as run:
|
|
53
|
+
assert run.distinct_id == "user-42"
|
|
54
|
+
|
|
55
|
+
assert http.run_ingest[0]["run"]["distinct_id"] == "user-42"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_wrap_agent_mints_different_anon_ids_across_calls(processor, http):
|
|
59
|
+
seen = []
|
|
60
|
+
for _ in range(3):
|
|
61
|
+
with wrap_agent(
|
|
62
|
+
processor=processor,
|
|
63
|
+
team_site_id="site-x",
|
|
64
|
+
agent_name="chat",
|
|
65
|
+
) as run:
|
|
66
|
+
seen.append(run.distinct_id)
|
|
67
|
+
assert len(set(seen)) == 3
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_wrap_agent_still_mints_anon_on_error(processor, http):
|
|
71
|
+
import pytest
|
|
72
|
+
with pytest.raises(ValueError):
|
|
73
|
+
with wrap_agent(
|
|
74
|
+
processor=processor,
|
|
75
|
+
team_site_id="site-x",
|
|
76
|
+
agent_name="chat",
|
|
77
|
+
) as _run:
|
|
78
|
+
raise ValueError("boom")
|
|
79
|
+
|
|
80
|
+
assert len(http.run_ingest) == 1
|
|
81
|
+
payload = http.run_ingest[0]["run"]
|
|
82
|
+
assert payload["status"] == "error"
|
|
83
|
+
assert ANON_RE.match(payload["distinct_id"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_start_run_mints_anon_when_distinct_id_omitted(processor, http):
|
|
87
|
+
start_run(processor=processor, agent_name="external_session")
|
|
88
|
+
assert len(http.run_start) == 1
|
|
89
|
+
run = http.run_start[0]["run"]
|
|
90
|
+
assert ANON_RE.match(run["distinct_id"])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_start_run_respects_explicit_distinct_id(processor, http):
|
|
94
|
+
start_run(
|
|
95
|
+
processor=processor,
|
|
96
|
+
agent_name="external_session",
|
|
97
|
+
distinct_id="user-7",
|
|
98
|
+
)
|
|
99
|
+
run = http.run_start[0]["run"]
|
|
100
|
+
assert run["distinct_id"] == "user-7"
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Rich error instrumentation (v2.9.0).
|
|
2
|
+
|
|
3
|
+
Proves thrown errors surface error TYPE, HTTP/provider STATUS CODE, STACK
|
|
4
|
+
TRACE, and severity LEVEL on both spans and runs — Langfuse parity — instead
|
|
5
|
+
of only a status + message.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from trodo.otel.wrap_agent import wrap_agent, span, describe_error
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitError(Exception):
|
|
15
|
+
"""Stand-in for an OpenAI/Anthropic SDK error with an HTTP status + code."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = 429
|
|
20
|
+
self.code = "rate_limit_exceeded"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_describe_error_extracts_type_status_and_stack():
|
|
24
|
+
try:
|
|
25
|
+
raise RateLimitError("rate limit exceeded")
|
|
26
|
+
except RateLimitError as e:
|
|
27
|
+
info = describe_error(type(e), e, e.__traceback__)
|
|
28
|
+
assert info["error_type"] == "RateLimitError"
|
|
29
|
+
assert info["error_message"] == "rate limit exceeded"
|
|
30
|
+
assert info["status_code"] == "429" # HTTP status wins over .code
|
|
31
|
+
assert info["stack_trace"] and "RateLimitError" in info["stack_trace"]
|
|
32
|
+
assert info["level"] == "error"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_describe_error_falls_back_to_response_status():
|
|
36
|
+
class Resp:
|
|
37
|
+
status_code = 503
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
e = RuntimeError("upstream down")
|
|
41
|
+
e.response = Resp() # type: ignore[attr-defined]
|
|
42
|
+
raise e
|
|
43
|
+
except RuntimeError as ex:
|
|
44
|
+
info = describe_error(type(ex), ex, ex.__traceback__)
|
|
45
|
+
assert info["error_type"] == "RuntimeError"
|
|
46
|
+
assert info["status_code"] == "503"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_errored_span_and_run_carry_rich_error_fields(processor, http):
|
|
50
|
+
with pytest.raises(RateLimitError):
|
|
51
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
|
|
52
|
+
run.set_input("query")
|
|
53
|
+
with span("call-model", kind="llm"):
|
|
54
|
+
raise RateLimitError("rate limit exceeded")
|
|
55
|
+
|
|
56
|
+
ingest = http.run_ingest[0]
|
|
57
|
+
run_payload = ingest["run"]
|
|
58
|
+
spans = ingest.get("spans", [])
|
|
59
|
+
errored = next(s for s in spans if s["status"] == "error")
|
|
60
|
+
|
|
61
|
+
assert errored["error_type"] == "RateLimitError"
|
|
62
|
+
assert errored["error_message"] == "rate limit exceeded"
|
|
63
|
+
assert errored["status_code"] == "429"
|
|
64
|
+
assert "RateLimitError" in errored["stack_trace"]
|
|
65
|
+
assert errored["level"] == "error"
|
|
66
|
+
|
|
67
|
+
assert run_payload["status"] == "error"
|
|
68
|
+
assert run_payload["level"] == "error"
|
|
69
|
+
assert run_payload["error_type"] == "RateLimitError"
|
|
70
|
+
assert "rate limit exceeded" in run_payload["error_summary"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_set_error_records_without_raising(processor, http):
|
|
74
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as run:
|
|
75
|
+
with span("soft-step", kind="tool") as s:
|
|
76
|
+
s.set_error("bad input", type="ValidationError", status_code=422)
|
|
77
|
+
# no raise — the span still finalises as error
|
|
78
|
+
run.set_error_summary("partial failure", type="PartialFailure")
|
|
79
|
+
|
|
80
|
+
ingest = http.run_ingest[0]
|
|
81
|
+
run_payload = ingest["run"]
|
|
82
|
+
s = next(x for x in ingest.get("spans", []) if x["name"] == "soft-step")
|
|
83
|
+
|
|
84
|
+
assert s["status"] == "error"
|
|
85
|
+
assert s["error_type"] == "ValidationError"
|
|
86
|
+
assert s["error_message"] == "bad input"
|
|
87
|
+
assert s["status_code"] == "422"
|
|
88
|
+
assert s["level"] == "error"
|
|
89
|
+
|
|
90
|
+
assert run_payload["status"] == "error"
|
|
91
|
+
assert run_payload["error_summary"] == "partial failure"
|
|
92
|
+
assert run_payload["error_type"] == "PartialFailure"
|
|
93
|
+
assert run_payload["level"] == "error"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_healthy_span_omits_error_fields(processor, http):
|
|
97
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
98
|
+
with span("ok-step", kind="generic"):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
spans = http.run_ingest[0].get("spans", [])
|
|
102
|
+
ok = next(s for s in spans if s["name"] == "ok-step")
|
|
103
|
+
assert ok["status"] == "ok"
|
|
104
|
+
# to_dict() drops None fields, so error keys must be absent entirely.
|
|
105
|
+
assert "error_type" not in ok
|
|
106
|
+
assert "status_code" not in ok
|
|
107
|
+
assert "stack_trace" not in ok
|
|
@@ -22,6 +22,13 @@ def _hr_to_iso(nanos: Optional[int]) -> Optional[str]:
|
|
|
22
22
|
return datetime.fromtimestamp(nanos / 1e9, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _trunc(s: Any, max_len: int) -> Optional[str]:
|
|
26
|
+
if s is None:
|
|
27
|
+
return None
|
|
28
|
+
s = str(s)
|
|
29
|
+
return s[:max_len] if len(s) > max_len else s
|
|
30
|
+
|
|
31
|
+
|
|
25
32
|
def _infer_kind(attrs: Dict[str, Any]) -> str:
|
|
26
33
|
if not attrs:
|
|
27
34
|
return "generic"
|
|
@@ -111,6 +118,29 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
111
118
|
except Exception:
|
|
112
119
|
ok = "error" if (status_code and str(status_code).endswith("ERROR")) else "ok"
|
|
113
120
|
|
|
121
|
+
# Rich error detail. The real exception class + stacktrace live in the OTel
|
|
122
|
+
# `exception` event (record_exception — emitted by the Anthropic/OpenAI/
|
|
123
|
+
# LangChain instrumentors), NOT span.status. The old bridge read only the
|
|
124
|
+
# status code and dropped all of it.
|
|
125
|
+
status_desc = getattr(status, "description", None)
|
|
126
|
+
exc_attrs: Dict[str, Any] = {}
|
|
127
|
+
for ev in (getattr(otel_span, "events", None) or []):
|
|
128
|
+
if getattr(ev, "name", None) == "exception":
|
|
129
|
+
exc_attrs = dict(getattr(ev, "attributes", {}) or {})
|
|
130
|
+
break
|
|
131
|
+
err_type = exc_attrs.get("exception.type") or attrs.get("exception.type") or attrs.get("error.type")
|
|
132
|
+
err_msg = exc_attrs.get("exception.message") or attrs.get("exception.message") or status_desc
|
|
133
|
+
err_stack = exc_attrs.get("exception.stacktrace") or attrs.get("exception.stacktrace")
|
|
134
|
+
status_code_val = (
|
|
135
|
+
attrs.get("http.response.status_code")
|
|
136
|
+
or attrs.get("http.status_code")
|
|
137
|
+
or attrs.get("gen_ai.response.status_code")
|
|
138
|
+
or attrs.get("error.code")
|
|
139
|
+
)
|
|
140
|
+
has_error = ok == "error" or err_type is not None
|
|
141
|
+
ok = "error" if has_error else "ok"
|
|
142
|
+
level = "error" if has_error else "default"
|
|
143
|
+
|
|
114
144
|
# Accept both stable and experimental GenAI semconv keys.
|
|
115
145
|
in_toks = (
|
|
116
146
|
attrs.get("gen_ai.usage.input_tokens")
|
|
@@ -138,6 +168,11 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
138
168
|
kind=kind,
|
|
139
169
|
name=getattr(otel_span, "name", kind),
|
|
140
170
|
status=ok,
|
|
171
|
+
level=level,
|
|
172
|
+
error_type=_trunc(err_type, 128) if err_type else None,
|
|
173
|
+
error_message=_trunc(err_msg, 4_000) if err_msg else None,
|
|
174
|
+
status_code=_trunc(status_code_val, 32) if status_code_val is not None else None,
|
|
175
|
+
stack_trace=_trunc(err_stack, 20_000) if err_stack else None,
|
|
141
176
|
started_at=started_at,
|
|
142
177
|
ended_at=ended_at,
|
|
143
178
|
duration_ms=duration_ms,
|
|
@@ -413,6 +413,7 @@ def track_mcp(
|
|
|
413
413
|
"kind": "tool",
|
|
414
414
|
"name": f"tool.{tool}",
|
|
415
415
|
"status": status,
|
|
416
|
+
"level": "error" if error else "default",
|
|
416
417
|
"input": _stringify({"tool": tool, "params": input}) if input is not None else None,
|
|
417
418
|
"output": _stringify(output_to_record),
|
|
418
419
|
"tool_name": tool,
|
|
@@ -20,12 +20,18 @@ class TrodoRun:
|
|
|
20
20
|
conversation_id: Optional[str] = None
|
|
21
21
|
parent_run_id: Optional[str] = None
|
|
22
22
|
status: str = "ok" # 'running' | 'ok' | 'error'
|
|
23
|
+
# Severity (Langfuse parity): 'debug' | 'default' | 'warning' | 'error'.
|
|
24
|
+
# Optional — backend derives it from status when omitted.
|
|
25
|
+
level: Optional[str] = None
|
|
23
26
|
input: Optional[Union[str, Dict[str, Any]]] = None
|
|
24
27
|
output: Optional[Union[str, Dict[str, Any]]] = None
|
|
25
28
|
started_at: Optional[str] = None
|
|
26
29
|
ended_at: Optional[str] = None
|
|
27
30
|
duration_ms: Optional[int] = None
|
|
28
31
|
error_summary: Optional[str] = None
|
|
32
|
+
# Exception class of the run-level failure (runs previously only had the
|
|
33
|
+
# free-text error_summary).
|
|
34
|
+
error_type: Optional[str] = None
|
|
29
35
|
metadata: Optional[Dict[str, Any]] = None
|
|
30
36
|
# Aggregates summed from child spans at finalisation.
|
|
31
37
|
total_tokens_in: Optional[int] = None
|
|
@@ -47,6 +53,8 @@ class TrodoSpan:
|
|
|
47
53
|
kind: str = "generic" # 'llm' | 'tool' | 'agent' | 'retrieval' | 'generic'
|
|
48
54
|
name: str = ""
|
|
49
55
|
status: str = "ok"
|
|
56
|
+
# Severity (Langfuse parity): 'debug' | 'default' | 'warning' | 'error'.
|
|
57
|
+
level: Optional[str] = None
|
|
50
58
|
started_at: Optional[str] = None
|
|
51
59
|
ended_at: Optional[str] = None
|
|
52
60
|
duration_ms: Optional[int] = None
|
|
@@ -54,6 +62,10 @@ class TrodoSpan:
|
|
|
54
62
|
output: Optional[Union[str, Dict[str, Any]]] = None
|
|
55
63
|
error_type: Optional[str] = None
|
|
56
64
|
error_message: Optional[str] = None
|
|
65
|
+
# HTTP/provider status code (e.g. '429', 'rate_limit_exceeded').
|
|
66
|
+
status_code: Optional[str] = None
|
|
67
|
+
# Truncated exception stacktrace.
|
|
68
|
+
stack_trace: Optional[str] = None
|
|
57
69
|
model: Optional[str] = None
|
|
58
70
|
provider: Optional[str] = None
|
|
59
71
|
input_tokens: Optional[int] = None
|
|
@@ -26,6 +26,7 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import json
|
|
28
28
|
import time
|
|
29
|
+
import traceback
|
|
29
30
|
import uuid
|
|
30
31
|
from datetime import datetime, timezone
|
|
31
32
|
from typing import Any, Callable, Dict, Optional, Union
|
|
@@ -79,6 +80,75 @@ def _truncate(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[str]:
|
|
|
79
80
|
return s[:max_len] if len(s) > max_len else s
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def describe_error(exc_type, exc, tb=None) -> Dict[str, Optional[str]]:
|
|
84
|
+
"""Extract rich error detail from a caught exception so spans carry error
|
|
85
|
+
TYPE, HTTP/provider STATUS CODE, and STACK TRACE — not just a message.
|
|
86
|
+
|
|
87
|
+
Works generically across provider SDKs: OpenAI (``exc.status`` /
|
|
88
|
+
``exc.code``), Anthropic (``exc.status``), httpx/requests
|
|
89
|
+
(``exc.response.status_code``), and stdlib errors (``exc.errno``). Never
|
|
90
|
+
raises. Returns keys: error_type, error_message, status_code, stack_trace,
|
|
91
|
+
level.
|
|
92
|
+
"""
|
|
93
|
+
if exc is None:
|
|
94
|
+
return {"error_type": None, "error_message": None, "status_code": None,
|
|
95
|
+
"stack_trace": None, "level": "error"}
|
|
96
|
+
error_type = getattr(exc_type, "__name__", None) or type(exc).__name__
|
|
97
|
+
error_message = _truncate(str(exc), 4_000)
|
|
98
|
+
|
|
99
|
+
status_code: Optional[str] = None
|
|
100
|
+
# HTTP status first (numeric), then a provider/system error code.
|
|
101
|
+
for attr in ("status", "status_code"):
|
|
102
|
+
v = getattr(exc, attr, None)
|
|
103
|
+
if v is not None:
|
|
104
|
+
status_code = str(v)[:32]
|
|
105
|
+
break
|
|
106
|
+
if status_code is None:
|
|
107
|
+
resp = getattr(exc, "response", None)
|
|
108
|
+
rc = getattr(resp, "status_code", None) if resp is not None else None
|
|
109
|
+
if rc is not None:
|
|
110
|
+
status_code = str(rc)[:32]
|
|
111
|
+
if status_code is None:
|
|
112
|
+
code = getattr(exc, "code", None) or getattr(exc, "errno", None)
|
|
113
|
+
if code is not None:
|
|
114
|
+
status_code = str(code)[:32]
|
|
115
|
+
|
|
116
|
+
stack_trace: Optional[str] = None
|
|
117
|
+
try:
|
|
118
|
+
stack_trace = _truncate("".join(traceback.format_exception(exc_type, exc, tb)), 20_000)
|
|
119
|
+
except Exception:
|
|
120
|
+
stack_trace = None
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"error_type": (error_type or "Error")[:128],
|
|
124
|
+
"error_message": error_message,
|
|
125
|
+
"status_code": status_code,
|
|
126
|
+
"stack_trace": stack_trace,
|
|
127
|
+
"level": "error",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_error(handle, exc_type, exc, tb) -> Dict[str, Optional[str]]:
|
|
132
|
+
"""Resolve a span's final error state from either a raised exception (wins)
|
|
133
|
+
or a manual ``handle.set_error(...)``. Returns describe_error's keys plus a
|
|
134
|
+
``status`` of 'ok'|'error'."""
|
|
135
|
+
if exc is not None:
|
|
136
|
+
info = describe_error(exc_type, exc, tb)
|
|
137
|
+
info["status"] = "error"
|
|
138
|
+
return info
|
|
139
|
+
if handle is not None and getattr(handle, "has_error", False):
|
|
140
|
+
return {
|
|
141
|
+
"status": "error",
|
|
142
|
+
"error_type": handle.error_type,
|
|
143
|
+
"error_message": handle.error_message,
|
|
144
|
+
"status_code": handle.status_code,
|
|
145
|
+
"stack_trace": handle.stack_trace,
|
|
146
|
+
"level": "error",
|
|
147
|
+
}
|
|
148
|
+
return {"status": "ok", "error_type": None, "error_message": None,
|
|
149
|
+
"status_code": None, "stack_trace": None, "level": None}
|
|
150
|
+
|
|
151
|
+
|
|
82
152
|
def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any]]]:
|
|
83
153
|
"""Prepare a value for storage in the JSONB input/output column.
|
|
84
154
|
|
|
@@ -172,6 +242,10 @@ class RunHandle:
|
|
|
172
242
|
self.input: Optional[Union[str, Dict[str, Any]]] = None
|
|
173
243
|
self.output: Optional[Union[str, Dict[str, Any]]] = None
|
|
174
244
|
self.metadata: Dict[str, Any] = {}
|
|
245
|
+
# Manually-recorded run-level error (via set_error_summary). When set
|
|
246
|
+
# without a raised exception, the run finalises as ``error``.
|
|
247
|
+
self.error_summary: Optional[str] = None
|
|
248
|
+
self.error_type: Optional[str] = None
|
|
175
249
|
|
|
176
250
|
def set_input(self, value: Any) -> None:
|
|
177
251
|
self.input = _prepare_value(value)
|
|
@@ -182,6 +256,15 @@ class RunHandle:
|
|
|
182
256
|
def set_metadata(self, **kwargs: Any) -> None:
|
|
183
257
|
self.metadata.update(kwargs)
|
|
184
258
|
|
|
259
|
+
def set_error_summary(self, summary: str, *, type: Optional[str] = None) -> None:
|
|
260
|
+
"""Mark the run as errored without raising — e.g. a soft failure the
|
|
261
|
+
agent recovered from but you still want recorded. Sets ``status='error'``
|
|
262
|
+
and ``error_summary`` on the run at finalisation.
|
|
263
|
+
"""
|
|
264
|
+
self.error_summary = _truncate(summary, 4_000)
|
|
265
|
+
if type:
|
|
266
|
+
self.error_type = str(type)[:128]
|
|
267
|
+
|
|
185
268
|
|
|
186
269
|
class SpanHandle:
|
|
187
270
|
"""Handle returned by span context manager for setting output/attrs."""
|
|
@@ -204,6 +287,48 @@ class SpanHandle:
|
|
|
204
287
|
self.cost_details: Optional[Dict[str, float]] = None
|
|
205
288
|
self.temperature: Optional[float] = None
|
|
206
289
|
self.tool_name: Optional[str] = None
|
|
290
|
+
# Severity for this span (Langfuse parity). Leave None for the default
|
|
291
|
+
# behaviour (backend derives 'error' on a thrown exception, else
|
|
292
|
+
# 'default'). Set 'warning' for a recovered/retried step, 'debug' for
|
|
293
|
+
# verbose spans.
|
|
294
|
+
self.level: Optional[str] = None
|
|
295
|
+
# Manually-recorded error (via set_error) — applied at span close even
|
|
296
|
+
# when the body didn't raise. A raised exception still wins over these.
|
|
297
|
+
self.error_type: Optional[str] = None
|
|
298
|
+
self.error_message: Optional[str] = None
|
|
299
|
+
self.status_code: Optional[str] = None
|
|
300
|
+
self.stack_trace: Optional[str] = None
|
|
301
|
+
|
|
302
|
+
def set_level(self, level: str) -> None:
|
|
303
|
+
"""Mark this span's severity (does not change ok/error status)."""
|
|
304
|
+
self.level = level
|
|
305
|
+
|
|
306
|
+
def set_error(
|
|
307
|
+
self,
|
|
308
|
+
message: str,
|
|
309
|
+
*,
|
|
310
|
+
type: Optional[str] = None,
|
|
311
|
+
status_code: Optional[Any] = None,
|
|
312
|
+
stack_trace: Optional[str] = None,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Record an error on this span without raising — for when you catch an
|
|
315
|
+
exception to recover but still want it captured. Sets ``status='error'``
|
|
316
|
+
plus ``error_type`` / ``error_message`` (and optional ``status_code``),
|
|
317
|
+
and defaults the severity to ``error``.
|
|
318
|
+
"""
|
|
319
|
+
self.error_message = _truncate(message, 4_000)
|
|
320
|
+
self.error_type = str(type or "Error")[:128]
|
|
321
|
+
if status_code is not None and status_code != "":
|
|
322
|
+
self.status_code = str(status_code)[:32]
|
|
323
|
+
if stack_trace:
|
|
324
|
+
self.stack_trace = _truncate(stack_trace, 20_000)
|
|
325
|
+
if self.level is None:
|
|
326
|
+
self.level = "error"
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def has_error(self) -> bool:
|
|
330
|
+
"""True when set_error was called (span should finalise as error)."""
|
|
331
|
+
return self.error_message is not None or self.error_type is not None
|
|
207
332
|
|
|
208
333
|
def set_input(self, value: Any) -> None:
|
|
209
334
|
self.input = _prepare_value(value)
|
|
@@ -418,10 +543,22 @@ class wrap_agent:
|
|
|
418
543
|
assert self.handle is not None
|
|
419
544
|
ended_iso = _now_iso()
|
|
420
545
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
421
|
-
|
|
422
|
-
|
|
546
|
+
# Error precedence: a raised exception wins; otherwise a manual
|
|
547
|
+
# run.set_error_summary(...) marks the run errored without raising.
|
|
548
|
+
manual_run_error = self.handle.error_summary is not None
|
|
423
549
|
if exc is not None:
|
|
424
|
-
|
|
550
|
+
status = "error"
|
|
551
|
+
einfo = describe_error(exc_type, exc, tb)
|
|
552
|
+
error_summary = einfo["error_message"]
|
|
553
|
+
error_type = einfo["error_type"]
|
|
554
|
+
elif manual_run_error:
|
|
555
|
+
status = "error"
|
|
556
|
+
error_summary = self.handle.error_summary
|
|
557
|
+
error_type = self.handle.error_type
|
|
558
|
+
else:
|
|
559
|
+
status = "ok"
|
|
560
|
+
error_summary = None
|
|
561
|
+
error_type = None
|
|
425
562
|
|
|
426
563
|
pending = self._processor.get_pending(self.handle.run_id)
|
|
427
564
|
agg = _aggregate(pending)
|
|
@@ -433,12 +570,14 @@ class wrap_agent:
|
|
|
433
570
|
conversation_id=self._conversation_id,
|
|
434
571
|
parent_run_id=self._parent_run_id,
|
|
435
572
|
status=status,
|
|
573
|
+
level="error" if status == "error" else None,
|
|
436
574
|
input=self.handle.input,
|
|
437
575
|
output=self.handle.output,
|
|
438
576
|
started_at=self._started_iso,
|
|
439
577
|
ended_at=ended_iso,
|
|
440
578
|
duration_ms=duration_ms,
|
|
441
579
|
error_summary=error_summary,
|
|
580
|
+
error_type=error_type,
|
|
442
581
|
metadata={**(self._metadata or {}), **self.handle.metadata} or None,
|
|
443
582
|
total_tokens_in=agg["total_tokens_in"],
|
|
444
583
|
total_tokens_out=agg["total_tokens_out"],
|
|
@@ -583,9 +722,9 @@ class join_run:
|
|
|
583
722
|
return None
|
|
584
723
|
ended_iso = _now_iso()
|
|
585
724
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
725
|
+
einfo = _resolve_error(self.handle, exc_type, exc, tb)
|
|
726
|
+
status = einfo["status"]
|
|
727
|
+
level = self.handle.level or einfo["level"]
|
|
589
728
|
|
|
590
729
|
trodo_span = TrodoSpan(
|
|
591
730
|
span_id=self._span_id,
|
|
@@ -594,13 +733,16 @@ class join_run:
|
|
|
594
733
|
kind=self._kind,
|
|
595
734
|
name=self._name,
|
|
596
735
|
status=status,
|
|
736
|
+
level=level,
|
|
597
737
|
started_at=self._started_iso,
|
|
598
738
|
ended_at=ended_iso,
|
|
599
739
|
duration_ms=duration_ms,
|
|
600
740
|
input=self.handle.input,
|
|
601
741
|
output=self.handle.output,
|
|
602
|
-
error_type=error_type,
|
|
603
|
-
error_message=error_message,
|
|
742
|
+
error_type=einfo["error_type"],
|
|
743
|
+
error_message=einfo["error_message"],
|
|
744
|
+
status_code=einfo["status_code"],
|
|
745
|
+
stack_trace=einfo["stack_trace"],
|
|
604
746
|
model=self.handle.model,
|
|
605
747
|
provider=self.handle.provider,
|
|
606
748
|
input_tokens=self.handle.input_tokens,
|
|
@@ -675,9 +817,9 @@ class span:
|
|
|
675
817
|
return None
|
|
676
818
|
ended_iso = _now_iso()
|
|
677
819
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
820
|
+
einfo = _resolve_error(self.handle, exc_type, exc, tb)
|
|
821
|
+
status = einfo["status"]
|
|
822
|
+
level = self.handle.level or einfo["level"]
|
|
681
823
|
|
|
682
824
|
trodo_span = TrodoSpan(
|
|
683
825
|
span_id=self._span_id,
|
|
@@ -686,13 +828,16 @@ class span:
|
|
|
686
828
|
kind=self._kind,
|
|
687
829
|
name=self._name,
|
|
688
830
|
status=status,
|
|
831
|
+
level=level,
|
|
689
832
|
started_at=self._started_iso,
|
|
690
833
|
ended_at=ended_iso,
|
|
691
834
|
duration_ms=duration_ms,
|
|
692
835
|
input=self.handle.input,
|
|
693
836
|
output=self.handle.output,
|
|
694
|
-
error_type=error_type,
|
|
695
|
-
error_message=error_message,
|
|
837
|
+
error_type=einfo["error_type"],
|
|
838
|
+
error_message=einfo["error_message"],
|
|
839
|
+
status_code=einfo["status_code"],
|
|
840
|
+
stack_trace=einfo["stack_trace"],
|
|
696
841
|
model=self.handle.model,
|
|
697
842
|
provider=self.handle.provider,
|
|
698
843
|
input_tokens=self.handle.input_tokens,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
README.md
|
|
2
2
|
pyproject.toml
|
|
3
|
+
tests/test_anon_distinct_id 2.py
|
|
3
4
|
tests/test_anon_distinct_id.py
|
|
4
5
|
tests/test_auto_instrument_fixes.py
|
|
5
6
|
tests/test_cross_process_session.py
|
|
6
7
|
tests/test_end_run.py
|
|
8
|
+
tests/test_error_enrichment.py
|
|
7
9
|
tests/test_llm_usage_cost.py
|
|
8
10
|
tests/test_processor_methods.py
|
|
9
11
|
tests/test_register_otel.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|