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.
Files changed (47) hide show
  1. {trodo_python-2.8.0 → trodo_python-2.9.0}/PKG-INFO +1 -1
  2. {trodo_python-2.8.0 → trodo_python-2.9.0}/pyproject.toml +1 -1
  3. trodo_python-2.9.0/tests/test_anon_distinct_id.py +100 -0
  4. trodo_python-2.9.0/tests/test_error_enrichment.py +84 -0
  5. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/__init__.py +1 -1
  6. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/auto_instrument.py +35 -0
  7. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/helpers.py +1 -0
  8. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/processor.py +12 -0
  9. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/wrap_agent.py +76 -11
  10. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/PKG-INFO +1 -1
  11. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/SOURCES.txt +2 -0
  12. {trodo_python-2.8.0 → trodo_python-2.9.0}/README.md +0 -0
  13. {trodo_python-2.8.0 → trodo_python-2.9.0}/setup.cfg +0 -0
  14. /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
  15. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_auto_instrument_fixes.py +0 -0
  16. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_cross_process_session.py +0 -0
  17. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_end_run.py +0 -0
  18. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_llm_usage_cost.py +0 -0
  19. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_processor_methods.py +0 -0
  20. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_register_otel.py +0 -0
  21. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_start_run.py +0 -0
  22. {trodo_python-2.8.0 → trodo_python-2.9.0}/tests/test_wrap_agent_unchanged.py +0 -0
  23. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/__init__.py +0 -0
  24. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/async_client.py +0 -0
  25. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/endpoints.py +0 -0
  26. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/api/http_client.py +0 -0
  27. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/auto/__init__.py +0 -0
  28. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/auto/auto_event_manager.py +0 -0
  29. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/client.py +0 -0
  30. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/__init__.py +0 -0
  31. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/group_manager.py +0 -0
  32. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/managers/people_manager.py +0 -0
  33. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/__init__.py +0 -0
  34. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/context.py +0 -0
  35. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/register.py +0 -0
  36. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/otel/transport.py +0 -0
  37. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/__init__.py +0 -0
  38. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/batch_flusher.py +0 -0
  39. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/queue/event_queue.py +0 -0
  40. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/__init__.py +0 -0
  41. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/server_session.py +0 -0
  42. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/session/session_manager.py +0 -0
  43. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/types.py +0 -0
  44. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo/user_context.py +0 -0
  45. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  46. {trodo_python-2.8.0 → trodo_python-2.9.0}/trodo_python.egg-info/requires.txt +0 -0
  47. {trodo_python-2.8.0 → trodo_python-2.9.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.9.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.9.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,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
@@ -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.9.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,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
- error_summary = None
423
- if exc is not None:
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
- error_type = exc_type.__name__ if exc_type else None
588
- error_message = _truncate(str(exc), 4_000) if exc else None
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
- error_type = exc_type.__name__ if exc_type else None
680
- error_message = _truncate(str(exc), 4_000) if exc else None
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.8.0
3
+ Version: 2.9.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