trodo-python 2.7.0__py3-none-any.whl → 2.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trodo/__init__.py +1 -1
- trodo/otel/auto_instrument.py +35 -0
- trodo/otel/helpers.py +103 -23
- trodo/otel/processor.py +20 -0
- trodo/otel/wrap_agent.py +111 -11
- {trodo_python-2.7.0.dist-info → trodo_python-2.9.0.dist-info}/METADATA +64 -1
- {trodo_python-2.7.0.dist-info → trodo_python-2.9.0.dist-info}/RECORD +9 -9
- {trodo_python-2.7.0.dist-info → trodo_python-2.9.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.7.0.dist-info → trodo_python-2.9.0.dist-info}/top_level.txt +0 -0
trodo/__init__.py
CHANGED
trodo/otel/auto_instrument.py
CHANGED
|
@@ -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,
|
trodo/otel/helpers.py
CHANGED
|
@@ -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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
333
|
+
usage_map = (extract_usage_map or _default_usage_map)(result)
|
|
273
334
|
except Exception:
|
|
274
|
-
|
|
275
|
-
if
|
|
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 +
|
|
401
|
-
|
|
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='
|
|
407
|
-
|
|
408
|
-
|
|
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:
|
trodo/otel/processor.py
CHANGED
|
@@ -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
|
trodo/otel/wrap_agent.py
CHANGED
|
@@ -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
|
-
|
|
392
|
-
if
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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.
|
|
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,4 +1,4 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
1
|
+
trodo/__init__.py,sha256=p9NGic9WzF53OYKtk-MSdQNcCTbXq8DNZeXYWl9OCUo,16678
|
|
2
2
|
trodo/client.py,sha256=8DsKoLh_eaNxj93qkHynfeee-QsdomB_kXfUQjGnWDk,18607
|
|
3
3
|
trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
|
|
4
4
|
trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
|
|
@@ -12,20 +12,20 @@ trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
12
12
|
trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
|
|
13
13
|
trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
|
|
14
14
|
trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
|
|
15
|
-
trodo/otel/auto_instrument.py,sha256=
|
|
15
|
+
trodo/otel/auto_instrument.py,sha256=gym90cYD6NzVDVsNtUjKHdKANy17t4AnU4lYGEGHyo8,12866
|
|
16
16
|
trodo/otel/context.py,sha256=iJ1rE42-SbO8VZHAxhIl2ZJXgNwLIVps5xLg8GKgfFc,1165
|
|
17
|
-
trodo/otel/helpers.py,sha256=
|
|
18
|
-
trodo/otel/processor.py,sha256=
|
|
17
|
+
trodo/otel/helpers.py,sha256=4HsjMOrE-7zuvaRSiGXxV7ZyfXQ5gLxtR3HdpLut9sk,20054
|
|
18
|
+
trodo/otel/processor.py,sha256=aqcTmzTw9cESgIp829pu_XCa5_dG_2MaeJNsqJZeqQU,7495
|
|
19
19
|
trodo/otel/register.py,sha256=YV2EnkUoa-_54YAuChOe-Mg28UUKg8JO7-qhVP9G6u4,7644
|
|
20
20
|
trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
|
|
21
|
-
trodo/otel/wrap_agent.py,sha256=
|
|
21
|
+
trodo/otel/wrap_agent.py,sha256=Nvjj8CjIGHqfWNV_heHno9HKJbKU3lHqHInX1EyRhnw,30334
|
|
22
22
|
trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
23
|
trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
|
|
24
24
|
trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
|
|
25
25
|
trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
26
26
|
trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
|
|
27
27
|
trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
|
|
28
|
-
trodo_python-2.
|
|
29
|
-
trodo_python-2.
|
|
30
|
-
trodo_python-2.
|
|
31
|
-
trodo_python-2.
|
|
28
|
+
trodo_python-2.9.0.dist-info/METADATA,sha256=Ab8QIUgDMayOyTjdtrdFP_EMk5bCcLHtX4hZJaaKEac,20482
|
|
29
|
+
trodo_python-2.9.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
30
|
+
trodo_python-2.9.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
|
|
31
|
+
trodo_python-2.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|