trodo-python 2.8.0__tar.gz → 2.9.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.9.0}/PKG-INFO +1 -1
- {trodo_python-2.8.0 → trodo_python-2.9.0}/pyproject.toml +1 -1
- trodo_python-2.9.0/tests/test_anon_distinct_id.py +100 -0
- trodo_python-2.9.0/tests/test_error_enrichment.py +84 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/__init__.py +1 -1
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/auto_instrument.py +35 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/helpers.py +1 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/processor.py +12 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/wrap_agent.py +76 -11
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/PKG-INFO +1 -1
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/SOURCES.txt +2 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/README.md +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/setup.cfg +0 -0
- /trodo_python-2.8.0/tests/test_anon_distinct_id.py → /trodo_python-2.9.0/tests/test_anon_distinct_id 2.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_cross_process_session.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_end_run.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_processor_methods.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_register_otel.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_start_run.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/async_client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/endpoints.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/http_client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/auto/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/client.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/group_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/people_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/context.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/register.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/transport.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/event_queue.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/__init__.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/server_session.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/session_manager.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/types.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/user_context.py +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-2.8.0 → trodo_python-2.9.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,84 @@
|
|
|
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_healthy_span_omits_error_fields(processor, http):
|
|
74
|
+
with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
|
|
75
|
+
with span("ok-step", kind="generic"):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
spans = http.run_ingest[0].get("spans", [])
|
|
79
|
+
ok = next(s for s in spans if s["name"] == "ok-step")
|
|
80
|
+
assert ok["status"] == "ok"
|
|
81
|
+
# to_dict() drops None fields, so error keys must be absent entirely.
|
|
82
|
+
assert "error_type" not in ok
|
|
83
|
+
assert "status_code" not in ok
|
|
84
|
+
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,54 @@ 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
|
+
|
|
82
131
|
def _prepare_value(value: Any, max_len: int = _MAX_VALUE_LEN) -> Optional[Union[str, Dict[str, Any]]]:
|
|
83
132
|
"""Prepare a value for storage in the JSONB input/output column.
|
|
84
133
|
|
|
@@ -204,6 +253,15 @@ class SpanHandle:
|
|
|
204
253
|
self.cost_details: Optional[Dict[str, float]] = None
|
|
205
254
|
self.temperature: Optional[float] = None
|
|
206
255
|
self.tool_name: Optional[str] = None
|
|
256
|
+
# Severity for this span (Langfuse parity). Leave None for the default
|
|
257
|
+
# behaviour (backend derives 'error' on a thrown exception, else
|
|
258
|
+
# 'default'). Set 'warning' for a recovered/retried step, 'debug' for
|
|
259
|
+
# verbose spans.
|
|
260
|
+
self.level: Optional[str] = None
|
|
261
|
+
|
|
262
|
+
def set_level(self, level: str) -> None:
|
|
263
|
+
"""Mark this span's severity (does not change ok/error status)."""
|
|
264
|
+
self.level = level
|
|
207
265
|
|
|
208
266
|
def set_input(self, value: Any) -> None:
|
|
209
267
|
self.input = _prepare_value(value)
|
|
@@ -419,9 +477,8 @@ class wrap_agent:
|
|
|
419
477
|
ended_iso = _now_iso()
|
|
420
478
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
421
479
|
status = "error" if exc is not None else "ok"
|
|
422
|
-
|
|
423
|
-
if
|
|
424
|
-
error_summary = _truncate(str(exc), 4_000)
|
|
480
|
+
einfo = describe_error(exc_type, exc, tb) if exc is not None else None
|
|
481
|
+
error_summary = einfo["error_message"] if einfo else None
|
|
425
482
|
|
|
426
483
|
pending = self._processor.get_pending(self.handle.run_id)
|
|
427
484
|
agg = _aggregate(pending)
|
|
@@ -433,12 +490,14 @@ class wrap_agent:
|
|
|
433
490
|
conversation_id=self._conversation_id,
|
|
434
491
|
parent_run_id=self._parent_run_id,
|
|
435
492
|
status=status,
|
|
493
|
+
level="error" if exc is not None else None,
|
|
436
494
|
input=self.handle.input,
|
|
437
495
|
output=self.handle.output,
|
|
438
496
|
started_at=self._started_iso,
|
|
439
497
|
ended_at=ended_iso,
|
|
440
498
|
duration_ms=duration_ms,
|
|
441
499
|
error_summary=error_summary,
|
|
500
|
+
error_type=einfo["error_type"] if einfo else None,
|
|
442
501
|
metadata={**(self._metadata or {}), **self.handle.metadata} or None,
|
|
443
502
|
total_tokens_in=agg["total_tokens_in"],
|
|
444
503
|
total_tokens_out=agg["total_tokens_out"],
|
|
@@ -584,8 +643,8 @@ class join_run:
|
|
|
584
643
|
ended_iso = _now_iso()
|
|
585
644
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
586
645
|
status = "error" if exc is not None else "ok"
|
|
587
|
-
|
|
588
|
-
|
|
646
|
+
einfo = describe_error(exc_type, exc, tb) if exc is not None else None
|
|
647
|
+
level = self.handle.level or ("error" if exc is not None else None)
|
|
589
648
|
|
|
590
649
|
trodo_span = TrodoSpan(
|
|
591
650
|
span_id=self._span_id,
|
|
@@ -594,13 +653,16 @@ class join_run:
|
|
|
594
653
|
kind=self._kind,
|
|
595
654
|
name=self._name,
|
|
596
655
|
status=status,
|
|
656
|
+
level=level,
|
|
597
657
|
started_at=self._started_iso,
|
|
598
658
|
ended_at=ended_iso,
|
|
599
659
|
duration_ms=duration_ms,
|
|
600
660
|
input=self.handle.input,
|
|
601
661
|
output=self.handle.output,
|
|
602
|
-
error_type=error_type,
|
|
603
|
-
error_message=error_message,
|
|
662
|
+
error_type=einfo["error_type"] if einfo else None,
|
|
663
|
+
error_message=einfo["error_message"] if einfo else None,
|
|
664
|
+
status_code=einfo["status_code"] if einfo else None,
|
|
665
|
+
stack_trace=einfo["stack_trace"] if einfo else None,
|
|
604
666
|
model=self.handle.model,
|
|
605
667
|
provider=self.handle.provider,
|
|
606
668
|
input_tokens=self.handle.input_tokens,
|
|
@@ -676,8 +738,8 @@ class span:
|
|
|
676
738
|
ended_iso = _now_iso()
|
|
677
739
|
duration_ms = int(time.time() * 1000.0 - self._started_ms)
|
|
678
740
|
status = "error" if exc is not None else "ok"
|
|
679
|
-
|
|
680
|
-
|
|
741
|
+
einfo = describe_error(exc_type, exc, tb) if exc is not None else None
|
|
742
|
+
level = self.handle.level or ("error" if exc is not None else None)
|
|
681
743
|
|
|
682
744
|
trodo_span = TrodoSpan(
|
|
683
745
|
span_id=self._span_id,
|
|
@@ -686,13 +748,16 @@ class span:
|
|
|
686
748
|
kind=self._kind,
|
|
687
749
|
name=self._name,
|
|
688
750
|
status=status,
|
|
751
|
+
level=level,
|
|
689
752
|
started_at=self._started_iso,
|
|
690
753
|
ended_at=ended_iso,
|
|
691
754
|
duration_ms=duration_ms,
|
|
692
755
|
input=self.handle.input,
|
|
693
756
|
output=self.handle.output,
|
|
694
|
-
error_type=error_type,
|
|
695
|
-
error_message=error_message,
|
|
757
|
+
error_type=einfo["error_type"] if einfo else None,
|
|
758
|
+
error_message=einfo["error_message"] if einfo else None,
|
|
759
|
+
status_code=einfo["status_code"] if einfo else None,
|
|
760
|
+
stack_trace=einfo["stack_trace"] if einfo else None,
|
|
696
761
|
model=self.handle.model,
|
|
697
762
|
provider=self.handle.provider,
|
|
698
763
|
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
|