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.
Files changed (47) hide show
  1. {trodo_python-2.8.0 → trodo_python-2.10.0}/PKG-INFO +1 -1
  2. {trodo_python-2.8.0 → trodo_python-2.10.0}/pyproject.toml +1 -1
  3. trodo_python-2.10.0/tests/test_anon_distinct_id.py +100 -0
  4. trodo_python-2.10.0/tests/test_error_enrichment.py +107 -0
  5. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/__init__.py +1 -1
  6. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/auto_instrument.py +35 -0
  7. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/helpers.py +1 -0
  8. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/processor.py +12 -0
  9. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/wrap_agent.py +158 -13
  10. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/PKG-INFO +1 -1
  11. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/SOURCES.txt +2 -0
  12. {trodo_python-2.8.0 → trodo_python-2.10.0}/README.md +0 -0
  13. {trodo_python-2.8.0 → trodo_python-2.10.0}/setup.cfg +0 -0
  14. /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
  15. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_auto_instrument_fixes.py +0 -0
  16. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_cross_process_session.py +0 -0
  17. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_end_run.py +0 -0
  18. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_llm_usage_cost.py +0 -0
  19. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_processor_methods.py +0 -0
  20. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_register_otel.py +0 -0
  21. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_start_run.py +0 -0
  22. {trodo_python-2.8.0 → trodo_python-2.10.0}/tests/test_wrap_agent_unchanged.py +0 -0
  23. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/__init__.py +0 -0
  24. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/async_client.py +0 -0
  25. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/endpoints.py +0 -0
  26. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/api/http_client.py +0 -0
  27. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/auto/__init__.py +0 -0
  28. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/auto/auto_event_manager.py +0 -0
  29. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/client.py +0 -0
  30. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/__init__.py +0 -0
  31. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/group_manager.py +0 -0
  32. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/managers/people_manager.py +0 -0
  33. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/__init__.py +0 -0
  34. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/context.py +0 -0
  35. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/register.py +0 -0
  36. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/otel/transport.py +0 -0
  37. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/__init__.py +0 -0
  38. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/batch_flusher.py +0 -0
  39. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/queue/event_queue.py +0 -0
  40. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/__init__.py +0 -0
  41. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/server_session.py +0 -0
  42. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/session/session_manager.py +0 -0
  43. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/types.py +0 -0
  44. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo/user_context.py +0 -0
  45. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  46. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/requires.txt +0 -0
  47. {trodo_python-2.8.0 → trodo_python-2.10.0}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.8.0
3
+ Version: 2.10.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.8.0"
7
+ version = "2.10.0"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -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
@@ -40,7 +40,7 @@ Downstream microservice (join the caller's run instead of making a new one):
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
- __version__ = "2.8.0"
43
+ __version__ = "2.10.0"
44
44
 
45
45
  from typing import Any, Callable, Dict, List, Optional, Union
46
46
 
@@ -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
- status = "error" if exc is not None else "ok"
422
- error_summary = None
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
- error_summary = _truncate(str(exc), 4_000)
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
- status = "error" if exc is not None else "ok"
587
- error_type = exc_type.__name__ if exc_type else None
588
- error_message = _truncate(str(exc), 4_000) if exc else None
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
- status = "error" if exc is not None else "ok"
679
- error_type = exc_type.__name__ if exc_type else None
680
- error_message = _truncate(str(exc), 4_000) if exc else None
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.8.0
3
+ Version: 2.10.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -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