trodo-python 2.7.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.7.0 → trodo_python-2.9.0}/PKG-INFO +64 -1
  2. {trodo_python-2.7.0 → trodo_python-2.9.0}/README.md +63 -0
  3. {trodo_python-2.7.0 → trodo_python-2.9.0}/pyproject.toml +1 -1
  4. trodo_python-2.9.0/tests/test_anon_distinct_id.py +100 -0
  5. trodo_python-2.9.0/tests/test_error_enrichment.py +84 -0
  6. trodo_python-2.9.0/tests/test_llm_usage_cost.py +115 -0
  7. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/__init__.py +1 -1
  8. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/auto_instrument.py +35 -0
  9. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/helpers.py +103 -23
  10. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/processor.py +20 -0
  11. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/wrap_agent.py +111 -11
  12. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo_python.egg-info/PKG-INFO +64 -1
  13. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo_python.egg-info/SOURCES.txt +3 -0
  14. {trodo_python-2.7.0 → trodo_python-2.9.0}/setup.cfg +0 -0
  15. /trodo_python-2.7.0/tests/test_anon_distinct_id.py → /trodo_python-2.9.0/tests/test_anon_distinct_id 2.py +0 -0
  16. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_auto_instrument_fixes.py +0 -0
  17. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_cross_process_session.py +0 -0
  18. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_end_run.py +0 -0
  19. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_processor_methods.py +0 -0
  20. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_register_otel.py +0 -0
  21. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_start_run.py +0 -0
  22. {trodo_python-2.7.0 → trodo_python-2.9.0}/tests/test_wrap_agent_unchanged.py +0 -0
  23. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/api/__init__.py +0 -0
  24. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/api/async_client.py +0 -0
  25. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/api/endpoints.py +0 -0
  26. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/api/http_client.py +0 -0
  27. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/auto/__init__.py +0 -0
  28. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/auto/auto_event_manager.py +0 -0
  29. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/client.py +0 -0
  30. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/managers/__init__.py +0 -0
  31. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/managers/group_manager.py +0 -0
  32. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/managers/people_manager.py +0 -0
  33. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/__init__.py +0 -0
  34. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/context.py +0 -0
  35. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/register.py +0 -0
  36. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/otel/transport.py +0 -0
  37. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/queue/__init__.py +0 -0
  38. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/queue/batch_flusher.py +0 -0
  39. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/queue/event_queue.py +0 -0
  40. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/session/__init__.py +0 -0
  41. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/session/server_session.py +0 -0
  42. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/session/session_manager.py +0 -0
  43. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/types.py +0 -0
  44. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo/user_context.py +0 -0
  45. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  46. {trodo_python-2.7.0 → trodo_python-2.9.0}/trodo_python.egg-info/requires.txt +0 -0
  47. {trodo_python-2.7.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.7.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
@@ -274,6 +274,69 @@ with tracer.start_as_current_span('custom') as sp:
274
274
  sp.set_attribute('gen_ai.system', 'my-llm')
275
275
  ```
276
276
 
277
+ ### Cost & token reporting (v2.8.0+)
278
+
279
+ Trodo computes per-span cost from whatever you report. **You don't have to send
280
+ cost** — send tokens and Trodo prices them using the team's **Model Price** config
281
+ (Configuration → Model Price), falling back to built-in defaults. Resolution per
282
+ span, highest priority first:
283
+
284
+ 1. **Explicit `cost`** (a final USD number) — used as-is, never recomputed.
285
+ 2. **`cost_details`** (per-category USD breakdown) — authoritative.
286
+ 3. **Tokens** (`usage_details` map, or `input_tokens`/`output_tokens`) — priced by
287
+ the team's configured model price → global default → left unset if unknown.
288
+
289
+ All token categories live in an open **`usage_details`** map. `input`/`output` are
290
+ the defaults; add `cache_read`, `cache_write`, `reasoning`, `audio`, `image`, or any
291
+ custom key. Raw provider field names are fine — the backend normalises them
292
+ (`prompt_tokens`→`input`, `cache_read_input_tokens`→`cache_read`, …). Custom keys
293
+ must match the category name you price in the UI.
294
+
295
+ ```python
296
+ # (a) Tokens only — Trodo prices it from the model name. The llm() helper
297
+ # auto-forwards the FULL provider usage object, so cache/reasoning tokens
298
+ # are captured with zero config.
299
+ answer = trodo.llm('answer', call_anthropic,
300
+ model='claude-sonnet-4', provider='anthropic')
301
+
302
+ # (b) Raw usage object via track_llm_call — same auto-normalisation.
303
+ trodo.track_llm_call(
304
+ model='gpt-4o', provider='openai',
305
+ usage=resp['usage'], # {prompt_tokens, completion_tokens, prompt_tokens_details:{cached_tokens}}
306
+ prompt=body, completion=resp,
307
+ )
308
+
309
+ # (c) Explicit usage map + cache shorthands.
310
+ trodo.track_llm_call(
311
+ model='claude-sonnet-4', provider='anthropic',
312
+ usage_details={'input': 1000, 'output': 500},
313
+ cache_read_tokens=200, cache_write_tokens=80, # → cache_read / cache_write
314
+ )
315
+
316
+ # (d) Pass cost straight through (skip server-side pricing).
317
+ trodo.track_llm_call(model='gpt-4o', provider='openai', cost=0.0123)
318
+
319
+ # (e) Per-category cost breakdown (authoritative).
320
+ trodo.track_llm_call(
321
+ model='gpt-4o', provider='openai',
322
+ cost_details={'input': 0.0003, 'output': 0.0005, 'cache_read': 0.00001},
323
+ )
324
+ ```
325
+
326
+ Inside a `wrap_agent` / `span` block, set the same fields on the handle:
327
+
328
+ ```python
329
+ s.set_llm(
330
+ model='gpt-4o', provider='openai',
331
+ usage_details={'input': 1000, 'output': 500},
332
+ cache_read_tokens=200,
333
+ # or: cost=0.0123 / cost_details={'input': ..., 'output': ...}
334
+ )
335
+ ```
336
+
337
+ Override auto-extraction with `extract_usage` (scalar in/out) or `extract_usage_map`
338
+ (open map) on `trodo.llm(name, fn, ...)`.
339
+
277
340
  ### Cross-service runs
278
341
 
279
342
  When one service calls another, the downstream service **joins** the
@@ -243,6 +243,69 @@ with tracer.start_as_current_span('custom') as sp:
243
243
  sp.set_attribute('gen_ai.system', 'my-llm')
244
244
  ```
245
245
 
246
+ ### Cost & token reporting (v2.8.0+)
247
+
248
+ Trodo computes per-span cost from whatever you report. **You don't have to send
249
+ cost** — send tokens and Trodo prices them using the team's **Model Price** config
250
+ (Configuration → Model Price), falling back to built-in defaults. Resolution per
251
+ span, highest priority first:
252
+
253
+ 1. **Explicit `cost`** (a final USD number) — used as-is, never recomputed.
254
+ 2. **`cost_details`** (per-category USD breakdown) — authoritative.
255
+ 3. **Tokens** (`usage_details` map, or `input_tokens`/`output_tokens`) — priced by
256
+ the team's configured model price → global default → left unset if unknown.
257
+
258
+ All token categories live in an open **`usage_details`** map. `input`/`output` are
259
+ the defaults; add `cache_read`, `cache_write`, `reasoning`, `audio`, `image`, or any
260
+ custom key. Raw provider field names are fine — the backend normalises them
261
+ (`prompt_tokens`→`input`, `cache_read_input_tokens`→`cache_read`, …). Custom keys
262
+ must match the category name you price in the UI.
263
+
264
+ ```python
265
+ # (a) Tokens only — Trodo prices it from the model name. The llm() helper
266
+ # auto-forwards the FULL provider usage object, so cache/reasoning tokens
267
+ # are captured with zero config.
268
+ answer = trodo.llm('answer', call_anthropic,
269
+ model='claude-sonnet-4', provider='anthropic')
270
+
271
+ # (b) Raw usage object via track_llm_call — same auto-normalisation.
272
+ trodo.track_llm_call(
273
+ model='gpt-4o', provider='openai',
274
+ usage=resp['usage'], # {prompt_tokens, completion_tokens, prompt_tokens_details:{cached_tokens}}
275
+ prompt=body, completion=resp,
276
+ )
277
+
278
+ # (c) Explicit usage map + cache shorthands.
279
+ trodo.track_llm_call(
280
+ model='claude-sonnet-4', provider='anthropic',
281
+ usage_details={'input': 1000, 'output': 500},
282
+ cache_read_tokens=200, cache_write_tokens=80, # → cache_read / cache_write
283
+ )
284
+
285
+ # (d) Pass cost straight through (skip server-side pricing).
286
+ trodo.track_llm_call(model='gpt-4o', provider='openai', cost=0.0123)
287
+
288
+ # (e) Per-category cost breakdown (authoritative).
289
+ trodo.track_llm_call(
290
+ model='gpt-4o', provider='openai',
291
+ cost_details={'input': 0.0003, 'output': 0.0005, 'cache_read': 0.00001},
292
+ )
293
+ ```
294
+
295
+ Inside a `wrap_agent` / `span` block, set the same fields on the handle:
296
+
297
+ ```python
298
+ s.set_llm(
299
+ model='gpt-4o', provider='openai',
300
+ usage_details={'input': 1000, 'output': 500},
301
+ cache_read_tokens=200,
302
+ # or: cost=0.0123 / cost_details={'input': ..., 'output': ...}
303
+ )
304
+ ```
305
+
306
+ Override auto-extraction with `extract_usage` (scalar in/out) or `extract_usage_map`
307
+ (open map) on `trodo.llm(name, fn, ...)`.
308
+
246
309
  ### Cross-service runs
247
310
 
248
311
  When one service calls another, the downstream service **joins** the
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.7.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
@@ -0,0 +1,115 @@
1
+ """LLM usage + cost wire payloads (v2.8.0).
2
+
3
+ Verifies every way a caller can report cost/tokens reaches the backend in the
4
+ expected snake_case shape:
5
+ - explicit ``cost`` (highest priority)
6
+ - open ``usage_details`` map + ``cache_read_tokens``/``cache_write_tokens``
7
+ - raw provider ``usage`` object auto-extracted into usage_details
8
+ - ``cost_details`` per-category breakdown
9
+ - ``llm()`` helper auto-forwarding the full provider usage map (incl. cache)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Dict, List
14
+
15
+ from trodo.otel.helpers import _default_usage_map, llm, track_llm_call
16
+ from trodo.otel.wrap_agent import wrap_agent
17
+
18
+
19
+ def _llm_spans(http) -> List[Dict[str, Any]]:
20
+ spans = http.run_ingest[0].get("spans", []) if http.run_ingest else []
21
+ return [s for s in spans if s.get("kind") == "llm"]
22
+
23
+
24
+ def test_default_usage_map_flattens_openai_details():
25
+ out = _default_usage_map(
26
+ {"usage": {"prompt_tokens": 1000, "completion_tokens": 200,
27
+ "prompt_tokens_details": {"cached_tokens": 300}}}
28
+ )
29
+ assert out == {"prompt_tokens": 1000, "completion_tokens": 200, "cached_tokens": 300}
30
+
31
+
32
+ def test_default_usage_map_anthropic_cache_fields():
33
+ out = _default_usage_map(
34
+ {"usage": {"input_tokens": 50, "output_tokens": 25,
35
+ "cache_read_input_tokens": 10, "cache_creation_input_tokens": 5}}
36
+ )
37
+ assert out == {
38
+ "input_tokens": 50, "output_tokens": 25,
39
+ "cache_read_input_tokens": 10, "cache_creation_input_tokens": 5,
40
+ }
41
+
42
+
43
+ def test_default_usage_map_bare_usage_object():
44
+ out = _default_usage_map({"prompt_tokens": 12, "completion_tokens": 4})
45
+ assert out == {"prompt_tokens": 12, "completion_tokens": 4}
46
+
47
+
48
+ def test_track_llm_call_explicit_cost_wins(processor, http):
49
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
50
+ track_llm_call(model="gpt-4o", provider="openai",
51
+ input_tokens=100, output_tokens=50, cost=0.42)
52
+ span = _llm_spans(http)[0]
53
+ assert span["cost"] == 0.42
54
+ assert span["input_tokens"] == 100
55
+
56
+
57
+ def test_track_llm_call_usage_details_and_cache_shorthands(processor, http):
58
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
59
+ track_llm_call(model="claude-sonnet-4", provider="anthropic",
60
+ usage_details={"input": 1000, "output": 500},
61
+ cache_read_tokens=200, cache_write_tokens=80)
62
+ span = _llm_spans(http)[0]
63
+ assert span["usage_details"] == {
64
+ "input": 1000, "output": 500, "cache_read": 200, "cache_write": 80,
65
+ }
66
+
67
+
68
+ def test_track_llm_call_raw_usage_auto_extract(processor, http):
69
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
70
+ track_llm_call(
71
+ model="gpt-4o", provider="openai",
72
+ usage={"prompt_tokens": 800, "completion_tokens": 400,
73
+ "prompt_tokens_details": {"cached_tokens": 100}},
74
+ )
75
+ span = _llm_spans(http)[0]
76
+ assert span["usage_details"] == {
77
+ "prompt_tokens": 800, "completion_tokens": 400, "cached_tokens": 100,
78
+ }
79
+
80
+
81
+ def test_track_llm_call_cost_details(processor, http):
82
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
83
+ track_llm_call(model="gpt-4o", provider="openai", input_tokens=100,
84
+ output_tokens=50, cost_details={"input": 0.0003, "output": 0.0005})
85
+ span = _llm_spans(http)[0]
86
+ assert span["cost_details"] == {"input": 0.0003, "output": 0.0005}
87
+
88
+
89
+ def test_llm_helper_auto_forwards_usage_map(processor, http):
90
+ def call_model(*_a, **_k):
91
+ return {"text": "hi", "usage": {
92
+ "input_tokens": 1000, "output_tokens": 200, "cache_read_input_tokens": 300}}
93
+
94
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
95
+ wrapped = llm("answer", call_model, model="claude-sonnet-4", provider="anthropic")
96
+ wrapped()
97
+ span = _llm_spans(http)[0]
98
+ assert span["usage_details"] == {
99
+ "input_tokens": 1000, "output_tokens": 200, "cache_read_input_tokens": 300,
100
+ }
101
+ assert span["model"] == "claude-sonnet-4"
102
+
103
+
104
+ def test_llm_helper_custom_scalar_extractor_backcompat(processor, http):
105
+ def call_model(*_a, **_k):
106
+ return {"weird": {"in": 7, "out": 3}}
107
+
108
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat"):
109
+ wrapped = llm("answer", call_model, model="x", provider="y",
110
+ extract_usage=lambda r: (r["weird"]["in"], r["weird"]["out"]))
111
+ wrapped()
112
+ span = _llm_spans(http)[0]
113
+ assert span["input_tokens"] == 7
114
+ assert span["output_tokens"] == 3
115
+ assert "usage_details" not in span
@@ -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.7.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,
@@ -232,6 +232,52 @@ def _default_usage_extractor(result: Any) -> Tuple[Optional[int], Optional[int]]
232
232
  return (None, None)
233
233
 
234
234
 
235
+ def _coerce_num(v: Any) -> Optional[float]:
236
+ try:
237
+ n = float(v)
238
+ except (TypeError, ValueError):
239
+ return None
240
+ return n
241
+
242
+
243
+ def _default_usage_map(result: Any) -> Optional[Dict[str, float]]:
244
+ """Forward the FULL provider usage object (incl. cache/reasoning) as an open
245
+ map. The backend normalises raw keys (``prompt_tokens`` -> input,
246
+ ``cache_read_input_tokens`` / ``cached_tokens`` -> cache_read,
247
+ ``cache_creation_input_tokens`` -> cache_write, ``reasoning_tokens`` ->
248
+ reasoning, ...), so passing whatever the provider returned is enough.
249
+
250
+ Flattens OpenAI ``*_tokens_details`` so cached/reasoning leaves survive.
251
+ Accepts either the bare usage object or a full response carrying ``usage`` /
252
+ ``usageMetadata``.
253
+ """
254
+ if result is None:
255
+ return None
256
+ raw: Any = None
257
+ if isinstance(result, dict):
258
+ raw = result.get("usage") or result.get("usageMetadata")
259
+ # Bare usage object passed directly (has numeric token leaves).
260
+ if raw is None and any(_coerce_num(v) is not None or isinstance(v, dict) for v in result.values()):
261
+ raw = result
262
+ else:
263
+ raw = getattr(result, "usage", None) or getattr(result, "usageMetadata", None)
264
+ if not isinstance(raw, dict):
265
+ return None
266
+ out: Dict[str, float] = {}
267
+ for k, v in raw.items():
268
+ if isinstance(v, dict):
269
+ # OpenAI prompt_tokens_details / completion_tokens_details — flatten.
270
+ for dk, dv in v.items():
271
+ n = _coerce_num(dv)
272
+ if n is not None:
273
+ out[dk] = n
274
+ continue
275
+ n = _coerce_num(v)
276
+ if n is not None:
277
+ out[k] = n
278
+ return out or None
279
+
280
+
235
281
  def llm(
236
282
  name: Any = None,
237
283
  fn: Optional[Callable[..., Any]] = None,
@@ -240,13 +286,16 @@ def llm(
240
286
  provider: Optional[str] = None,
241
287
  temperature: Optional[float] = None,
242
288
  extract_usage: Optional[Callable[[Any], Tuple[Optional[int], Optional[int]]]] = None,
289
+ extract_usage_map: Optional[Callable[[Any], Optional[Dict[str, float]]]] = None,
243
290
  ) -> Any:
244
291
  """Wrap an LLM call as a ``kind='llm'`` span with auto token extraction.
245
292
 
246
- The helper records ``model``/``provider`` on entry; on return it inspects
247
- the response for the common usage shapes (OpenAI ``usage.prompt_tokens``,
248
- Anthropic ``usage.input_tokens``, Gemini ``usageMetadata.promptTokenCount``)
249
- and records tokens. Pass ``extract_usage=lambda r: (in, out)`` to override.
293
+ By default the helper forwards the FULL provider usage object (OpenAI
294
+ ``usage``, Anthropic ``usage`` incl. cache fields, Gemini ``usageMetadata``)
295
+ as an open map, so cache/reasoning tokens are captured and priced
296
+ automatically by the backend. Pass ``extract_usage=lambda r: (in, out)`` to
297
+ fall back to scalar-only extraction, or ``extract_usage_map=lambda r: {..}``
298
+ to build the map yourself.
250
299
 
251
300
  Usage::
252
301
 
@@ -257,7 +306,6 @@ def llm(
257
306
  @trodo.llm('plan', model='claude-haiku-4-5', provider='anthropic')
258
307
  def plan(messages): ...
259
308
  """
260
- extractor = extract_usage or _default_usage_extractor
261
309
 
262
310
  def _set_llm(s: SpanHandle) -> None:
263
311
  if model or provider or temperature is not None:
@@ -268,18 +316,25 @@ def llm(
268
316
  )
269
317
 
270
318
  def _on_result(s: SpanHandle, result: Any) -> None:
319
+ if extract_usage is not None:
320
+ # Caller opted into scalar-only extraction (back-compat).
321
+ try:
322
+ pt, ct = extract_usage(result)
323
+ except Exception:
324
+ pt, ct = (None, None)
325
+ if pt is not None or ct is not None:
326
+ s.set_llm(
327
+ model=model, provider=provider,
328
+ input_tokens=pt, output_tokens=ct, temperature=temperature,
329
+ )
330
+ return
331
+ # Default: forward the full provider usage map (incl. cache/reasoning).
271
332
  try:
272
- pt, ct = extractor(result)
333
+ usage_map = (extract_usage_map or _default_usage_map)(result)
273
334
  except Exception:
274
- pt, ct = (None, None)
275
- if pt is not None or ct is not None:
276
- s.set_llm(
277
- model=model,
278
- provider=provider,
279
- input_tokens=pt,
280
- output_tokens=ct,
281
- temperature=temperature,
282
- )
335
+ usage_map = None
336
+ if usage_map:
337
+ s.set_llm(model=model, provider=provider, temperature=temperature, usage_details=usage_map)
283
338
 
284
339
  return _dual_form("llm")(
285
340
  name, fn, kind="llm", extra_set=_set_llm, on_result=_on_result
@@ -358,6 +413,7 @@ def track_mcp(
358
413
  "kind": "tool",
359
414
  "name": f"tool.{tool}",
360
415
  "status": status,
416
+ "level": "error" if error else "default",
361
417
  "input": _stringify({"tool": tool, "params": input}) if input is not None else None,
362
418
  "output": _stringify(output_to_record),
363
419
  "tool_name": tool,
@@ -387,6 +443,11 @@ def track_llm_call(
387
443
  provider: Optional[str] = None,
388
444
  input_tokens: Optional[int] = None,
389
445
  output_tokens: Optional[int] = None,
446
+ cache_read_tokens: Optional[int] = None,
447
+ cache_write_tokens: Optional[int] = None,
448
+ usage_details: Optional[Dict[str, float]] = None,
449
+ usage: Any = None,
450
+ cost_details: Optional[Dict[str, float]] = None,
390
451
  prompt: Any = None,
391
452
  completion: Any = None,
392
453
  temperature: Optional[float] = None,
@@ -397,23 +458,38 @@ def track_llm_call(
397
458
  """Record a one-shot LLM span for a raw-HTTP caller.
398
459
 
399
460
  Opens and immediately closes a ``span(kind='llm')`` populated with the
400
- model + token counts + prompt/completion. No-op outside an active run
401
- context.
461
+ model + tokens + prompt/completion. No-op outside an active run context.
462
+
463
+ Cost can be reported three ways (in priority order):
464
+ 1. ``cost`` — a final USD figure (overrides all server-side derivation).
465
+ 2. ``cost_details`` — a per-category USD breakdown (authoritative).
466
+ 3. tokens only — the backend prices them against the team's model prices.
467
+
468
+ Tokens can be passed as scalars (``input_tokens``/``output_tokens``),
469
+ cache shorthands (``cache_read_tokens``/``cache_write_tokens``), an open
470
+ ``usage_details`` map, or a raw provider ``usage`` object to auto-extract
471
+ from (e.g. ``resp['usage']`` or ``resp['usageMetadata']``).
402
472
 
403
473
  Usage:
404
474
  resp = httpx.post(url, json=body).json()
405
475
  trodo.track_llm_call(
406
- model='gemini-2.5-flash',
407
- provider='google',
408
- input_tokens=resp['usageMetadata']['promptTokenCount'],
409
- output_tokens=resp['usageMetadata']['candidatesTokenCount'],
410
- prompt=body,
411
- completion=resp,
476
+ model='claude-sonnet-4', provider='anthropic',
477
+ usage=resp['usage'], # cache fields captured automatically
478
+ prompt=body, completion=resp,
412
479
  )
413
480
  """
414
481
  if get_active_context() is None:
415
482
  return
416
483
  span_name = name or (f"llm.{provider}.{model}" if model else "llm")
484
+ # Merge an explicit usage_details map with anything auto-extracted from a
485
+ # raw `usage` object the caller passed through.
486
+ merged_usage: Dict[str, float] = {}
487
+ if usage is not None:
488
+ from_usage = _default_usage_map(usage)
489
+ if from_usage:
490
+ merged_usage.update(from_usage)
491
+ if usage_details:
492
+ merged_usage.update(usage_details)
417
493
  with span_ctx(span_name, kind="llm", input=prompt, attributes=metadata) as s:
418
494
  s.set_llm(
419
495
  model=model,
@@ -421,6 +497,10 @@ def track_llm_call(
421
497
  input_tokens=input_tokens,
422
498
  output_tokens=output_tokens,
423
499
  cost=cost,
500
+ usage_details=merged_usage or None,
501
+ cost_details=cost_details,
502
+ cache_read_tokens=cache_read_tokens,
503
+ cache_write_tokens=cache_write_tokens,
424
504
  temperature=temperature,
425
505
  )
426
506
  if completion is not None:
@@ -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,11 +62,23 @@ 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
60
72
  output_tokens: Optional[int] = None
61
73
  cost: Optional[float] = None
74
+ # Open token-usage map forwarded to the backend, which normalises raw
75
+ # provider field names to canonical categories (input, output, cache_read,
76
+ # cache_write, reasoning, + custom keys) and prices each against the team's
77
+ # configured model prices.
78
+ usage_details: Optional[Dict[str, float]] = None
79
+ # Per-category cost breakdown in USD (authoritative when set — ingested cost
80
+ # always wins over server-side derivation).
81
+ cost_details: Optional[Dict[str, float]] = None
62
82
  temperature: Optional[float] = None
63
83
  tool_name: Optional[str] = None
64
84
  attributes: Optional[Dict[str, Any]] = 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
 
@@ -197,8 +246,22 @@ class SpanHandle:
197
246
  self.input_tokens: Optional[int] = None
198
247
  self.output_tokens: Optional[int] = None
199
248
  self.cost: Optional[float] = None
249
+ # Open token-usage map (canonical or raw provider keys — the backend
250
+ # normalises). Lets callers report cache/reasoning/custom categories.
251
+ self.usage_details: Optional[Dict[str, float]] = None
252
+ # Optional per-category cost breakdown in USD (authoritative when set).
253
+ self.cost_details: Optional[Dict[str, float]] = None
200
254
  self.temperature: Optional[float] = None
201
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
202
265
 
203
266
  def set_input(self, value: Any) -> None:
204
267
  self.input = _prepare_value(value)
@@ -217,6 +280,10 @@ class SpanHandle:
217
280
  input_tokens: Optional[int] = None,
218
281
  output_tokens: Optional[int] = None,
219
282
  cost: Optional[float] = None,
283
+ usage_details: Optional[Dict[str, float]] = None,
284
+ cost_details: Optional[Dict[str, float]] = None,
285
+ cache_read_tokens: Optional[int] = None,
286
+ cache_write_tokens: Optional[int] = None,
220
287
  temperature: Optional[float] = None,
221
288
  ) -> None:
222
289
  if model is not None:
@@ -231,6 +298,28 @@ class SpanHandle:
231
298
  self.cost = float(cost)
232
299
  if temperature is not None:
233
300
  self.temperature = float(temperature)
301
+ # Merge any usage map + cache shorthands into one forwarded map.
302
+ if usage_details or cache_read_tokens is not None or cache_write_tokens is not None:
303
+ merged: Dict[str, float] = dict(self.usage_details or {})
304
+ if usage_details:
305
+ for k, v in usage_details.items():
306
+ try:
307
+ merged[k] = float(v)
308
+ except (TypeError, ValueError):
309
+ continue
310
+ if cache_read_tokens is not None:
311
+ merged["cache_read"] = float(cache_read_tokens)
312
+ if cache_write_tokens is not None:
313
+ merged["cache_write"] = float(cache_write_tokens)
314
+ self.usage_details = merged
315
+ if cost_details:
316
+ merged_c: Dict[str, float] = dict(self.cost_details or {})
317
+ for k, v in cost_details.items():
318
+ try:
319
+ merged_c[k] = float(v)
320
+ except (TypeError, ValueError):
321
+ continue
322
+ self.cost_details = merged_c
234
323
 
235
324
  def set_tool(self, tool_name: str) -> None:
236
325
  self.tool_name = tool_name
@@ -388,9 +477,8 @@ class wrap_agent:
388
477
  ended_iso = _now_iso()
389
478
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
390
479
  status = "error" if exc is not None else "ok"
391
- error_summary = None
392
- if exc is not None:
393
- 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
394
482
 
395
483
  pending = self._processor.get_pending(self.handle.run_id)
396
484
  agg = _aggregate(pending)
@@ -402,12 +490,14 @@ class wrap_agent:
402
490
  conversation_id=self._conversation_id,
403
491
  parent_run_id=self._parent_run_id,
404
492
  status=status,
493
+ level="error" if exc is not None else None,
405
494
  input=self.handle.input,
406
495
  output=self.handle.output,
407
496
  started_at=self._started_iso,
408
497
  ended_at=ended_iso,
409
498
  duration_ms=duration_ms,
410
499
  error_summary=error_summary,
500
+ error_type=einfo["error_type"] if einfo else None,
411
501
  metadata={**(self._metadata or {}), **self.handle.metadata} or None,
412
502
  total_tokens_in=agg["total_tokens_in"],
413
503
  total_tokens_out=agg["total_tokens_out"],
@@ -553,8 +643,8 @@ class join_run:
553
643
  ended_iso = _now_iso()
554
644
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
555
645
  status = "error" if exc is not None else "ok"
556
- error_type = exc_type.__name__ if exc_type else None
557
- 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)
558
648
 
559
649
  trodo_span = TrodoSpan(
560
650
  span_id=self._span_id,
@@ -563,18 +653,23 @@ class join_run:
563
653
  kind=self._kind,
564
654
  name=self._name,
565
655
  status=status,
656
+ level=level,
566
657
  started_at=self._started_iso,
567
658
  ended_at=ended_iso,
568
659
  duration_ms=duration_ms,
569
660
  input=self.handle.input,
570
661
  output=self.handle.output,
571
- error_type=error_type,
572
- 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,
573
666
  model=self.handle.model,
574
667
  provider=self.handle.provider,
575
668
  input_tokens=self.handle.input_tokens,
576
669
  output_tokens=self.handle.output_tokens,
577
670
  cost=self.handle.cost,
671
+ usage_details=self.handle.usage_details,
672
+ cost_details=self.handle.cost_details,
578
673
  temperature=self.handle.temperature,
579
674
  tool_name=self.handle.tool_name,
580
675
  attributes=self.handle.attributes or None,
@@ -643,8 +738,8 @@ class span:
643
738
  ended_iso = _now_iso()
644
739
  duration_ms = int(time.time() * 1000.0 - self._started_ms)
645
740
  status = "error" if exc is not None else "ok"
646
- error_type = exc_type.__name__ if exc_type else None
647
- 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)
648
743
 
649
744
  trodo_span = TrodoSpan(
650
745
  span_id=self._span_id,
@@ -653,18 +748,23 @@ class span:
653
748
  kind=self._kind,
654
749
  name=self._name,
655
750
  status=status,
751
+ level=level,
656
752
  started_at=self._started_iso,
657
753
  ended_at=ended_iso,
658
754
  duration_ms=duration_ms,
659
755
  input=self.handle.input,
660
756
  output=self.handle.output,
661
- error_type=error_type,
662
- 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,
663
761
  model=self.handle.model,
664
762
  provider=self.handle.provider,
665
763
  input_tokens=self.handle.input_tokens,
666
764
  output_tokens=self.handle.output_tokens,
667
765
  cost=self.handle.cost,
766
+ usage_details=self.handle.usage_details,
767
+ cost_details=self.handle.cost_details,
668
768
  temperature=self.handle.temperature,
669
769
  tool_name=self.handle.tool_name,
670
770
  attributes=self.handle.attributes or None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.7.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
@@ -274,6 +274,69 @@ with tracer.start_as_current_span('custom') as sp:
274
274
  sp.set_attribute('gen_ai.system', 'my-llm')
275
275
  ```
276
276
 
277
+ ### Cost & token reporting (v2.8.0+)
278
+
279
+ Trodo computes per-span cost from whatever you report. **You don't have to send
280
+ cost** — send tokens and Trodo prices them using the team's **Model Price** config
281
+ (Configuration → Model Price), falling back to built-in defaults. Resolution per
282
+ span, highest priority first:
283
+
284
+ 1. **Explicit `cost`** (a final USD number) — used as-is, never recomputed.
285
+ 2. **`cost_details`** (per-category USD breakdown) — authoritative.
286
+ 3. **Tokens** (`usage_details` map, or `input_tokens`/`output_tokens`) — priced by
287
+ the team's configured model price → global default → left unset if unknown.
288
+
289
+ All token categories live in an open **`usage_details`** map. `input`/`output` are
290
+ the defaults; add `cache_read`, `cache_write`, `reasoning`, `audio`, `image`, or any
291
+ custom key. Raw provider field names are fine — the backend normalises them
292
+ (`prompt_tokens`→`input`, `cache_read_input_tokens`→`cache_read`, …). Custom keys
293
+ must match the category name you price in the UI.
294
+
295
+ ```python
296
+ # (a) Tokens only — Trodo prices it from the model name. The llm() helper
297
+ # auto-forwards the FULL provider usage object, so cache/reasoning tokens
298
+ # are captured with zero config.
299
+ answer = trodo.llm('answer', call_anthropic,
300
+ model='claude-sonnet-4', provider='anthropic')
301
+
302
+ # (b) Raw usage object via track_llm_call — same auto-normalisation.
303
+ trodo.track_llm_call(
304
+ model='gpt-4o', provider='openai',
305
+ usage=resp['usage'], # {prompt_tokens, completion_tokens, prompt_tokens_details:{cached_tokens}}
306
+ prompt=body, completion=resp,
307
+ )
308
+
309
+ # (c) Explicit usage map + cache shorthands.
310
+ trodo.track_llm_call(
311
+ model='claude-sonnet-4', provider='anthropic',
312
+ usage_details={'input': 1000, 'output': 500},
313
+ cache_read_tokens=200, cache_write_tokens=80, # → cache_read / cache_write
314
+ )
315
+
316
+ # (d) Pass cost straight through (skip server-side pricing).
317
+ trodo.track_llm_call(model='gpt-4o', provider='openai', cost=0.0123)
318
+
319
+ # (e) Per-category cost breakdown (authoritative).
320
+ trodo.track_llm_call(
321
+ model='gpt-4o', provider='openai',
322
+ cost_details={'input': 0.0003, 'output': 0.0005, 'cache_read': 0.00001},
323
+ )
324
+ ```
325
+
326
+ Inside a `wrap_agent` / `span` block, set the same fields on the handle:
327
+
328
+ ```python
329
+ s.set_llm(
330
+ model='gpt-4o', provider='openai',
331
+ usage_details={'input': 1000, 'output': 500},
332
+ cache_read_tokens=200,
333
+ # or: cost=0.0123 / cost_details={'input': ..., 'output': ...}
334
+ )
335
+ ```
336
+
337
+ Override auto-extraction with `extract_usage` (scalar in/out) or `extract_usage_map`
338
+ (open map) on `trodo.llm(name, fn, ...)`.
339
+
277
340
  ### Cross-service runs
278
341
 
279
342
  When one service calls another, the downstream service **joins** the
@@ -1,9 +1,12 @@
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
9
+ tests/test_llm_usage_cost.py
7
10
  tests/test_processor_methods.py
8
11
  tests/test_register_otel.py
9
12
  tests/test_start_run.py
File without changes