trodo-python 2.23.4__py3-none-any.whl → 2.25.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 CHANGED
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.23.4"
44
+ __version__ = "2.25.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -163,6 +163,8 @@ def init(
163
163
  auto_instrument: bool = True,
164
164
  disable_instrumentations: Optional[List[str]] = None,
165
165
  otel_mode: str = "trodo",
166
+ delivery: str = "auto",
167
+ delivery_options: Optional[Dict[str, Any]] = None,
166
168
  ) -> TrodoClient:
167
169
  """Initialise the singleton SDK instance.
168
170
 
@@ -171,6 +173,19 @@ def init(
171
173
  any other installed opentelemetry-instrumentation-* package, so
172
174
  LLM calls emit token/cost spans with no further code.
173
175
 
176
+ ``delivery`` (2.24): how traces reach Trodo.
177
+ 'background' — wrap_agent returns at once; a daemon thread posts queued
178
+ runs in batches every second, retries while nobody waits,
179
+ and flushes at exit. The default on servers.
180
+ 'immediate' — the run is sent before wrap_agent returns, with short
181
+ retries (≤ 15 s), for platforms that freeze between
182
+ requests: Lambda, Cloud Functions, Cloud Run, Vercel.
183
+ 'auto' — 'immediate' when such a platform's environment variables
184
+ are present, otherwise 'background'. Default.
185
+ ``delivery_options``: flush_interval_s (1), flush_at (50), max_batch_bytes
186
+ (4 MB), max_queue_items (10,000), max_queue_bytes (64 MB), max_retry_s
187
+ (600), exit_flush_s (5).
188
+
174
189
  ``disable_instrumentations`` turns OFF specific instrumentors by id
175
190
  (e.g. ``['openai']`` when LangChain is your primary framework, to avoid
176
191
  double-counting the same model call). Mirrors Node's
@@ -191,6 +206,8 @@ def init(
191
206
  auto_instrument=auto_instrument,
192
207
  disable_instrumentations=disable_instrumentations,
193
208
  otel_mode=otel_mode,
209
+ delivery=delivery,
210
+ delivery_options=delivery_options,
194
211
  )
195
212
  return _client
196
213
 
trodo/_version.py ADDED
@@ -0,0 +1 @@
1
+ SDK_VERSION = "2.25.0"
trodo/api/endpoints.py CHANGED
@@ -22,8 +22,11 @@ GROUPS_REMOVE = "/api/sdk/groups/remove_group"
22
22
  GROUPS_PROFILE = "/api/sdk/groups/profile"
23
23
  # Agent run tracking (Lemma-style, run-centric)
24
24
  RUNS_INGEST = "/api/sdk/runs/ingest"
25
+ # Many writes in one request — the background sender (delivery/sender.py).
26
+ BATCH = "/api/sdk/batch"
25
27
  RUNS_START = "/api/sdk/runs/start"
26
28
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
29
+ SPANS_APPEND = "/api/sdk/spans/append"
27
30
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
28
31
  # Datasets & experiments
29
32
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
trodo/api/http_client.py CHANGED
@@ -11,6 +11,7 @@ import requests
11
11
  from ..types import ApiResult, EventPayload
12
12
  from . import endpoints
13
13
  from ._retry import jittered_backoff, retry_delay
14
+ from .._version import SDK_VERSION
14
15
 
15
16
 
16
17
  class HttpClient:
@@ -82,9 +83,40 @@ class HttpClient:
82
83
  return self._request(path, body, attempt + 1)
83
84
  self._log(f"Error: {exc}")
84
85
  if self.on_error:
85
- self.on_error(exc)
86
+ try:
87
+ self.on_error(exc)
88
+ except Exception:
89
+ pass # a handler that raises must not surface into user code
86
90
  return {}
87
91
 
92
+ def post_raw(
93
+ self, path: str, body: Dict[str, Any], timeout: Optional[float] = None, gzip_body: bool = True,
94
+ ) -> tuple:
95
+ """One attempt, the status told: ``(status, retry_after, parsed_body)``.
96
+
97
+ The background sender (delivery/sender.py) decides what to do with a
98
+ 429, a 5xx or a timeout, so unlike ``_request`` this neither retries
99
+ nor swallows: it raises only on a network error or a timeout. Bodies
100
+ over 8 KB are gzipped.
101
+ """
102
+ import gzip as _gzip
103
+ import json as _json
104
+
105
+ url = f"{self.api_base}{path}"
106
+ raw = _json.dumps(body, default=str).encode("utf-8")
107
+ headers: Dict[str, str] = {"User-Agent": f"trodo-python/{SDK_VERSION}"}
108
+ data = raw
109
+ if gzip_body and len(raw) > 8 * 1024:
110
+ data = _gzip.compress(raw)
111
+ headers["Content-Encoding"] = "gzip"
112
+ self._log(f"POST {url} ({len(raw)} bytes{', gzip' if 'Content-Encoding' in headers else ''})")
113
+ resp = self._session.post(url, data=data, headers=headers, timeout=timeout or self.timeout)
114
+ try:
115
+ parsed = resp.json() if resp.text else None
116
+ except Exception:
117
+ parsed = {"body": resp.text[:300]}
118
+ return resp.status_code, resp.headers.get("retry-after"), parsed
119
+
88
120
  def _get(
89
121
  self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
90
122
  ) -> ApiResult:
trodo/client.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import functools
6
+ import inspect
6
7
  from typing import Any, Callable, Dict, List, Optional, Union
7
8
 
8
9
  from .api.http_client import HttpClient
@@ -14,6 +15,7 @@ from .queue.batch_flusher import BatchFlusher
14
15
  from .auto.auto_event_manager import AutoEventManager
15
16
  from .user_context import UserContext
16
17
  from .otel.processor import TrodoSpanProcessor
18
+ from .delivery.sender import Sender
17
19
  from .otel.wrap_agent import (
18
20
  wrap_agent as wrap_agent_ctx,
19
21
  span as span_ctx,
@@ -61,6 +63,8 @@ class TrodoClient:
61
63
  disable_instrumentations: Optional[List[str]] = None,
62
64
  otel_mode: str = "trodo",
63
65
  max_cached_users: int = 10_000,
66
+ delivery: str = "auto",
67
+ delivery_options: Optional[Dict[str, Any]] = None,
64
68
  ) -> None:
65
69
  if not site_id:
66
70
  raise ValueError("trodo-python: site_id is required")
@@ -105,7 +109,11 @@ class TrodoClient:
105
109
  # API server. An evicted user is simply rebuilt on next use.
106
110
  self._user_context_cache: LruDict[str, UserContext] = LruDict(max_cached_users)
107
111
 
108
- self._span_processor = TrodoSpanProcessor(http_client=self._http)
112
+ # Background delivery (2.24): runs and spans are queued and sent by a
113
+ # daemon thread; 'immediate' on serverless platforms (detected from
114
+ # the environment when delivery='auto').
115
+ self._sender = Sender(self._http, mode=delivery, on_error=on_error, debug=debug, **(delivery_options or {}))
116
+ self._span_processor = TrodoSpanProcessor(http_client=self._http, sender=self._sender)
109
117
  if auto_instrument:
110
118
  import os
111
119
  if os.environ.get("TRODO_LEGACY_INIT") == "1":
@@ -299,10 +307,12 @@ class TrodoClient:
299
307
  # Lifecycle
300
308
  # --------------------------------------------------------------------------
301
309
 
302
- def flush(self) -> None:
310
+ def flush(self, timeout: Optional[float] = None) -> None:
311
+ """Send everything queued and wait for it (bounded by ``timeout`` seconds, if given)."""
303
312
  if self._batch_flusher:
304
313
  self._batch_flusher.flush()
305
314
  self._span_processor.force_flush()
315
+ self._sender.flush(timeout)
306
316
  # 'otlp' mode: drain the OTel BatchSpanProcessor too — it batches for
307
317
  # up to 5s, so without this a short-lived process drops its spans.
308
318
  _flush_otlp_pipeline()
@@ -313,8 +323,13 @@ class TrodoClient:
313
323
  self._batch_flusher.stop()
314
324
  self._batch_flusher.flush()
315
325
  self._span_processor.shutdown()
326
+ self._sender.shutdown()
316
327
  _shutdown_otlp_pipeline()
317
328
 
329
+ def delivery_stats(self) -> Dict[str, Any]:
330
+ """What the background sender is doing: queued, in flight, sent, dropped (by reason), paused-until, mode."""
331
+ return self._sender.stats()
332
+
318
333
  # --------------------------------------------------------------------------
319
334
  # Agent Runs — Lemma-style seamless wrapping
320
335
  # --------------------------------------------------------------------------
@@ -364,16 +379,31 @@ class TrodoClient:
364
379
  """Decorator form of wrap_agent."""
365
380
 
366
381
  def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
367
- @functools.wraps(fn)
368
- def wrapper(*args: Any, **kwargs: Any) -> Any:
369
- with self.wrap_agent(
382
+ def _run():
383
+ return self.wrap_agent(
370
384
  agent_name,
371
385
  distinct_id=distinct_id,
372
386
  conversation_id=conversation_id,
373
387
  parent_run_id=parent_run_id,
374
388
  metadata=metadata,
375
389
  user=user,
376
- ) as run:
390
+ )
391
+
392
+ if inspect.iscoroutinefunction(fn):
393
+ # An ``async def`` agent (2.24). Before, the run closed the
394
+ # moment the coroutine was created and recorded the coroutine
395
+ # object as its output.
396
+ @functools.wraps(fn)
397
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
398
+ async with _run() as run:
399
+ result = await fn(*args, **kwargs)
400
+ run.set_output(result)
401
+ return result
402
+ return async_wrapper
403
+
404
+ @functools.wraps(fn)
405
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
406
+ with _run() as run:
377
407
  result = fn(*args, **kwargs)
378
408
  run.set_output(result)
379
409
  return result
@@ -600,7 +630,10 @@ class TrodoClient:
600
630
  "distinct_id": distinct_id,
601
631
  "attributes": metadata or {},
602
632
  }
603
- return self._http.post_run_feedback(run_id, payload)
633
+ # Queued behind the run it belongs to (2.24): never waits on the
634
+ # network, survives an outage, and lands after its run.
635
+ self._sender.enqueue({"run_id": run_id, "feedback": payload})
636
+ return {}
604
637
 
605
638
  def feedback_props(self, run_id: str, props: FeedbackProps) -> ApiResult:
606
639
  """Convenience — pass a FeedbackProps dataclass."""
File without changes
@@ -0,0 +1,500 @@
1
+ """Background delivery of traces (2.24).
2
+
3
+ Before this, ``wrap_agent`` posted the finished run — and slept through every
4
+ retry and Retry-After — on the caller's thread before handing the agent's
5
+ result back: up to ~90 s added to one agent call when a team was over its
6
+ rate ceiling, the event loop frozen in an asyncio app, and then the trace was
7
+ dropped. Now a finished run (or a flush of spans) goes into this queue and
8
+ the agent returns at once. A daemon thread drains the queue to
9
+ ``POST /api/sdk/batch`` every second or 50 items, gzipped.
10
+
11
+ The rules match what Langfuse, LangSmith, Braintrust, OpenTelemetry, Sentry
12
+ and PostHog have converged on (1 Oct 2026 survey):
13
+ - bounded queue (10,000 items / 64 MB). Full → the OLDEST are dropped, so
14
+ the traces being debugged right now survive; drops are counted and
15
+ reported to the server with the next batch.
16
+ - retries while nobody waits: 429 pauses the whole sender until
17
+ Retry-After (a client over its limit delivers late, not never); 5xx, 408
18
+ and network errors back off (1 s → 60 s) for up to 10 minutes per item,
19
+ then it is dropped as ``gave_up``; 413 splits the batch, a single
20
+ oversized item is dropped as ``too_large``; any other 4xx is a bad item:
21
+ dropped as ``rejected``, reported once.
22
+ - exit: ``atexit`` flushes (bounded by ``exit_flush_s``).
23
+ - ``immediate`` mode for platforms that freeze between requests (Lambda,
24
+ Cloud Functions, Cloud Run, Vercel): the item is sent now, with short
25
+ retries, on the caller's thread. ``auto`` picks it from the platform's
26
+ environment variables.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import atexit
32
+ import gzip
33
+ import json
34
+ import os
35
+ import random
36
+ import sys
37
+ import threading
38
+ import time
39
+ from collections import deque
40
+ from typing import Any, Callable, Deque, Dict, List, Optional
41
+
42
+ from ..api import endpoints
43
+
44
+ Item = Dict[str, Any]
45
+
46
+ DEFAULTS = dict(
47
+ flush_interval_s=1.0, flush_at=50, max_batch_bytes=4 * 1024 * 1024,
48
+ max_queue_items=10_000, max_queue_bytes=64 * 1024 * 1024,
49
+ max_retry_s=600.0, max_retry_after_s=600.0, immediate_retry_s=15.0,
50
+ exit_flush_s=5.0, request_timeout_s=20.0,
51
+ )
52
+ _MIN_BACKOFF_S = 1.0
53
+ _MAX_BACKOFF_S = 60.0
54
+ _TRANSIENT = {408, 425, 429, 500, 502, 503, 504}
55
+
56
+
57
+ def detect_mode(env: Optional[Dict[str, str]] = None) -> str:
58
+ """'immediate' on serverless / freeze-between-requests platforms, else 'background'."""
59
+ e = os.environ if env is None else env
60
+ if e.get("AWS_LAMBDA_FUNCTION_NAME") or e.get("LAMBDA_TASK_ROOT"):
61
+ return "immediate"
62
+ if e.get("FUNCTION_TARGET") or e.get("FUNCTION_NAME") or e.get("K_SERVICE"):
63
+ return "immediate" # Cloud Functions / Cloud Run (CPU off between requests)
64
+ if e.get("NETLIFY") or e.get("VERCEL") or e.get("FUNCTIONS_WORKER_RUNTIME"):
65
+ return "immediate"
66
+ return "background"
67
+
68
+
69
+ def parse_retry_after_s(value: Optional[str], now: float) -> Optional[float]:
70
+ if not value:
71
+ return None
72
+ try:
73
+ secs = float(value)
74
+ return secs if secs > 0 else None
75
+ except ValueError:
76
+ pass
77
+ try:
78
+ from email.utils import parsedate_to_datetime
79
+ delta = parsedate_to_datetime(value).timestamp() - now
80
+ return delta if delta > 0 else None
81
+ except Exception:
82
+ return None
83
+
84
+
85
+ class _Queued:
86
+ __slots__ = ("item", "nbytes", "enqueued_at", "first_tried_at", "attempts")
87
+
88
+ def __init__(self, item: Item, nbytes: int, enqueued_at: float) -> None:
89
+ self.item = item
90
+ self.nbytes = nbytes
91
+ self.enqueued_at = enqueued_at
92
+ self.first_tried_at: Optional[float] = None
93
+ self.attempts = 0
94
+
95
+
96
+ class Sender:
97
+ def __init__(
98
+ self,
99
+ http: Any,
100
+ mode: str = "auto",
101
+ on_error: Optional[Callable[[Exception], None]] = None,
102
+ debug: bool = False,
103
+ now: Callable[[], float] = time.time,
104
+ sleep: Callable[[float], None] = time.sleep,
105
+ register_atexit: bool = True,
106
+ **options: Any,
107
+ ) -> None:
108
+ self._http = http
109
+ self.mode = mode if mode in ("background", "immediate") else detect_mode()
110
+ self._o = {**DEFAULTS, **{k: v for k, v in options.items() if v is not None}}
111
+ self._on_error = on_error
112
+ self._debug = debug
113
+ self._now = now
114
+ self._sleep = sleep
115
+ self._queue: Deque[_Queued] = deque()
116
+ self._queue_bytes = 0
117
+ self._lock = threading.Lock()
118
+ self._wake = threading.Condition(self._lock)
119
+ self._paused_until: Optional[float] = None
120
+ self._not_before: float = 0.0 # backoff after a failed batch
121
+ self._stopped = False
122
+ self._flush_requested = False
123
+ self._in_flight = 0
124
+ self._stats = {"sent": 0, "dropped": 0, "dropped_by_reason": {}}
125
+ self._unreported = {"count": 0, "by_reason": {}}
126
+ self._warned_drop = False
127
+ self._thread: Optional[threading.Thread] = None
128
+ if self.mode == "background" and register_atexit:
129
+ atexit.register(self._atexit)
130
+
131
+ # ── public ────────────────────────────────────────────────────────────────
132
+
133
+ def enqueue(self, item: Item) -> None:
134
+ """Queue an item. Returns at once in background mode; in immediate mode
135
+ returns once the item is sent (or given up on)."""
136
+ if self._stopped:
137
+ return
138
+ if self.mode == "immediate":
139
+ self._send_immediate(item)
140
+ return
141
+ nbytes = _size_of(item)
142
+ if nbytes > self._o["max_batch_bytes"]:
143
+ self._drop(1, "too_large", f"an item of {nbytes} bytes exceeds the {self._o['max_batch_bytes']}-byte request limit")
144
+ return
145
+ with self._lock:
146
+ self._queue.append(_Queued(item, nbytes, self._now()))
147
+ self._queue_bytes += nbytes
148
+ shed = self._shed_locked()
149
+ self._ensure_thread_locked()
150
+ if len(self._queue) >= self._o["flush_at"]:
151
+ self._wake.notify()
152
+ if shed:
153
+ self._drop_queue_full(shed)
154
+
155
+ def flush(self, timeout_s: Optional[float] = None) -> None:
156
+ """Send everything queued and wait for it, for at most ``timeout_s``."""
157
+ if self.mode == "immediate":
158
+ return
159
+ deadline = None if timeout_s is None else self._now() + timeout_s
160
+ with self._lock:
161
+ self._not_before = 0.0
162
+ self._flush_requested = True
163
+ self._ensure_thread_locked()
164
+ self._wake.notify_all()
165
+ while (self._queue or self._in_flight) and not self._stopped:
166
+ remaining = None if deadline is None else deadline - self._now()
167
+ if remaining is not None and remaining <= 0:
168
+ break
169
+ self._wake.wait(timeout=min(0.1, remaining) if remaining is not None else 0.1)
170
+ self._flush_requested = False
171
+
172
+ def shutdown(self, timeout_s: Optional[float] = None) -> None:
173
+ self.flush(self._o["exit_flush_s"] if timeout_s is None else timeout_s)
174
+ with self._lock:
175
+ self._stopped = True
176
+ left = len(self._queue)
177
+ self._queue.clear()
178
+ self._queue_bytes = 0
179
+ self._wake.notify_all()
180
+ if left:
181
+ self._drop(left, "shutdown", f"{left} items were still queued at shutdown")
182
+
183
+ def stats(self) -> Dict[str, Any]:
184
+ with self._lock:
185
+ return {
186
+ "queued": len(self._queue), "queued_bytes": self._queue_bytes, "in_flight": self._in_flight,
187
+ "sent": self._stats["sent"], "dropped": self._stats["dropped"],
188
+ "dropped_by_reason": dict(self._stats["dropped_by_reason"]),
189
+ "paused_until": self._paused_until, "mode": self.mode,
190
+ }
191
+
192
+ # ── background thread ─────────────────────────────────────────────────────
193
+
194
+ def _ensure_thread_locked(self) -> None:
195
+ if self._thread is None or not self._thread.is_alive():
196
+ self._thread = threading.Thread(target=self._run, name="trodo-sender", daemon=True)
197
+ self._thread.start()
198
+
199
+ def _run(self) -> None:
200
+ interval = self._o["flush_interval_s"]
201
+ while True:
202
+ with self._lock:
203
+ while True:
204
+ if self._stopped:
205
+ return
206
+ if not self._queue:
207
+ self._wake.wait(timeout=interval)
208
+ continue
209
+ now = self._now()
210
+ gate = max(self._paused_until or 0.0, self._not_before)
211
+ if gate > now:
212
+ self._wake.wait(timeout=gate - now)
213
+ continue
214
+ if self._flush_requested or len(self._queue) >= self._o["flush_at"]:
215
+ break
216
+ age = now - self._queue[0].enqueued_at
217
+ if age >= interval:
218
+ break
219
+ self._wake.wait(timeout=interval - age)
220
+ batch = self._take_batch_locked()
221
+ self._in_flight += 1
222
+ try:
223
+ self._send_batch(batch)
224
+ finally:
225
+ with self._lock:
226
+ self._in_flight -= 1
227
+ self._wake.notify_all()
228
+
229
+ def _take_batch_locked(self) -> List[_Queued]:
230
+ out: List[_Queued] = []
231
+ nbytes = 0
232
+ while self._queue and len(out) < self._o["flush_at"]:
233
+ nxt = self._queue[0]
234
+ if out and nbytes + nxt.nbytes > self._o["max_batch_bytes"]:
235
+ break
236
+ out.append(self._queue.popleft())
237
+ nbytes += nxt.nbytes
238
+ self._queue_bytes -= nbytes
239
+ return out
240
+
241
+ def _send_batch(self, batch: List[_Queued]) -> None:
242
+ t = self._now()
243
+ for q in batch:
244
+ q.attempts += 1
245
+ if q.first_tried_at is None:
246
+ q.first_tried_at = t
247
+ try:
248
+ status, retry_after, body = self._http.post_raw(
249
+ endpoints.BATCH, self._envelope(batch), timeout=self._o["request_timeout_s"], gzip_body=True,
250
+ )
251
+ except Exception as exc: # network error, timeout
252
+ self._log(f"batch of {len(batch)} failed: {exc}")
253
+ self._requeue(batch, None)
254
+ return
255
+ if status == 200 and isinstance(body, dict) and isinstance(body.get("results"), list):
256
+ self._reported()
257
+ self._settle(batch, body)
258
+ return
259
+ if status == 429:
260
+ self._pause(parse_retry_after_s(retry_after, self._now()))
261
+ self._requeue(batch, 0.0)
262
+ return
263
+ if status == 413:
264
+ self._split(batch)
265
+ return
266
+ if status in _TRANSIENT:
267
+ self._requeue(batch, None)
268
+ return
269
+ if status == 404:
270
+ self._legacy_send(batch)
271
+ return
272
+ self._drop(len(batch), "rejected", f"POST {endpoints.BATCH} -> HTTP {status}: {json.dumps(body)[:300]}")
273
+
274
+ def _settle(self, batch: List[_Queued], body: Dict[str, Any]) -> None:
275
+ keep: List[_Queued] = []
276
+ rejected = 0
277
+ first_error = None
278
+ results = body["results"]
279
+ for i, q in enumerate(batch):
280
+ r = results[i] if i < len(results) and isinstance(results[i], dict) else {"status": 500}
281
+ st = int(r.get("status", 500))
282
+ if 200 <= st < 300:
283
+ with self._lock:
284
+ self._stats["sent"] += 1
285
+ continue
286
+ if st == 429 or st >= 500 or st == 408:
287
+ keep.append(q)
288
+ continue
289
+ if st == 413:
290
+ self._drop(1, "too_large", r.get("error") or "payload too large")
291
+ continue
292
+ rejected += 1
293
+ first_error = first_error or f"HTTP {st}: {r.get('error', '')}"
294
+ if rejected:
295
+ self._drop(rejected, "rejected", f"{rejected} item(s) rejected by the server ({first_error})")
296
+ ra = body.get("retry_after_ms")
297
+ if ra:
298
+ self._pause(float(ra) / 1000.0)
299
+ if keep:
300
+ self._requeue(keep, 0.0 if ra else None)
301
+
302
+ def _requeue(self, batch: List[_Queued], wait_s: Optional[float]) -> None:
303
+ t = self._now()
304
+ alive: List[_Queued] = []
305
+ for q in batch:
306
+ if q.first_tried_at is not None and t - q.first_tried_at > self._o["max_retry_s"]:
307
+ self._drop(1, "gave_up", f"an item was retried for {int(self._o['max_retry_s'])} s without being accepted")
308
+ else:
309
+ alive.append(q)
310
+ if not alive:
311
+ return
312
+ with self._lock:
313
+ for q in reversed(alive):
314
+ self._queue.appendleft(q)
315
+ self._queue_bytes += sum(q.nbytes for q in alive)
316
+ shed = self._shed_locked()
317
+ attempts = max(q.attempts for q in alive)
318
+ backoff = wait_s if wait_s is not None else min(_MAX_BACKOFF_S, _MIN_BACKOFF_S * 2 ** (attempts - 1)) * (0.5 + random.random() / 2)
319
+ self._not_before = t + backoff
320
+ if shed:
321
+ self._drop_queue_full(shed)
322
+
323
+ def _split(self, batch: List[_Queued]) -> None:
324
+ if len(batch) == 1:
325
+ self._drop(1, "too_large", f"an item of {batch[0].nbytes} bytes was refused as too large")
326
+ return
327
+ with self._lock:
328
+ for q in reversed(batch):
329
+ self._queue.appendleft(q)
330
+ self._queue_bytes += sum(q.nbytes for q in batch)
331
+ self._o["flush_at"] = max(1, len(batch) // 2)
332
+ self._wake.notify()
333
+
334
+ def _pause(self, secs: Optional[float]) -> None:
335
+ wait = min(secs if secs is not None else 30.0, self._o["max_retry_after_s"])
336
+ with self._lock:
337
+ self._paused_until = self._now() + wait
338
+ self._log(f"rate limited; paused for {int(wait)} s")
339
+
340
+ def _legacy_send(self, batch: List[_Queued]) -> None:
341
+ """A server before 2.24 has no /batch: each item goes to its own route,
342
+ one attempt, the status read — a refusal keeps the item like the batch
343
+ path would, instead of the old blocking request that dropped it."""
344
+ keep: List[_Queued] = []
345
+ wait: Optional[float] = None
346
+ for q in batch:
347
+ path, body = _legacy_route(q.item)
348
+ try:
349
+ status, retry_after, resp = self._http.post_raw(path, body, timeout=self._o["request_timeout_s"], gzip_body=True)
350
+ except Exception:
351
+ status, retry_after, resp = None, None, None
352
+ if status is not None and 200 <= status < 300:
353
+ with self._lock:
354
+ self._stats["sent"] += 1
355
+ continue
356
+ if status is None or status in _TRANSIENT:
357
+ keep.append(q)
358
+ if status == 429:
359
+ wait = parse_retry_after_s(retry_after, self._now()) or wait
360
+ continue
361
+ self._drop(1, "too_large" if status == 413 else "rejected", f"POST {path} -> HTTP {status}: {json.dumps(resp)[:200]}")
362
+ if wait is not None:
363
+ self._pause(wait)
364
+ if keep:
365
+ self._requeue(keep, 0.0 if wait is not None else None)
366
+
367
+ def _shed_locked(self) -> int:
368
+ """Drop the oldest until the queue fits; returns how many (the caller reports, outside the lock)."""
369
+ n = 0
370
+ while len(self._queue) > self._o["max_queue_items"] or self._queue_bytes > self._o["max_queue_bytes"]:
371
+ q = self._queue.popleft()
372
+ self._queue_bytes -= q.nbytes
373
+ n += 1
374
+ return n
375
+
376
+ def _drop_queue_full(self, n: int) -> None:
377
+ self._drop(n, "queue_full", f"the delivery queue is full ({self._o['max_queue_items']} items / {self._o['max_queue_bytes'] // 1048576} MB); the {n} oldest were dropped")
378
+
379
+ def _envelope(self, batch: List[_Queued]) -> Dict[str, Any]:
380
+ items = [q.item for q in batch]
381
+ with self._lock:
382
+ if not self._unreported["count"]:
383
+ return {"items": items}
384
+ return {"items": items, "dropped": {"count": self._unreported["count"], "by_reason": dict(self._unreported["by_reason"])}}
385
+
386
+ def _reported(self) -> None:
387
+ with self._lock:
388
+ self._unreported = {"count": 0, "by_reason": {}}
389
+
390
+ def _drop(self, n: int, reason: str, message: str) -> None:
391
+ with self._lock:
392
+ self._stats["dropped"] += n
393
+ self._stats["dropped_by_reason"][reason] = self._stats["dropped_by_reason"].get(reason, 0) + n
394
+ self._unreported["count"] += n
395
+ self._unreported["by_reason"][reason] = self._unreported["by_reason"].get(reason, 0) + n
396
+ warned = self._warned_drop
397
+ self._warned_drop = True
398
+ err = RuntimeError(f"trodo-python: {message}")
399
+ if self._on_error:
400
+ try:
401
+ self._on_error(err)
402
+ except Exception:
403
+ pass
404
+ return
405
+ if warned:
406
+ self._log(message)
407
+ return
408
+ sys.stderr.write(
409
+ f"[trodo-python] {message}\n"
410
+ "[trodo-python] Traces were dropped. Pass on_error to trodo.init(on_error=...) to handle these yourself; "
411
+ "further drops in this process stay quiet.\n"
412
+ )
413
+
414
+ # ── immediate ─────────────────────────────────────────────────────────────
415
+
416
+ def _send_immediate(self, item: Item) -> None:
417
+ deadline = self._now() + self._o["immediate_retry_s"]
418
+ attempt = 0
419
+ q = _Queued(item, 0, self._now())
420
+ while True:
421
+ attempt += 1
422
+ q.attempts = attempt
423
+ try:
424
+ status, retry_after, body = self._http.post_raw(
425
+ endpoints.BATCH, self._envelope([q]),
426
+ timeout=min(self._o["request_timeout_s"], max(1.0, deadline - self._now())), gzip_body=True,
427
+ )
428
+ except Exception:
429
+ status, retry_after, body = None, None, None
430
+ if status == 200 and isinstance(body, dict):
431
+ self._reported()
432
+ results = body.get("results") or []
433
+ r = results[0] if results and isinstance(results[0], dict) else {"status": 200}
434
+ st = int(r.get("status", 200))
435
+ if 200 <= st < 300:
436
+ with self._lock:
437
+ self._stats["sent"] += 1
438
+ return
439
+ if not (st == 429 or st >= 500 or st == 408):
440
+ self._drop(1, "too_large" if st == 413 else "rejected", f"HTTP {st}: {r.get('error', '')}")
441
+ return
442
+ elif status == 404:
443
+ # No /batch on this server: the item's own route, within the same budget.
444
+ path, body = _legacy_route(item)
445
+ try:
446
+ r_status, r_retry, _ = self._http.post_raw(path, body, timeout=min(self._o["request_timeout_s"], max(1.0, deadline - self._now())), gzip_body=True)
447
+ except Exception:
448
+ r_status, r_retry = None, None
449
+ if r_status is not None and 200 <= r_status < 300:
450
+ with self._lock:
451
+ self._stats["sent"] += 1
452
+ return
453
+ if r_status is not None and r_status not in _TRANSIENT:
454
+ self._drop(1, "rejected", f"POST {path} -> HTTP {r_status}")
455
+ return
456
+ status, retry_after = r_status, r_retry
457
+ elif status is not None and status not in _TRANSIENT:
458
+ self._drop(1, "rejected", f"POST {endpoints.BATCH} -> HTTP {status}")
459
+ return
460
+ if status == 429:
461
+ wait = parse_retry_after_s(retry_after, self._now()) or 2.0
462
+ else:
463
+ wait = _MIN_BACKOFF_S * 2 ** (attempt - 1)
464
+ wait = min(wait, deadline - self._now())
465
+ if wait <= 0:
466
+ self._drop(1, "gave_up", f"an item could not be delivered within {int(self._o['immediate_retry_s'])} s (immediate mode)")
467
+ return
468
+ self._sleep(wait)
469
+
470
+ def _atexit(self) -> None:
471
+ try:
472
+ self.flush(self._o["exit_flush_s"])
473
+ except Exception:
474
+ pass
475
+
476
+ def _log(self, msg: str) -> None:
477
+ if self._debug:
478
+ sys.stderr.write(f"[trodo-python] {msg}\n")
479
+
480
+
481
+ def _legacy_route(item: Item):
482
+ """The pre-2.24 route and body for one item."""
483
+ if "run" in item:
484
+ return endpoints.RUNS_INGEST, item
485
+ if "feedback" in item:
486
+ return f"{endpoints.RUNS_BASE}/{item['run_id']}/feedback", item["feedback"]
487
+ if "run_id" in item:
488
+ return f"{endpoints.RUNS_BASE}/{item['run_id']}/spans", {"spans": item["spans"]}
489
+ return endpoints.SPANS_APPEND, {"spans": item["spans"]}
490
+
491
+
492
+ def _size_of(item: Any) -> int:
493
+ try:
494
+ return len(json.dumps(item, default=str).encode("utf-8"))
495
+ except Exception:
496
+ return 0
497
+
498
+
499
+ def gzip_json(body: Any) -> bytes:
500
+ return gzip.compress(json.dumps(body, default=str).encode("utf-8"))
@@ -14,7 +14,11 @@ from datetime import datetime, timezone
14
14
  from typing import Any, Callable, Dict, Iterable, List, Optional
15
15
 
16
16
  from .context import get_active_context
17
- from .prompt_trace import prompt_attributes
17
+ from .prompt_trace import (
18
+ prompt_attributes,
19
+ prompt_ref_from_fingerprint,
20
+ remember_prompt,
21
+ )
18
22
  from .processor import TrodoSpan, TrodoSpanProcessor
19
23
 
20
24
  # Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
@@ -313,7 +317,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
313
317
  if temperature is None:
314
318
  temperature = attrs.get("ai.settings.temperature")
315
319
 
316
- return TrodoSpan(
320
+ trodo_span = TrodoSpan(
317
321
  span_id=span_id,
318
322
  run_id=run_id,
319
323
  parent_span_id=parent_span_id,
@@ -338,6 +342,26 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
338
342
  output=completion,
339
343
  attributes=attrs or None,
340
344
  )
345
+ # Exact-match prompt link for auto-instrumented model calls. compile()
346
+ # before the run leaves no run context and no carrier on this serialised
347
+ # input, so compare the input's content fingerprint with what compile()
348
+ # registered. Overrides the on_start "most recent in this run" guess, which
349
+ # an exact match is strictly better than. No match changes nothing.
350
+ if kind == "llm" and prompt:
351
+ ref = prompt_ref_from_fingerprint(prompt)
352
+ if ref:
353
+ trodo_span.attributes = {**(trodo_span.attributes or {}), **prompt_attributes(ref)}
354
+ state = _span_prompt_state.pop(id(otel_span), None)
355
+ if state is None and ctx is not None:
356
+ state = getattr(ctx, "prompt_state", None)
357
+ remember_prompt(state, ref)
358
+ return trodo_span
359
+
360
+
361
+ # Run prompt state captured at on_start (contextvars are often gone by on_end),
362
+ # keyed by id(span) and bounded so spans that never end cannot leak.
363
+ _SPAN_STATE_MAX = 1024
364
+ _span_prompt_state: "Dict[int, Any]" = {}
341
365
 
342
366
 
343
367
  # Subclass the REAL SpanProcessor when the sdk is importable (it always is on
@@ -385,11 +409,16 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
385
409
  current = state.get("current") if isinstance(state, dict) else None
386
410
  for k, v in prompt_attributes(current).items():
387
411
  set_attr(k, v)
412
+ if isinstance(state, dict):
413
+ if len(_span_prompt_state) >= _SPAN_STATE_MAX:
414
+ _span_prompt_state.pop(next(iter(_span_prompt_state)), None)
415
+ _span_prompt_state[id(span)] = state
388
416
  except Exception:
389
417
  pass # never break user code
390
418
 
391
419
  def on_end(self, span: Any) -> None:
392
420
  trodo_span = otel_span_to_trodo_span(span)
421
+ _span_prompt_state.pop(id(span), None)
393
422
  if trodo_span is not None:
394
423
  self._processor.enqueue_span(trodo_span)
395
424
 
@@ -401,6 +430,48 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
401
430
  return True
402
431
 
403
432
 
433
+ class OtlpPromptLinker(_SpanProcessorBase): # type: ignore[valid-type,misc]
434
+ """OTLP mode: link provider spans to a managed prompt by exact fingerprint.
435
+
436
+ In OTLP mode finished spans go straight to the exporter, so there is no
437
+ TrodoSpan to enrich. ``on_ending`` (opentelemetry-sdk >= 1.43) is the last
438
+ point where the span is still writable. Register this BEFORE the batch
439
+ processor. On older SDKs ``on_ending`` is never called and this is inert.
440
+ """
441
+
442
+ def __init__(self) -> None:
443
+ try:
444
+ super().__init__()
445
+ except TypeError:
446
+ pass
447
+
448
+ def on_start(self, span: Any, parent_context: Any = None) -> None:
449
+ return None
450
+
451
+ def on_ending(self, span: Any) -> None:
452
+ try:
453
+ attrs = dict(getattr(span, "attributes", None) or {})
454
+ if _infer_kind(attrs) != "llm":
455
+ return
456
+ inp = _extract_input(attrs)
457
+ ref = prompt_ref_from_fingerprint(inp) if inp else None
458
+ if not ref:
459
+ return
460
+ for k, v in prompt_attributes(ref).items():
461
+ span.set_attribute(k, v)
462
+ except Exception:
463
+ pass # never break the export pipeline
464
+
465
+ def on_end(self, span: Any) -> None:
466
+ return None
467
+
468
+ def shutdown(self) -> None:
469
+ return None
470
+
471
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
472
+ return True
473
+
474
+
404
475
  def _instrument(module_name: str, *preferred: str) -> None:
405
476
  """Import ``module_name`` and call ``instrument()`` on its Instrumentor.
406
477
 
trodo/otel/processor.py CHANGED
@@ -107,8 +107,11 @@ class TrodoSpanProcessor:
107
107
  http_client: Any,
108
108
  flush_interval_s: float = 5.0,
109
109
  max_spans_per_run: int = 1000,
110
+ sender: Any = None,
110
111
  ) -> None:
111
112
  self._http = http_client
113
+ # The background sender (2.24). Without one, sends block as before 2.24.
114
+ self._sender = sender
112
115
  self._flush_interval = flush_interval_s
113
116
  self._max_spans = max_spans_per_run
114
117
  self._pending: Dict[str, List[TrodoSpan]] = {}
@@ -142,10 +145,16 @@ class TrodoSpanProcessor:
142
145
  if len(bucket) < self._max_spans:
143
146
  bucket.append(span)
144
147
  if joined:
145
- try:
146
- self._http.post_spans_append(span.run_id, [span.to_dict()])
147
- except Exception:
148
- pass
148
+ self._send_spans(span.run_id, [span.to_dict()])
149
+
150
+ def _send_spans(self, run_id: str, spans: List[Dict[str, Any]]) -> None:
151
+ if self._sender is not None:
152
+ self._sender.enqueue({"run_id": run_id, "spans": spans})
153
+ return
154
+ try:
155
+ self._http.post_spans_append(run_id, spans)
156
+ except Exception:
157
+ pass
149
158
 
150
159
  def get_pending(self, run_id: str) -> List[TrodoSpan]:
151
160
  with self._lock:
@@ -158,6 +167,10 @@ class TrodoSpanProcessor:
158
167
  payload: Dict[str, Any] = {"run": run.to_dict()}
159
168
  if spans:
160
169
  payload["spans"] = [s.to_dict() for s in spans]
170
+ if self._sender is not None:
171
+ # Queued: the agent's caller never waits on the network (2.24).
172
+ self._sender.enqueue(payload)
173
+ return
161
174
  try:
162
175
  self._http.post_run_ingest(payload)
163
176
  except Exception:
@@ -186,10 +199,7 @@ class TrodoSpanProcessor:
186
199
  """Stream spans for a long-running or joined run without finalising."""
187
200
  if not spans:
188
201
  return
189
- try:
190
- self._http.post_spans_append(run_id, [s.to_dict() for s in spans])
191
- except Exception:
192
- pass
202
+ self._send_spans(run_id, [s.to_dict() for s in spans])
193
203
 
194
204
  def force_flush(self) -> None:
195
205
  """Flush any buffered spans that don't have a run finalise yet."""
@@ -199,12 +209,7 @@ class TrodoSpanProcessor:
199
209
  for run_id, spans in pending:
200
210
  if not spans:
201
211
  continue
202
- try:
203
- self._http.post_spans_append(
204
- run_id, [s.to_dict() for s in spans]
205
- )
206
- except Exception:
207
- pass
212
+ self._send_spans(run_id, [s.to_dict() for s in spans])
208
213
 
209
214
  def shutdown(self) -> None:
210
215
  self._shutdown = True
@@ -9,13 +9,25 @@ moved to a different version.
9
9
  The link is best-effort and never raises: if there is no active run context
10
10
  (the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
11
11
 
12
+ A third route covers compile-before-run followed by a model call made by an
13
+ AUTO-INSTRUMENTED provider SDK (no run context at compile time, no carrier on
14
+ the span's serialised input): compile() registers a sha256 fingerprint of the
15
+ rendered (role, text) list in a small bounded registry, and a model span whose
16
+ input normalises to the same fingerprint is linked. Exact match or nothing --
17
+ never a "last compiled prompt wins" guess. Only hashes are retained, never
18
+ message text. Providers that rewrite the messages will not match, by design.
19
+
12
20
  Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
13
21
  """
14
22
 
15
23
  from __future__ import annotations
16
24
 
25
+ import hashlib
26
+ import json
27
+ import threading
28
+ import time
17
29
  from collections import OrderedDict
18
- from typing import Any, Dict, List, Optional
30
+ from typing import Any, Dict, List, Optional, Tuple
19
31
 
20
32
  from .context import get_active_context
21
33
 
@@ -176,3 +188,175 @@ def _stamp_active_otel_span(ref: PromptRef) -> None:
176
188
  span.set_attribute(k, v)
177
189
  except Exception: # noqa: BLE001
178
190
  pass # not in OTLP mode / opentelemetry not installed — no-op
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Content-fingerprint registry
195
+ # ---------------------------------------------------------------------------
196
+
197
+ #: Max fingerprints kept process-wide (LRU). Keep in sync with the Node SDK.
198
+ FINGERPRINT_MAX_ENTRIES = 256
199
+ #: How long a fingerprint stays matchable. Keep in sync with the Node SDK.
200
+ FINGERPRINT_TTL_SECONDS = 10 * 60
201
+
202
+ # fingerprint -> (ref, registered_at). Insertion order == age order.
203
+ _fingerprints: "OrderedDict[str, Tuple[PromptRef, float]]" = OrderedDict()
204
+ _fp_lock = threading.Lock()
205
+
206
+
207
+ def _now() -> float:
208
+ return time.time()
209
+
210
+
211
+ def _text_of_content(content: Any) -> Optional[str]:
212
+ if isinstance(content, str):
213
+ return content
214
+ if not isinstance(content, list):
215
+ return None
216
+ out = ""
217
+ for part in content:
218
+ if isinstance(part, str):
219
+ out += part
220
+ elif isinstance(part, dict):
221
+ # {type:'text', text} (OpenAI/Anthropic/compiled), {content} (OTel GenAI parts)
222
+ if isinstance(part.get("text"), str):
223
+ out += part["text"]
224
+ elif part.get("type") == "text" and isinstance(part.get("content"), str):
225
+ out += part["content"]
226
+ return out
227
+
228
+
229
+ def _normalise_message(m: Any) -> Optional[Tuple[str, str]]:
230
+ if not isinstance(m, dict):
231
+ return None
232
+ # OpenInference indexed messages nest as {message: {role, content}}.
233
+ if isinstance(m.get("message"), dict):
234
+ m = m["message"]
235
+ role = m.get("role")
236
+ if not isinstance(role, str):
237
+ return None
238
+ content = m.get("content")
239
+ text = _text_of_content(content if content is not None else m.get("parts"))
240
+ if text is None:
241
+ return None
242
+ role = role.lower()
243
+ return ("system" if role == "developer" else role, text)
244
+
245
+
246
+ def normalise_messages(value: Any) -> Optional[List[Tuple[str, str]]]:
247
+ """Normalise a provider request's messages into a list of (role, text).
248
+
249
+ Handles an OpenAI-style list, Anthropic ``{system, messages}`` (system
250
+ becomes a leading 'system' message), a CompiledPrompt, and the JSON string
251
+ form provider instrumentations record. None when not message-shaped.
252
+ """
253
+ v = value
254
+ if hasattr(v, "messages") and not isinstance(v, (dict, list, str)):
255
+ v = getattr(v, "messages") # CompiledPrompt
256
+ if isinstance(v, (bytes, bytearray)):
257
+ return None
258
+ if isinstance(v, str):
259
+ t = v.strip()
260
+ if not t or t[0] not in "[{":
261
+ return None
262
+ try:
263
+ v = json.loads(t)
264
+ except Exception: # noqa: BLE001
265
+ return None
266
+ out: List[Tuple[str, str]] = []
267
+ if isinstance(v, dict):
268
+ items = v.get("messages")
269
+ if not isinstance(items, list):
270
+ return None
271
+ if v.get("system") is not None:
272
+ sys_text = _text_of_content(v["system"])
273
+ if sys_text is not None:
274
+ out.append(("system", sys_text))
275
+ elif isinstance(v, list):
276
+ items = v
277
+ else:
278
+ return None
279
+ for m in items:
280
+ n = _normalise_message(m)
281
+ if n is None:
282
+ return None # any non-message item: not a messages list, no guessing
283
+ out.append(n)
284
+ return out or None
285
+
286
+
287
+ def fingerprint_messages(value: Any) -> Optional[str]:
288
+ """sha256 over the normalised (role, text) list; length-prefixed."""
289
+ norm = normalise_messages(value)
290
+ if norm is None:
291
+ return None
292
+ h = hashlib.sha256()
293
+ for role, text in norm:
294
+ rb = text.encode("utf-8", "surrogatepass")
295
+ h.update(f"{len(role)}:{role}|{len(rb)}:".encode("utf-8"))
296
+ h.update(rb)
297
+ h.update(b";")
298
+ return h.hexdigest()
299
+
300
+
301
+ def _prune(now: float) -> None:
302
+ while _fingerprints:
303
+ key = next(iter(_fingerprints))
304
+ if now - _fingerprints[key][1] > FINGERPRINT_TTL_SECONDS:
305
+ del _fingerprints[key]
306
+ else:
307
+ break # insertion order == age order, so the rest are younger
308
+
309
+
310
+ def register_compiled_messages(messages: Any, ref: PromptRef) -> None:
311
+ """Register the rendered messages of a just-compiled prompt.
312
+
313
+ Best-effort, never raises, retains only a hash. Two prompt versions that
314
+ render byte-identical content collide on purpose (the messages cannot tell
315
+ them apart); the most recent compile wins.
316
+ """
317
+ try:
318
+ if not ref or not ref.get("name"):
319
+ return
320
+ fp = fingerprint_messages(messages)
321
+ if fp is None:
322
+ return
323
+ now = _now()
324
+ with _fp_lock:
325
+ _prune(now)
326
+ _fingerprints.pop(fp, None)
327
+ _fingerprints[fp] = (ref, now)
328
+ while len(_fingerprints) > FINGERPRINT_MAX_ENTRIES:
329
+ _fingerprints.popitem(last=False)
330
+ except Exception: # noqa: BLE001
331
+ pass # traceability must never break compile
332
+
333
+
334
+ def prompt_ref_from_fingerprint(value: Any) -> Optional[PromptRef]:
335
+ """Exact-match lookup for a provider span's input.
336
+
337
+ Zero cost when nothing has been compiled: the input is never parsed.
338
+ """
339
+ if not _fingerprints or value is None:
340
+ return None
341
+ try:
342
+ with _fp_lock:
343
+ _prune(_now())
344
+ if not _fingerprints:
345
+ return None
346
+ fp = fingerprint_messages(value)
347
+ if fp is None:
348
+ return None
349
+ with _fp_lock:
350
+ hit = _fingerprints.get(fp)
351
+ return hit[0] if hit else None
352
+ except Exception: # noqa: BLE001
353
+ return None
354
+
355
+
356
+ def _reset_fingerprints_for_tests() -> None:
357
+ with _fp_lock:
358
+ _fingerprints.clear()
359
+
360
+
361
+ def _fingerprint_count_for_tests() -> int:
362
+ return len(_fingerprints)
trodo/otel/register.py CHANGED
@@ -44,6 +44,12 @@ _registered: Optional[RegisterOtelResult] = None
44
44
  _otlp_processor: Optional[Any] = None
45
45
 
46
46
 
47
+ def _prompt_linker() -> Any:
48
+ from .auto_instrument import OtlpPromptLinker
49
+
50
+ return OtlpPromptLinker()
51
+
52
+
47
53
  def flush_otlp_pipeline() -> None:
48
54
  """Force-flush the 'otlp'-mode export pipeline. No-op in 'trodo' mode."""
49
55
  if _otlp_processor is not None:
@@ -172,6 +178,7 @@ def _setup_otlp_mode(
172
178
  if existing_class not in ("ProxyTracerProvider", "NoOpTracerProvider"):
173
179
  # User's pipeline owns the provider — just attach our processor.
174
180
  if hasattr(existing, "add_span_processor"):
181
+ existing.add_span_processor(_prompt_linker())
175
182
  existing.add_span_processor(batch_processor)
176
183
  # Auto-instrumentations are presumably already wired by the existing
177
184
  # pipeline. We still set the transport so wrap_agent dispatches.
@@ -189,6 +196,7 @@ def _setup_otlp_mode(
189
196
  "trodo.sdk.mode": "otlp",
190
197
  })
191
198
  provider = TracerProvider(resource=resource)
199
+ provider.add_span_processor(_prompt_linker())
192
200
  provider.add_span_processor(batch_processor)
193
201
  otel_trace.set_tracer_provider(provider)
194
202
 
trodo/otel/wrap_agent.py CHANGED
@@ -611,6 +611,17 @@ class wrap_agent:
611
611
  return self._exit_otel(exc_type, exc, tb)
612
612
  return self._exit_trodo(exc_type, exc, tb)
613
613
 
614
+ # ``async with trodo.wrap_agent(...)`` (2.24). Entering and leaving a run
615
+ # is bookkeeping in this process — contextvars and a queue hand-off — so
616
+ # the async form is the sync one; the event loop is never blocked, because
617
+ # the send happens on the sender's thread (or, in immediate mode, is the
618
+ # one blocking call a serverless handler wants before it returns).
619
+ async def __aenter__(self) -> RunHandle:
620
+ return self.__enter__()
621
+
622
+ async def __aexit__(self, exc_type, exc, tb) -> None:
623
+ return self.__exit__(exc_type, exc, tb)
624
+
614
625
  # ----------------------------------------------------------------------
615
626
  # Legacy 'trodo' transport — TrodoSpanProcessor + Trodo HTTP API path.
616
627
  # ----------------------------------------------------------------------
trodo/prompts/types.py CHANGED
@@ -95,6 +95,7 @@ class ManagedPrompt:
95
95
  try:
96
96
  from ..otel.prompt_trace import (
97
97
  record_compiled_prompt,
98
+ register_compiled_messages,
98
99
  tag_carrier_with_prompt,
99
100
  )
100
101
 
@@ -113,6 +114,11 @@ class ManagedPrompt:
113
114
  # since callers pass one or the other.
114
115
  tag_carrier_with_prompt(compiled, ref)
115
116
  tag_carrier_with_prompt(getattr(compiled, "messages", None), ref)
117
+ # (3) a content fingerprint, so an auto-instrumented provider
118
+ # span handed exactly these messages (compiled before the
119
+ # run; no carrier on the serialised input) links by exact
120
+ # match.
121
+ register_compiled_messages(getattr(compiled, "messages", None), ref)
116
122
  except Exception: # noqa: BLE001
117
123
  pass # traceability must never break compile
118
124
  return compiled
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.23.4
3
+ Version: 2.25.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -1,14 +1,17 @@
1
- trodo/__init__.py,sha256=SbEfXeTk-1B_rMcbO2kCU66Ock4p7pmiEBLQGttMaP4,26928
2
- trodo/client.py,sha256=9UYaHZtWPMYdlG2xWVf77PQJ9DfFgN-zobTh60i8_Cc,22453
1
+ trodo/__init__.py,sha256=ftnDcQvvOi9VnJoynkzzZCNwqHRhC7pbXS836bTwizo,27913
2
+ trodo/_version.py,sha256=50fFgmf3lAeDxqh-LvVujTqoWvZoG7fTVAY5gKvc-yQ,23
3
+ trodo/client.py,sha256=6k6HeKrGV2720kK17--u6BjbhAUFZNUzpd9yEYiaQOs,24137
3
4
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
5
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
5
6
  trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
7
  trodo/api/_retry.py,sha256=mHhuzfe3QuYa6j9BqRYVClvnYH0o3WkXXngmSwqWfgY,1214
7
8
  trodo/api/async_client.py,sha256=qF97NbvoYbNhem8uXtPh14EG4JVAhEAzUCYn7busaaA,3609
8
- trodo/api/endpoints.py,sha256=H0Da456mf2IUNZH5bsln69IIUoUhdF4bncPo-WDLZ9I,1433
9
- trodo/api/http_client.py,sha256=wzvxUpY2bR8BvebqbmqeHizVBmw41TmY3WQWVeAzABg,9881
9
+ trodo/api/endpoints.py,sha256=SGgVELcVfwuCrRwlVPW2j0KYkaMtwLFPpdMtGtlqPeA,1574
10
+ trodo/api/http_client.py,sha256=Vnw8l_QyoslCF2lJIxoH2uoUNFAK-8S_YfnWN8f4ZS0,11362
10
11
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
12
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
13
+ trodo/delivery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ trodo/delivery/sender.py,sha256=urO7m6mRMnSEW7_cNAmJ4A6jtDNxuihZHOxCx1jQScI,21348
12
15
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
16
  trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEvm7hE,2827
14
17
  trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
@@ -17,18 +20,18 @@ trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8p
17
20
  trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
18
21
  trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
19
22
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
20
- trodo/otel/auto_instrument.py,sha256=I_F3NCvc0JML2UpX9PaxFM3OGzMSuEYYfxZYTwkQDuo,26224
23
+ trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
21
24
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
22
25
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
23
- trodo/otel/processor.py,sha256=LKlXxP3BeQ7DP8SzYAgXZZOeJ6-6b7e43i19gCUC_0Y,7939
24
- trodo/otel/prompt_trace.py,sha256=d2A6UhlWAgHZLIKtU0NxgGUpHk_z3F2Nm4u4QTAC3tA,7332
25
- trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
26
+ trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
27
+ trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
28
+ trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
26
29
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
27
- trodo/otel/wrap_agent.py,sha256=PiluY8r_yvGtuST45sqYWDbtF0trmKFQRiN0vbZl8Rk,43948
30
+ trodo/otel/wrap_agent.py,sha256=qx2jnuVIMZTv-PpRt-qFWqMWz1YO4jqY5pwgBQirVDo,44519
28
31
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
29
32
  trodo/prompts/compile.py,sha256=XoKDKz6Yaofhrk-87AVW-SnwbiXLvfp161AL2bAFzNY,8802
30
33
  trodo/prompts/template.py,sha256=eGO2ZVNOw6JK7Ffj1UCYDMJFYyX5qQW2POlmFJSQpAo,9975
31
- trodo/prompts/types.py,sha256=OYpaLi1H9pJjeq0gsJo8eb-bWoF8rw2u_7pdukudtqo,5258
34
+ trodo/prompts/types.py,sha256=Mjn6x6f7dI3TIVHSvzM2xs_gchuh5sCwUfBsW-YLVm0,5654
32
35
  trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
36
  trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
34
37
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
@@ -37,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
37
40
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
38
41
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
39
42
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
40
- trodo_python-2.23.4.dist-info/METADATA,sha256=VQP23ftatdvKhd55L_qv-XX60zq7OuTjVKI0c6Kw9dI,25308
41
- trodo_python-2.23.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
42
- trodo_python-2.23.4.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
43
- trodo_python-2.23.4.dist-info/RECORD,,
43
+ trodo_python-2.25.0.dist-info/METADATA,sha256=_e2LmqFYjaZQdhh6Nr-Uj9wr_f88ZYLZDlD17N431as,25308
44
+ trodo_python-2.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
45
+ trodo_python-2.25.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-2.25.0.dist-info/RECORD,,