trodo-python 1.0.1__py3-none-any.whl → 2.1.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/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
@@ -0,0 +1,165 @@
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 append_spans(self, run_id: str, spans: List[TrodoSpan]) -> None:
140
+ """Stream spans for a long-running or joined run without finalising."""
141
+ if not spans:
142
+ return
143
+ try:
144
+ self._http.post_spans_append(run_id, [s.to_dict() for s in spans])
145
+ except Exception:
146
+ pass
147
+
148
+ def force_flush(self) -> None:
149
+ """Flush any buffered spans that don't have a run finalise yet."""
150
+ with self._lock:
151
+ pending = list(self._pending.items())
152
+ self._pending.clear()
153
+ for run_id, spans in pending:
154
+ if not spans:
155
+ continue
156
+ try:
157
+ self._http.post_spans_append(
158
+ run_id, [s.to_dict() for s in spans]
159
+ )
160
+ except Exception:
161
+ pass
162
+
163
+ def shutdown(self) -> None:
164
+ self._shutdown = True
165
+ self.force_flush()