trodo-python 2.23.4__py3-none-any.whl → 2.24.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.24.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.24.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"))
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
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
  # ----------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.23.4
3
+ Version: 2.24.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=ahX3Z2VcSDifpb6xg4RNUJzLe64Oka2TY2vNLey2f8M,27913
2
+ trodo/_version.py,sha256=fSs1-zmlRM8V2BkYDf5P5v8U0xEG-QAN-jPcvTDYQLU,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
@@ -20,11 +23,11 @@ trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
20
23
  trodo/otel/auto_instrument.py,sha256=I_F3NCvc0JML2UpX9PaxFM3OGzMSuEYYfxZYTwkQDuo,26224
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
26
+ trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
24
27
  trodo/otel/prompt_trace.py,sha256=d2A6UhlWAgHZLIKtU0NxgGUpHk_z3F2Nm4u4QTAC3tA,7332
25
28
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
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
@@ -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.24.0.dist-info/METADATA,sha256=zDBPYuJWTElYQQgr1S8qeDzl1chRfwbNtESdc-PFe1M,25308
44
+ trodo_python-2.24.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
45
+ trodo_python-2.24.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-2.24.0.dist-info/RECORD,,