trodo-python 1.2.0__py3-none-any.whl → 2.2.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 +348 -47
- trodo/api/endpoints.py +5 -1
- trodo/api/http_client.py +30 -2
- trodo/client.py +239 -111
- trodo/otel/__init__.py +21 -0
- trodo/otel/auto_instrument.py +281 -0
- trodo/otel/context.py +44 -0
- trodo/otel/helpers.py +407 -0
- trodo/otel/processor.py +184 -0
- trodo/otel/wrap_agent.py +508 -0
- trodo/types.py +12 -68
- {trodo_python-1.2.0.dist-info → trodo_python-2.2.0.dist-info}/METADATA +183 -3
- {trodo_python-1.2.0.dist-info → trodo_python-2.2.0.dist-info}/RECORD +15 -9
- {trodo_python-1.2.0.dist-info → trodo_python-2.2.0.dist-info}/WHEEL +0 -0
- {trodo_python-1.2.0.dist-info → trodo_python-2.2.0.dist-info}/top_level.txt +0 -0
trodo/otel/helpers.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Modular instrumentation helpers: span helpers (trace / tool / llm /
|
|
2
|
+
retrieval), one-shot ``track_llm_call``, and FastAPI middleware for
|
|
3
|
+
cross-service run joining.
|
|
4
|
+
|
|
5
|
+
These are the SDK's customer-facing surface for custom code: one dual-form
|
|
6
|
+
wrapper per span kind, usable either as ``helper("name", fn)`` or as a
|
|
7
|
+
``@helper("name")`` decorator. All forms auto-capture args/kwargs as
|
|
8
|
+
``input``, return value as ``output``, exception as ``error``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import inspect
|
|
15
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, TypeVar
|
|
16
|
+
|
|
17
|
+
from .context import get_active_context
|
|
18
|
+
from .wrap_agent import SpanHandle, join_run, span as span_ctx
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_input(fn: Callable[..., Any], args: tuple, kwargs: dict) -> Dict[str, Any]:
|
|
25
|
+
"""Bind positional+keyword args to their parameter names.
|
|
26
|
+
|
|
27
|
+
Named kwargs are the common case; positional args fall back to 'args'
|
|
28
|
+
if signature binding fails.
|
|
29
|
+
"""
|
|
30
|
+
payload: Dict[str, Any] = {}
|
|
31
|
+
if args:
|
|
32
|
+
try:
|
|
33
|
+
sig = inspect.signature(fn)
|
|
34
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
35
|
+
payload.update(bound.arguments)
|
|
36
|
+
except Exception:
|
|
37
|
+
payload["args"] = list(args)
|
|
38
|
+
payload["kwargs"] = dict(kwargs)
|
|
39
|
+
else:
|
|
40
|
+
payload.update(kwargs)
|
|
41
|
+
return payload
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _wrap_fn(
|
|
45
|
+
fn: F,
|
|
46
|
+
*,
|
|
47
|
+
name: str,
|
|
48
|
+
kind: str,
|
|
49
|
+
on_result: Optional[Callable[[SpanHandle, Any], None]] = None,
|
|
50
|
+
extra_set: Optional[Callable[[SpanHandle], None]] = None,
|
|
51
|
+
) -> F:
|
|
52
|
+
"""Wrap fn so each call opens a span (sync + async aware).
|
|
53
|
+
|
|
54
|
+
``extra_set`` runs on the span handle before the call (e.g. set_llm).
|
|
55
|
+
``on_result`` runs on the span handle with the return value (e.g.
|
|
56
|
+
extracting OpenAI/Gemini usage into the LLM span).
|
|
57
|
+
"""
|
|
58
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
59
|
+
|
|
60
|
+
if is_async:
|
|
61
|
+
|
|
62
|
+
@functools.wraps(fn)
|
|
63
|
+
async def _async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
64
|
+
with span_ctx(name, kind=kind, input=_make_input(fn, args, kwargs)) as s:
|
|
65
|
+
if kind == "tool":
|
|
66
|
+
s.set_tool(name)
|
|
67
|
+
if extra_set is not None:
|
|
68
|
+
extra_set(s)
|
|
69
|
+
result = await fn(*args, **kwargs)
|
|
70
|
+
if on_result is not None:
|
|
71
|
+
try:
|
|
72
|
+
on_result(s, result)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
s.set_output(result)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
return _async_wrapper # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
@functools.wraps(fn)
|
|
81
|
+
def _sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
82
|
+
with span_ctx(name, kind=kind, input=_make_input(fn, args, kwargs)) as s:
|
|
83
|
+
if kind == "tool":
|
|
84
|
+
s.set_tool(name)
|
|
85
|
+
if extra_set is not None:
|
|
86
|
+
extra_set(s)
|
|
87
|
+
result = fn(*args, **kwargs)
|
|
88
|
+
if on_result is not None:
|
|
89
|
+
try:
|
|
90
|
+
on_result(s, result)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
s.set_output(result)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
return _sync_wrapper # type: ignore[return-value]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dual_form(default_kind: str):
|
|
100
|
+
"""Build a dual-form helper: ``h("name", fn)`` OR ``@h("name")``."""
|
|
101
|
+
|
|
102
|
+
def helper(
|
|
103
|
+
name: Any = None,
|
|
104
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
105
|
+
*,
|
|
106
|
+
kind: str = default_kind,
|
|
107
|
+
on_result: Optional[Callable[[SpanHandle, Any], None]] = None,
|
|
108
|
+
extra_set: Optional[Callable[[SpanHandle], None]] = None,
|
|
109
|
+
) -> Any:
|
|
110
|
+
# Case: h(fn) — bare decorator without parens; `name` is the fn.
|
|
111
|
+
if callable(name) and fn is None:
|
|
112
|
+
target = name
|
|
113
|
+
return _wrap_fn(
|
|
114
|
+
target,
|
|
115
|
+
name=target.__name__,
|
|
116
|
+
kind=kind,
|
|
117
|
+
on_result=on_result,
|
|
118
|
+
extra_set=extra_set,
|
|
119
|
+
)
|
|
120
|
+
# Case: h("name", fn) — helper form; wrap immediately.
|
|
121
|
+
if callable(fn):
|
|
122
|
+
return _wrap_fn(
|
|
123
|
+
fn,
|
|
124
|
+
name=name or fn.__name__,
|
|
125
|
+
kind=kind,
|
|
126
|
+
on_result=on_result,
|
|
127
|
+
extra_set=extra_set,
|
|
128
|
+
)
|
|
129
|
+
# Case: h("name") / h(name="name") / h() — return decorator.
|
|
130
|
+
def decorator(target: F) -> F:
|
|
131
|
+
return _wrap_fn(
|
|
132
|
+
target,
|
|
133
|
+
name=name or target.__name__,
|
|
134
|
+
kind=kind,
|
|
135
|
+
on_result=on_result,
|
|
136
|
+
extra_set=extra_set,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return decorator
|
|
140
|
+
|
|
141
|
+
return helper
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tool(
|
|
145
|
+
name: Any = None,
|
|
146
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
147
|
+
*,
|
|
148
|
+
kind: str = "tool",
|
|
149
|
+
) -> Any:
|
|
150
|
+
"""Wrap a function as a tool span — dual-form helper + decorator.
|
|
151
|
+
|
|
152
|
+
Helper form (uselemma-compatible)::
|
|
153
|
+
|
|
154
|
+
run_funnel_query = trodo.tool('run_funnel_query', run_funnel_query)
|
|
155
|
+
result = run_funnel_query(team_id=1, preset='day7')
|
|
156
|
+
|
|
157
|
+
Decorator form (backward-compatible)::
|
|
158
|
+
|
|
159
|
+
@trodo.tool()
|
|
160
|
+
def run_funnel_query(team_id, preset): ...
|
|
161
|
+
|
|
162
|
+
@trodo.tool(name='custom-name')
|
|
163
|
+
async def fetch(...): ...
|
|
164
|
+
|
|
165
|
+
@trodo.tool # bare (no parens)
|
|
166
|
+
def do_thing(): ...
|
|
167
|
+
"""
|
|
168
|
+
return _dual_form("tool")(name, fn, kind=kind)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def trace(
|
|
172
|
+
name: Any = None,
|
|
173
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Wrap a function as a generic span — dual-form helper + decorator.
|
|
176
|
+
|
|
177
|
+
Usage::
|
|
178
|
+
|
|
179
|
+
trodo.trace('prepare', prepare_fn)({'q': 'hi'})
|
|
180
|
+
|
|
181
|
+
@trodo.trace('step')
|
|
182
|
+
def step(): ...
|
|
183
|
+
"""
|
|
184
|
+
return _dual_form("generic")(name, fn, kind="generic")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def retrieval(
|
|
188
|
+
name: Any = None,
|
|
189
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""Wrap a retriever / vector search as a ``kind='retrieval'`` span."""
|
|
192
|
+
return _dual_form("retrieval")(name, fn, kind="retrieval")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _default_usage_extractor(result: Any) -> Tuple[Optional[int], Optional[int]]:
|
|
196
|
+
"""Auto-detect OpenAI ``usage`` and Gemini ``usageMetadata`` shapes."""
|
|
197
|
+
if result is None:
|
|
198
|
+
return (None, None)
|
|
199
|
+
# OpenAI / OpenAI-compat: {"usage": {"prompt_tokens", "completion_tokens"}}
|
|
200
|
+
usage = None
|
|
201
|
+
if isinstance(result, dict):
|
|
202
|
+
usage = result.get("usage")
|
|
203
|
+
else:
|
|
204
|
+
usage = getattr(result, "usage", None)
|
|
205
|
+
if usage is not None:
|
|
206
|
+
get = (lambda k: usage.get(k)) if isinstance(usage, dict) else (lambda k: getattr(usage, k, None))
|
|
207
|
+
pt = get("prompt_tokens")
|
|
208
|
+
ct = get("completion_tokens")
|
|
209
|
+
if pt is not None or ct is not None:
|
|
210
|
+
return (
|
|
211
|
+
int(pt) if pt is not None else None,
|
|
212
|
+
int(ct) if ct is not None else None,
|
|
213
|
+
)
|
|
214
|
+
# Anthropic-style nested under `usage`:
|
|
215
|
+
it = get("input_tokens")
|
|
216
|
+
ot = get("output_tokens")
|
|
217
|
+
if it is not None or ot is not None:
|
|
218
|
+
return (
|
|
219
|
+
int(it) if it is not None else None,
|
|
220
|
+
int(ot) if ot is not None else None,
|
|
221
|
+
)
|
|
222
|
+
# Gemini: {"usageMetadata": {"promptTokenCount", "candidatesTokenCount"}}
|
|
223
|
+
if isinstance(result, dict):
|
|
224
|
+
meta = result.get("usageMetadata")
|
|
225
|
+
if isinstance(meta, dict):
|
|
226
|
+
pt = meta.get("promptTokenCount")
|
|
227
|
+
ct = meta.get("candidatesTokenCount")
|
|
228
|
+
return (
|
|
229
|
+
int(pt) if pt is not None else None,
|
|
230
|
+
int(ct) if ct is not None else None,
|
|
231
|
+
)
|
|
232
|
+
return (None, None)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def llm(
|
|
236
|
+
name: Any = None,
|
|
237
|
+
fn: Optional[Callable[..., Any]] = None,
|
|
238
|
+
*,
|
|
239
|
+
model: Optional[str] = None,
|
|
240
|
+
provider: Optional[str] = None,
|
|
241
|
+
temperature: Optional[float] = None,
|
|
242
|
+
extract_usage: Optional[Callable[[Any], Tuple[Optional[int], Optional[int]]]] = None,
|
|
243
|
+
) -> Any:
|
|
244
|
+
"""Wrap an LLM call as a ``kind='llm'`` span with auto token extraction.
|
|
245
|
+
|
|
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.
|
|
250
|
+
|
|
251
|
+
Usage::
|
|
252
|
+
|
|
253
|
+
answer = trodo.llm(
|
|
254
|
+
'answer', call_openai, model='gpt-4o-mini', provider='openai',
|
|
255
|
+
)(messages)
|
|
256
|
+
|
|
257
|
+
@trodo.llm('plan', model='claude-haiku-4-5', provider='anthropic')
|
|
258
|
+
def plan(messages): ...
|
|
259
|
+
"""
|
|
260
|
+
extractor = extract_usage or _default_usage_extractor
|
|
261
|
+
|
|
262
|
+
def _set_llm(s: SpanHandle) -> None:
|
|
263
|
+
if model or provider or temperature is not None:
|
|
264
|
+
s.set_llm(
|
|
265
|
+
model=model,
|
|
266
|
+
provider=provider,
|
|
267
|
+
temperature=temperature,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _on_result(s: SpanHandle, result: Any) -> None:
|
|
271
|
+
try:
|
|
272
|
+
pt, ct = extractor(result)
|
|
273
|
+
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
|
+
)
|
|
283
|
+
|
|
284
|
+
return _dual_form("llm")(
|
|
285
|
+
name, fn, kind="llm", extra_set=_set_llm, on_result=_on_result
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def track_llm_call(
|
|
290
|
+
*,
|
|
291
|
+
model: Optional[str] = None,
|
|
292
|
+
provider: Optional[str] = None,
|
|
293
|
+
input_tokens: Optional[int] = None,
|
|
294
|
+
output_tokens: Optional[int] = None,
|
|
295
|
+
prompt: Any = None,
|
|
296
|
+
completion: Any = None,
|
|
297
|
+
temperature: Optional[float] = None,
|
|
298
|
+
cost: Optional[float] = None,
|
|
299
|
+
name: Optional[str] = None,
|
|
300
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Record a one-shot LLM span for a raw-HTTP caller.
|
|
303
|
+
|
|
304
|
+
Opens and immediately closes a ``span(kind='llm')`` populated with the
|
|
305
|
+
model + token counts + prompt/completion. No-op outside an active run
|
|
306
|
+
context.
|
|
307
|
+
|
|
308
|
+
Usage:
|
|
309
|
+
resp = httpx.post(url, json=body).json()
|
|
310
|
+
trodo.track_llm_call(
|
|
311
|
+
model='gemini-2.5-flash',
|
|
312
|
+
provider='google',
|
|
313
|
+
input_tokens=resp['usageMetadata']['promptTokenCount'],
|
|
314
|
+
output_tokens=resp['usageMetadata']['candidatesTokenCount'],
|
|
315
|
+
prompt=body,
|
|
316
|
+
completion=resp,
|
|
317
|
+
)
|
|
318
|
+
"""
|
|
319
|
+
if get_active_context() is None:
|
|
320
|
+
return
|
|
321
|
+
span_name = name or (f"llm.{provider}.{model}" if model else "llm")
|
|
322
|
+
with span_ctx(span_name, kind="llm", input=prompt, attributes=metadata) as s:
|
|
323
|
+
s.set_llm(
|
|
324
|
+
model=model,
|
|
325
|
+
provider=provider,
|
|
326
|
+
input_tokens=input_tokens,
|
|
327
|
+
output_tokens=output_tokens,
|
|
328
|
+
cost=cost,
|
|
329
|
+
temperature=temperature,
|
|
330
|
+
)
|
|
331
|
+
if completion is not None:
|
|
332
|
+
s.set_output(completion)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# FastAPI / Starlette middleware
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
_TRODO_RUN_HEADER = "x-trodo-run-id"
|
|
340
|
+
_TRODO_PARENT_SPAN_HEADER = "x-trodo-parent-span-id"
|
|
341
|
+
_TRODO_AGENT_HEADER = "x-trodo-agent-name"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def fastapi_middleware(client: Any) -> Callable:
|
|
345
|
+
"""Return a FastAPI/Starlette middleware that auto-joins remote runs.
|
|
346
|
+
|
|
347
|
+
If inbound request has X-Trodo-Run-Id, every span created while
|
|
348
|
+
handling the request nests under the caller's run. Otherwise the
|
|
349
|
+
handler runs with no active context (no-op tracking).
|
|
350
|
+
|
|
351
|
+
Usage:
|
|
352
|
+
from fastapi import FastAPI
|
|
353
|
+
import trodo
|
|
354
|
+
trodo.init(site_id='...')
|
|
355
|
+
app = FastAPI()
|
|
356
|
+
app.middleware('http')(trodo.fastapi_middleware(trodo._client))
|
|
357
|
+
"""
|
|
358
|
+
processor = client._span_processor
|
|
359
|
+
site_id = client.site_id
|
|
360
|
+
|
|
361
|
+
async def _middleware(request: Any, call_next: Callable[[Any], Awaitable[Any]]):
|
|
362
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
363
|
+
run_id = headers.get(_TRODO_RUN_HEADER)
|
|
364
|
+
parent_span_id = headers.get(_TRODO_PARENT_SPAN_HEADER)
|
|
365
|
+
name = (
|
|
366
|
+
headers.get(_TRODO_AGENT_HEADER)
|
|
367
|
+
or f"http.{request.method}.{request.url.path}"
|
|
368
|
+
)
|
|
369
|
+
if not run_id:
|
|
370
|
+
return await call_next(request)
|
|
371
|
+
|
|
372
|
+
input_payload = {
|
|
373
|
+
"method": request.method,
|
|
374
|
+
"path": request.url.path,
|
|
375
|
+
}
|
|
376
|
+
with join_run(
|
|
377
|
+
processor=processor,
|
|
378
|
+
team_site_id=site_id,
|
|
379
|
+
run_id=run_id,
|
|
380
|
+
parent_span_id=parent_span_id,
|
|
381
|
+
name=name,
|
|
382
|
+
kind="agent",
|
|
383
|
+
input=input_payload,
|
|
384
|
+
) as s:
|
|
385
|
+
response = await call_next(request)
|
|
386
|
+
try:
|
|
387
|
+
s.set_output({"status_code": getattr(response, "status_code", None)})
|
|
388
|
+
except Exception:
|
|
389
|
+
pass
|
|
390
|
+
return response
|
|
391
|
+
|
|
392
|
+
return _middleware
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def propagation_headers() -> Dict[str, str]:
|
|
396
|
+
"""Return HTTP headers that carry the current run/span context.
|
|
397
|
+
|
|
398
|
+
Use when making outbound HTTP calls to downstream services so they
|
|
399
|
+
can ``join_run`` instead of creating their own runs.
|
|
400
|
+
"""
|
|
401
|
+
ctx = get_active_context()
|
|
402
|
+
if ctx is None:
|
|
403
|
+
return {}
|
|
404
|
+
headers: Dict[str, str] = {"X-Trodo-Run-Id": ctx.run_id}
|
|
405
|
+
if ctx.span_id:
|
|
406
|
+
headers["X-Trodo-Parent-Span-Id"] = ctx.span_id
|
|
407
|
+
return headers
|
trodo/otel/processor.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""TrodoSpanProcessor — buffers spans and ships them to the Trodo ingest API.
|
|
2
|
+
|
|
3
|
+
Mirrors the Node SDK processor: when a run finalises we POST /runs/ingest
|
|
4
|
+
with the run and all pending spans for that run. Standalone spans (no run)
|
|
5
|
+
flush asynchronously via append_spans.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass, asdict
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TrodoRun:
|
|
17
|
+
run_id: str
|
|
18
|
+
agent_name: str
|
|
19
|
+
distinct_id: Optional[str] = None
|
|
20
|
+
conversation_id: Optional[str] = None
|
|
21
|
+
parent_run_id: Optional[str] = None
|
|
22
|
+
status: str = "ok" # 'running' | 'ok' | 'error'
|
|
23
|
+
input: Optional[str] = None
|
|
24
|
+
output: Optional[str] = None
|
|
25
|
+
started_at: Optional[str] = None
|
|
26
|
+
ended_at: Optional[str] = None
|
|
27
|
+
duration_ms: Optional[int] = None
|
|
28
|
+
error_summary: Optional[str] = None
|
|
29
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
30
|
+
# Aggregates summed from child spans at finalisation.
|
|
31
|
+
total_tokens_in: Optional[int] = None
|
|
32
|
+
total_tokens_out: Optional[int] = None
|
|
33
|
+
total_cost: Optional[float] = None
|
|
34
|
+
span_count: Optional[int] = None
|
|
35
|
+
tool_count: Optional[int] = None
|
|
36
|
+
error_count: Optional[int] = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
39
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TrodoSpan:
|
|
44
|
+
span_id: str
|
|
45
|
+
run_id: str
|
|
46
|
+
parent_span_id: Optional[str]
|
|
47
|
+
kind: str = "generic" # 'llm' | 'tool' | 'agent' | 'retrieval' | 'generic'
|
|
48
|
+
name: str = ""
|
|
49
|
+
status: str = "ok"
|
|
50
|
+
started_at: Optional[str] = None
|
|
51
|
+
ended_at: Optional[str] = None
|
|
52
|
+
duration_ms: Optional[int] = None
|
|
53
|
+
input: Optional[str] = None
|
|
54
|
+
output: Optional[str] = None
|
|
55
|
+
error_type: Optional[str] = None
|
|
56
|
+
error_message: Optional[str] = None
|
|
57
|
+
model: Optional[str] = None
|
|
58
|
+
provider: Optional[str] = None
|
|
59
|
+
input_tokens: Optional[int] = None
|
|
60
|
+
output_tokens: Optional[int] = None
|
|
61
|
+
cost: Optional[float] = None
|
|
62
|
+
temperature: Optional[float] = None
|
|
63
|
+
tool_name: Optional[str] = None
|
|
64
|
+
attributes: Optional[Dict[str, Any]] = None
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
67
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TrodoSpanProcessor:
|
|
71
|
+
"""Buffers spans per run_id; flushes to /runs/ingest when the run ends.
|
|
72
|
+
|
|
73
|
+
Spans emitted while a ``join_run`` context is active are forwarded via
|
|
74
|
+
``append_spans`` immediately on each enqueue (because the owning service
|
|
75
|
+
is remote and won't finalise the run).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
http_client: Any,
|
|
81
|
+
flush_interval_s: float = 5.0,
|
|
82
|
+
max_spans_per_run: int = 1000,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._http = http_client
|
|
85
|
+
self._flush_interval = flush_interval_s
|
|
86
|
+
self._max_spans = max_spans_per_run
|
|
87
|
+
self._pending: Dict[str, List[TrodoSpan]] = {}
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
self._shutdown = False
|
|
90
|
+
# run_ids that are "joined" (owned by a remote service) — their spans
|
|
91
|
+
# are flushed incrementally via append_spans, never batched for a
|
|
92
|
+
# local ingest_run call.
|
|
93
|
+
self._joined_runs: set[str] = set()
|
|
94
|
+
|
|
95
|
+
def mark_joined(self, run_id: str) -> None:
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._joined_runs.add(run_id)
|
|
98
|
+
|
|
99
|
+
def unmark_joined(self, run_id: str) -> None:
|
|
100
|
+
with self._lock:
|
|
101
|
+
self._joined_runs.discard(run_id)
|
|
102
|
+
self._pending.pop(run_id, None)
|
|
103
|
+
|
|
104
|
+
def enqueue_span(self, span: TrodoSpan) -> None:
|
|
105
|
+
"""Buffer a completed span under its run_id.
|
|
106
|
+
|
|
107
|
+
Joined runs flush immediately (append_spans) instead of batching.
|
|
108
|
+
"""
|
|
109
|
+
with self._lock:
|
|
110
|
+
joined = span.run_id in self._joined_runs
|
|
111
|
+
if joined:
|
|
112
|
+
pass
|
|
113
|
+
else:
|
|
114
|
+
bucket = self._pending.setdefault(span.run_id, [])
|
|
115
|
+
if len(bucket) < self._max_spans:
|
|
116
|
+
bucket.append(span)
|
|
117
|
+
if joined:
|
|
118
|
+
try:
|
|
119
|
+
self._http.post_spans_append(span.run_id, [span.to_dict()])
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def get_pending(self, run_id: str) -> List[TrodoSpan]:
|
|
124
|
+
with self._lock:
|
|
125
|
+
return list(self._pending.get(run_id, []))
|
|
126
|
+
|
|
127
|
+
def ingest_run(self, run: TrodoRun) -> None:
|
|
128
|
+
"""Flush a run + all its buffered spans in one call."""
|
|
129
|
+
with self._lock:
|
|
130
|
+
spans = self._pending.pop(run.run_id, [])
|
|
131
|
+
payload: Dict[str, Any] = {"run": run.to_dict()}
|
|
132
|
+
if spans:
|
|
133
|
+
payload["spans"] = [s.to_dict() for s in spans]
|
|
134
|
+
try:
|
|
135
|
+
self._http.post_run_ingest(payload)
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def start_run(self, run: TrodoRun) -> None:
|
|
140
|
+
"""Open a Run row server-side without holding a context manager.
|
|
141
|
+
|
|
142
|
+
Pairs with ``end_run`` for sessions that span multiple processes or
|
|
143
|
+
HTTP requests. Spans emitted between start_run and end_run flush
|
|
144
|
+
incrementally via append_spans (callers are expected to mark_joined).
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
self._http.post_run_start({"run": run.to_dict()})
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def end_run(self, run_id: str, payload: Dict[str, Any]) -> None:
|
|
152
|
+
"""Finalise a Run opened by start_run."""
|
|
153
|
+
try:
|
|
154
|
+
self._http.post_run_end(run_id, payload)
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def append_spans(self, run_id: str, spans: List[TrodoSpan]) -> None:
|
|
159
|
+
"""Stream spans for a long-running or joined run without finalising."""
|
|
160
|
+
if not spans:
|
|
161
|
+
return
|
|
162
|
+
try:
|
|
163
|
+
self._http.post_spans_append(run_id, [s.to_dict() for s in spans])
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def force_flush(self) -> None:
|
|
168
|
+
"""Flush any buffered spans that don't have a run finalise yet."""
|
|
169
|
+
with self._lock:
|
|
170
|
+
pending = list(self._pending.items())
|
|
171
|
+
self._pending.clear()
|
|
172
|
+
for run_id, spans in pending:
|
|
173
|
+
if not spans:
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
self._http.post_spans_append(
|
|
177
|
+
run_id, [s.to_dict() for s in spans]
|
|
178
|
+
)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def shutdown(self) -> None:
|
|
183
|
+
self._shutdown = True
|
|
184
|
+
self.force_flush()
|