trodo-python 3.0.1__py3-none-any.whl → 3.0.3__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
@@ -180,7 +180,7 @@ def init(
180
180
  are present, otherwise 'background'. Default.
181
181
  ``delivery_options``: flush_interval_s (1), flush_at (50), max_batch_bytes
182
182
  (4 MB), max_queue_items (10,000), max_queue_bytes (64 MB), max_retry_s
183
- (600), exit_flush_s (5).
183
+ (600), exit_flush_s (5), max_in_flight (3).
184
184
 
185
185
  ``disable_instrumentations`` turns OFF specific instrumentors by id
186
186
  (e.g. ``['openai']`` when LangChain is your primary framework, to avoid
trodo/_version.py CHANGED
@@ -1 +1 @@
1
- SDK_VERSION = "3.0.1"
1
+ SDK_VERSION = "3.0.3"
trodo/delivery/sender.py CHANGED
@@ -5,8 +5,12 @@ retry and Retry-After — on the caller's thread before handing the agent's
5
5
  result back: up to ~90 s added to one agent call when a team was over its
6
6
  rate ceiling, the event loop frozen in an asyncio app, and then the trace was
7
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.
8
+ the agent returns at once. Daemon threads drain the queue to
9
+ ``POST /api/sdk/batch`` every second or 50 items, gzipped, up to
10
+ ``max_in_flight`` (3) requests at a time (3.0.3; one at a time before, which
11
+ let a script that exits after a burst deliver only ~150 runs in its 5 s exit
12
+ window). Feedback never travels while its run is in flight in another
13
+ request: the server refuses feedback for a run it has not stored yet.
10
14
 
11
15
  The rules match what Langfuse, LangSmith, Braintrust, OpenTelemetry, Sentry
12
16
  and PostHog have converged on (1 Oct 2026 survey):
@@ -14,12 +18,18 @@ and PostHog have converged on (1 Oct 2026 survey):
14
18
  the traces being debugged right now survive; drops are counted and
15
19
  reported to the server with the next batch.
16
20
  - 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
21
+ Retry-After (a client over its limit delivers late, not never). A rate
22
+ 429 never counts toward giving up (3.0.2): the server answered, it is
23
+ only pacing. 5xx, 408, network errors and an exhausted allowance back off
24
+ (1 s → 60 s) for up to 10 minutes per item, then it is dropped as
25
+ ``gave_up``. Once a 429 names the team's ceiling (units per minute), a
26
+ batch carries at most that many units, so each one is admissible; 413 splits the batch, a single
20
27
  oversized item is dropped as ``too_large``; any other 4xx is a bad item:
21
28
  dropped as ``rejected``, reported once.
22
- - exit: ``atexit`` flushes (bounded by ``exit_flush_s``).
29
+ - exit: ``atexit`` flushes (bounded by ``exit_flush_s``) and says on stderr
30
+ how many traces were still undelivered, and why, if any were.
31
+ - the server holds over-rate items itself (202 ``queued``) and stores them
32
+ at the team's rate; the sender says so on stderr once per 10 minutes.
23
33
  - ``immediate`` mode for platforms that freeze between requests (Lambda,
24
34
  Cloud Functions, Cloud Run, Vercel): the item is sent now, with short
25
35
  retries, on the caller's thread. ``auto`` picks it from the platform's
@@ -47,7 +57,7 @@ DEFAULTS = dict(
47
57
  flush_interval_s=1.0, flush_at=50, max_batch_bytes=4 * 1024 * 1024,
48
58
  max_queue_items=10_000, max_queue_bytes=64 * 1024 * 1024,
49
59
  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,
60
+ exit_flush_s=5.0, request_timeout_s=20.0, max_in_flight=3,
51
61
  )
52
62
  _MIN_BACKOFF_S = 1.0
53
63
  _MAX_BACKOFF_S = 60.0
@@ -124,7 +134,11 @@ class Sender:
124
134
  self._stats = {"sent": 0, "dropped": 0, "dropped_by_reason": {}}
125
135
  self._unreported = {"count": 0, "by_reason": {}}
126
136
  self._warned_drop = False
127
- self._thread: Optional[threading.Thread] = None
137
+ self._unit_cap: Optional[int] = None # the team's ceiling, once a 429 names it
138
+ self._last_rate: Optional[Dict[str, Any]] = None
139
+ self._warned_paced_at = 0.0
140
+ self._threads: List[threading.Thread] = []
141
+ self._runs_in_flight: Dict[str, int] = {} # run_id -> requests carrying it
128
142
  if self.mode == "background" and register_atexit:
129
143
  atexit.register(self._atexit)
130
144
 
@@ -174,11 +188,13 @@ class Sender:
174
188
  with self._lock:
175
189
  self._stopped = True
176
190
  left = len(self._queue)
191
+ spans_left = sum(_units_of(q.item) for q in self._queue)
177
192
  self._queue.clear()
178
193
  self._queue_bytes = 0
179
194
  self._wake.notify_all()
180
195
  if left:
181
196
  self._drop(left, "shutdown", f"{left} items were still queued at shutdown")
197
+ self._warn_undelivered(left, spans_left)
182
198
 
183
199
  def stats(self) -> Dict[str, Any]:
184
200
  with self._lock:
@@ -192,9 +208,11 @@ class Sender:
192
208
  # ── background thread ─────────────────────────────────────────────────────
193
209
 
194
210
  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()
211
+ self._threads = [t for t in self._threads if t.is_alive()]
212
+ while len(self._threads) < max(1, int(self._o["max_in_flight"])):
213
+ t = threading.Thread(target=self._run, name=f"trodo-sender-{len(self._threads) + 1}", daemon=True)
214
+ self._threads.append(t)
215
+ t.start()
198
216
 
199
217
  def _run(self) -> None:
200
218
  interval = self._o["flush_interval_s"]
@@ -218,32 +236,51 @@ class Sender:
218
236
  break
219
237
  self._wake.wait(timeout=interval - age)
220
238
  batch = self._take_batch_locked()
239
+ if not batch:
240
+ # The head is feedback whose run is in flight: wait for it.
241
+ self._wake.wait(timeout=interval)
242
+ continue
243
+ runs = _run_ids(batch)
244
+ for r in runs:
245
+ self._runs_in_flight[r] = self._runs_in_flight.get(r, 0) + 1
221
246
  self._in_flight += 1
222
247
  try:
223
248
  self._send_batch(batch)
224
249
  finally:
225
250
  with self._lock:
226
251
  self._in_flight -= 1
252
+ for r in runs:
253
+ left = self._runs_in_flight.get(r, 1) - 1
254
+ if left > 0:
255
+ self._runs_in_flight[r] = left
256
+ else:
257
+ self._runs_in_flight.pop(r, None)
227
258
  self._wake.notify_all()
228
259
 
229
260
  def _take_batch_locked(self) -> List[_Queued]:
230
261
  out: List[_Queued] = []
231
262
  nbytes = 0
263
+ units = 0
264
+ cap = self._unit_cap
232
265
  while self._queue and len(out) < self._o["flush_at"]:
233
266
  nxt = self._queue[0]
234
267
  if out and nbytes + nxt.nbytes > self._o["max_batch_bytes"]:
235
268
  break
269
+ u = _units_of(nxt.item)
270
+ if out and cap and units + u > cap:
271
+ break
272
+ fb = nxt.item.get("run_id") if isinstance(nxt.item, dict) and "feedback" in nxt.item else None
273
+ if fb and fb in self._runs_in_flight:
274
+ break
236
275
  out.append(self._queue.popleft())
237
276
  nbytes += nxt.nbytes
277
+ units += u
238
278
  self._queue_bytes -= nbytes
239
279
  return out
240
280
 
241
281
  def _send_batch(self, batch: List[_Queued]) -> None:
242
- t = self._now()
243
282
  for q in batch:
244
283
  q.attempts += 1
245
- if q.first_tried_at is None:
246
- q.first_tried_at = t
247
284
  try:
248
285
  status, retry_after, body = self._http.post_raw(
249
286
  endpoints.BATCH, self._envelope(batch), timeout=self._o["request_timeout_s"], gzip_body=True,
@@ -257,8 +294,9 @@ class Sender:
257
294
  self._settle(batch, body)
258
295
  return
259
296
  if status == 429:
297
+ paced = self._note_rate(body if isinstance(body, dict) else {}, retry_after)
260
298
  self._pause(parse_retry_after_s(retry_after, self._now()))
261
- self._requeue(batch, 0.0)
299
+ self._requeue(batch, 0.0, paced=paced)
262
300
  return
263
301
  if status == 413:
264
302
  self._split(batch)
@@ -273,16 +311,23 @@ class Sender:
273
311
 
274
312
  def _settle(self, batch: List[_Queued], body: Dict[str, Any]) -> None:
275
313
  keep: List[_Queued] = []
314
+ paced: List[_Queued] = []
276
315
  rejected = 0
316
+ queued = 0
277
317
  first_error = None
278
318
  results = body["results"]
279
319
  for i, q in enumerate(batch):
280
320
  r = results[i] if i < len(results) and isinstance(results[i], dict) else {"status": 500}
281
321
  st = int(r.get("status", 500))
282
322
  if 200 <= st < 300:
323
+ if r.get("queued"):
324
+ queued += 1
283
325
  with self._lock:
284
326
  self._stats["sent"] += 1
285
327
  continue
328
+ if st == 429 and self._note_rate(r, None):
329
+ paced.append(q)
330
+ continue
286
331
  if st == 429 or st >= 500 or st == 408:
287
332
  keep.append(q)
288
333
  continue
@@ -293,17 +338,60 @@ class Sender:
293
338
  first_error = first_error or f"HTTP {st}: {r.get('error', '')}"
294
339
  if rejected:
295
340
  self._drop(rejected, "rejected", f"{rejected} item(s) rejected by the server ({first_error})")
341
+ if queued:
342
+ self._warn_paced(body.get("paced") if isinstance(body.get("paced"), dict) else {})
296
343
  ra = body.get("retry_after_ms")
297
344
  if ra:
298
345
  self._pause(float(ra) / 1000.0)
346
+ if paced:
347
+ self._requeue(paced, 0.0, paced=True)
299
348
  if keep:
300
349
  self._requeue(keep, 0.0 if ra else None)
301
350
 
302
- def _requeue(self, batch: List[_Queued], wait_s: Optional[float]) -> None:
351
+ def _note_rate(self, body: Dict[str, Any], retry_after: Optional[str]) -> bool:
352
+ """Is this 429 the rate ceiling (pacing: retry, never give up) rather
353
+ than an exhausted allowance? Learns the team's ceiling from it."""
354
+ ceiling = body.get("ceiling_units_min")
355
+ rate = bool(retry_after) or body.get("retry_after_seconds") is not None or body.get("deferred") or ceiling is not None
356
+ if not rate:
357
+ return False
358
+ try:
359
+ c = int(ceiling) if ceiling is not None else 0
360
+ except (TypeError, ValueError):
361
+ c = 0
362
+ with self._lock:
363
+ if c > 0:
364
+ self._unit_cap = c
365
+ self._last_rate = {"ceiling": c or None, "reason": body.get("reason"), "message": body.get("message")}
366
+ return True
367
+
368
+ def _warn_paced(self, paced: Dict[str, Any]) -> None:
369
+ t = self._now()
370
+ with self._lock:
371
+ if t - self._warned_paced_at < 600:
372
+ return
373
+ self._warned_paced_at = t
374
+ c = paced.get("ceiling_units_min")
375
+ limit = f" ({c} units/min)" if c else ""
376
+ sys.stderr.write(
377
+ f"[trodo-python] This team is sending faster than its rate limit{limit}. Trodo is holding the extra traces "
378
+ "and storing them at that rate, so they will appear late. Contact Trodo for a higher limit.\n"
379
+ )
380
+
381
+ def _requeue(self, batch: List[_Queued], wait_s: Optional[float], paced: bool = False) -> None:
303
382
  t = self._now()
304
383
  alive: List[_Queued] = []
305
384
  for q in batch:
306
- if q.first_tried_at is not None and t - q.first_tried_at > self._o["max_retry_s"]:
385
+ if paced:
386
+ # The server answered and is only pacing: the give-up clock
387
+ # (for outages and refusals) starts over.
388
+ q.first_tried_at = None
389
+ q.attempts = 0
390
+ alive.append(q)
391
+ elif q.first_tried_at is None:
392
+ q.first_tried_at = t
393
+ alive.append(q)
394
+ elif t - q.first_tried_at > self._o["max_retry_s"]:
307
395
  self._drop(1, "gave_up", f"an item was retried for {int(self._o['max_retry_s'])} s without being accepted")
308
396
  else:
309
397
  alive.append(q)
@@ -314,7 +402,7 @@ class Sender:
314
402
  self._queue.appendleft(q)
315
403
  self._queue_bytes += sum(q.nbytes for q in alive)
316
404
  shed = self._shed_locked()
317
- attempts = max(q.attempts for q in alive)
405
+ attempts = max(1, max(q.attempts for q in alive))
318
406
  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
407
  self._not_before = t + backoff
320
408
  if shed:
@@ -342,6 +430,7 @@ class Sender:
342
430
  one attempt, the status read — a refusal keeps the item like the batch
343
431
  path would, instead of the old blocking request that dropped it."""
344
432
  keep: List[_Queued] = []
433
+ paced: List[_Queued] = []
345
434
  wait: Optional[float] = None
346
435
  for q in batch:
347
436
  path, body = _legacy_route(q.item)
@@ -353,14 +442,18 @@ class Sender:
353
442
  with self._lock:
354
443
  self._stats["sent"] += 1
355
444
  continue
445
+ if status == 429 and self._note_rate(resp if isinstance(resp, dict) else {}, retry_after):
446
+ paced.append(q)
447
+ wait = parse_retry_after_s(retry_after, self._now()) or wait
448
+ continue
356
449
  if status is None or status in _TRANSIENT:
357
450
  keep.append(q)
358
- if status == 429:
359
- wait = parse_retry_after_s(retry_after, self._now()) or wait
360
451
  continue
361
452
  self._drop(1, "too_large" if status == 413 else "rejected", f"POST {path} -> HTTP {status}: {json.dumps(resp)[:200]}")
362
453
  if wait is not None:
363
454
  self._pause(wait)
455
+ if paced:
456
+ self._requeue(paced, 0.0, paced=True)
364
457
  if keep:
365
458
  self._requeue(keep, 0.0 if wait is not None else None)
366
459
 
@@ -472,6 +565,27 @@ class Sender:
472
565
  self.flush(self._o["exit_flush_s"])
473
566
  except Exception:
474
567
  pass
568
+ with self._lock:
569
+ left = len(self._queue) + self._in_flight
570
+ units = sum(_units_of(q.item) for q in self._queue)
571
+ if left:
572
+ self._warn_undelivered(left, units)
573
+
574
+ def _warn_undelivered(self, items: int, units: int) -> None:
575
+ """Always on stderr, whatever was said before: data is about to be lost."""
576
+ with self._lock:
577
+ rate = self._last_rate if self._paused_until and self._paused_until > self._now() else None
578
+ why = ""
579
+ if rate and rate.get("message"):
580
+ why = f" Trodo said: {rate['message']}"
581
+ elif rate:
582
+ c = rate.get("ceiling")
583
+ why = f" The team is over its rate limit{f' ({c} units/min)' if c else ''} and Trodo could not hold more."
584
+ sys.stderr.write(
585
+ f"[trodo-python] {items} trace item(s), {units} units, were NOT delivered before the process exited "
586
+ f"(waited {self._o['exit_flush_s']:g} s).{why} Call trodo.flush() before exiting (it waits until they are "
587
+ "delivered), or raise delivery_options exit_flush_s.\n"
588
+ )
475
589
 
476
590
  def _log(self, msg: str) -> None:
477
591
  if self._debug:
@@ -489,6 +603,24 @@ def _legacy_route(item: Item):
489
603
  return endpoints.SPANS_APPEND, {"spans": item["spans"]}
490
604
 
491
605
 
606
+ def _run_ids(batch: List[_Queued]) -> List[str]:
607
+ """The runs a request carries (the ``run`` items), so their feedback waits for them."""
608
+ out = []
609
+ for q in batch:
610
+ run = q.item.get("run") if isinstance(q.item, dict) else None
611
+ if isinstance(run, dict) and run.get("run_id"):
612
+ out.append(str(run["run_id"]))
613
+ return out
614
+
615
+
616
+ def _units_of(item: Any) -> int:
617
+ """What an item costs against the rate ceiling: one per span, one for the run."""
618
+ if not isinstance(item, dict):
619
+ return 0
620
+ spans = item.get("spans")
621
+ return (len(spans) if isinstance(spans, list) else 0) + (1 if isinstance(item.get("run"), dict) else 0)
622
+
623
+
492
624
  def _size_of(item: Any) -> int:
493
625
  try:
494
626
  return len(json.dumps(item, default=str).encode("utf-8"))
trodo/otel/processor.py CHANGED
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import threading
11
11
  from dataclasses import dataclass, asdict
12
+ import json
12
13
  from typing import Any, Dict, List, Optional, Union
13
14
 
14
15
  # How long end_run waits for the run's queued spans to send before sending the end.
@@ -97,6 +98,37 @@ class TrodoSpan:
97
98
  return {k: v for k, v in asdict(self).items() if v is not None}
98
99
 
99
100
 
101
+ # A run's spans travel in pieces of at most this many bytes (and at most
102
+ # max_spans_per_run spans). The sender drops any single item over its 4 MB
103
+ # request limit, so before this a run of a few hundred LLM spans with long
104
+ # prompts was dropped whole, run row included.
105
+ _CHUNK_BYTES = 1_000_000
106
+
107
+
108
+ def _size(d: Dict[str, Any]) -> int:
109
+ try:
110
+ return len(json.dumps(d, default=str))
111
+ except Exception:
112
+ return 0
113
+
114
+
115
+ def _pieces(dicts: List[Dict[str, Any]], limit: int = _CHUNK_BYTES) -> List[List[Dict[str, Any]]]:
116
+ """Split span dicts into consecutive pieces of at most ``limit`` bytes (one span always fits a piece)."""
117
+ out: List[List[Dict[str, Any]]] = []
118
+ cur: List[Dict[str, Any]] = []
119
+ size = 0
120
+ for d in dicts:
121
+ n = _size(d)
122
+ if cur and size + n > limit:
123
+ out.append(cur)
124
+ cur, size = [], 0
125
+ cur.append(d)
126
+ size += n
127
+ if cur:
128
+ out.append(cur)
129
+ return out
130
+
131
+
100
132
  class _SentSpan:
101
133
  """What the run's totals need of a span already sent ahead: no payloads."""
102
134
 
@@ -135,6 +167,7 @@ class TrodoSpanProcessor:
135
167
  # stores spans before their run arrives) and keeps only what its totals
136
168
  # need. Before this, every span after the 1,000th was dropped.
137
169
  self._sent_ahead: Dict[str, List[_SentSpan]] = {}
170
+ self._bucket_bytes: Dict[str, int] = {}
138
171
  self._lock = threading.Lock()
139
172
  self._shutdown = False
140
173
  # run_ids that are "joined" (owned by a remote service) — their spans
@@ -151,6 +184,7 @@ class TrodoSpanProcessor:
151
184
  self._joined_runs.discard(run_id)
152
185
  self._pending.pop(run_id, None)
153
186
  self._sent_ahead.pop(run_id, None)
187
+ self._bucket_bytes.pop(run_id, None)
154
188
 
155
189
  def enqueue_span(self, span: TrodoSpan) -> None:
156
190
  """Buffer a completed span under its run_id.
@@ -158,19 +192,34 @@ class TrodoSpanProcessor:
158
192
  Joined runs flush immediately (append_spans) instead of batching.
159
193
  """
160
194
  chunk: List[TrodoSpan] = []
195
+ d = span.to_dict()
196
+ n = _size(d)
161
197
  with self._lock:
162
198
  joined = span.run_id in self._joined_runs
163
199
  if not joined:
164
200
  bucket = self._pending.setdefault(span.run_id, [])
201
+ held = self._bucket_bytes.get(span.run_id, 0)
202
+ # Full by bytes: what is held goes ahead and this span starts the next piece.
203
+ if bucket and held + n > _CHUNK_BYTES:
204
+ chunk = bucket
205
+ bucket = []
206
+ self._pending[span.run_id] = bucket
207
+ held = 0
165
208
  bucket.append(span)
209
+ held += n
210
+ # Full by count: everything held goes ahead, this span included.
166
211
  if len(bucket) >= self._max_spans:
167
- chunk = bucket
212
+ chunk = chunk + bucket
168
213
  self._pending[span.run_id] = []
214
+ held = 0
215
+ self._bucket_bytes[span.run_id] = held
216
+ if chunk:
169
217
  self._sent_ahead.setdefault(span.run_id, []).extend(_SentSpan(x) for x in chunk)
170
218
  if joined:
171
- self._send_spans(span.run_id, [span.to_dict()])
219
+ self._send_spans(span.run_id, [d])
172
220
  elif chunk:
173
- self._send_spans(span.run_id, [x.to_dict() for x in chunk])
221
+ for piece in _pieces([x.to_dict() for x in chunk]):
222
+ self._send_spans(span.run_id, piece)
174
223
 
175
224
  def _send_spans(self, run_id: str, spans: List[Dict[str, Any]]) -> None:
176
225
  if self._sender is not None:
@@ -191,9 +240,14 @@ class TrodoSpanProcessor:
191
240
  with self._lock:
192
241
  spans = self._pending.pop(run.run_id, [])
193
242
  self._sent_ahead.pop(run.run_id, None)
243
+ self._bucket_bytes.pop(run.run_id, None)
194
244
  payload: Dict[str, Any] = {"run": run.to_dict()}
195
245
  if spans:
196
- payload["spans"] = [s.to_dict() for s in spans]
246
+ # All but the last piece go ahead; the run travels with the last.
247
+ pieces = _pieces([s.to_dict() for s in spans])
248
+ for piece in pieces[:-1]:
249
+ self._send_spans(run.run_id, piece)
250
+ payload["spans"] = pieces[-1]
197
251
  if self._sender is not None:
198
252
  # Queued: the agent's caller never waits on the network (2.24).
199
253
  self._sender.enqueue(payload)
@@ -226,8 +280,9 @@ class TrodoSpanProcessor:
226
280
  with self._lock:
227
281
  buffered = self._pending.pop(run_id, None) or []
228
282
  self._sent_ahead.pop(run_id, None)
229
- if buffered:
230
- self._send_spans(run_id, [s.to_dict() for s in buffered])
283
+ self._bucket_bytes.pop(run_id, None)
284
+ for piece in _pieces([s.to_dict() for s in buffered]):
285
+ self._send_spans(run_id, piece)
231
286
  if self._sender is not None:
232
287
  self._sender.flush(END_RUN_FLUSH_S)
233
288
  except Exception:
@@ -241,17 +296,18 @@ class TrodoSpanProcessor:
241
296
  """Stream spans for a long-running or joined run without finalising."""
242
297
  if not spans:
243
298
  return
244
- self._send_spans(run_id, [s.to_dict() for s in spans])
299
+ for piece in _pieces([s.to_dict() for s in spans]):
300
+ self._send_spans(run_id, piece)
245
301
 
246
302
  def force_flush(self) -> None:
247
303
  """Flush any buffered spans that don't have a run finalise yet."""
248
304
  with self._lock:
249
305
  pending = list(self._pending.items())
250
306
  self._pending.clear()
307
+ self._bucket_bytes.clear()
251
308
  for run_id, spans in pending:
252
- if not spans:
253
- continue
254
- self._send_spans(run_id, [s.to_dict() for s in spans])
309
+ for piece in _pieces([s.to_dict() for s in spans]):
310
+ self._send_spans(run_id, piece)
255
311
 
256
312
  def shutdown(self) -> None:
257
313
  self._shutdown = True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 3.0.1
3
+ Version: 3.0.3
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,5 +1,5 @@
1
- trodo/__init__.py,sha256=x7zE63FK8CFJ6ATUvsXxeTkvZUaLKXqAJr99I6nnyZc,27099
2
- trodo/_version.py,sha256=_yhb2lUJxj5RzcysqVOZ_qSNlhsiiNpiHeBRAZw1zgc,22
1
+ trodo/__init__.py,sha256=b6NdLadR5zQ_wPNLhHXFGLJgoDNNeaI9iNpeSoG9sdw,27118
2
+ trodo/_version.py,sha256=mHUYAxoRGG4Dl3kgX7AySWcAjRVyw9veTe7GVztx0Zk,22
3
3
  trodo/client.py,sha256=eaZtHMtOI-VLgfoqM_KVWMuP_I5Ivs7VFnR0oU96Yqk,23743
4
4
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
5
5
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
@@ -12,7 +12,7 @@ trodo/api/management.py,sha256=uwhr0ZI1pvguz5-itW1KcgRj0TXM2n4KDa-hGIaBdTA,4023
12
12
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
14
14
  trodo/delivery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- trodo/delivery/sender.py,sha256=urO7m6mRMnSEW7_cNAmJ4A6jtDNxuihZHOxCx1jQScI,21348
15
+ trodo/delivery/sender.py,sha256=l3itziOJ3-DRRir9-zL7LPf36npzM-whZXzkNbwHuW4,27802
16
16
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  trodo/managers/dataset_manager.py,sha256=HTFszK-wKmwjW-aw7xJWr5EKvDpJ_om496MLDr3gelg,13920
18
18
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
@@ -23,7 +23,7 @@ trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
23
23
  trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
24
24
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
25
25
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
26
- trodo/otel/processor.py,sha256=jX1gk7TSkevOw5hnFadYf3U2lNgya5VBuFqpxGjLZHg,10203
26
+ trodo/otel/processor.py,sha256=ezaesBaxgv4-Mx8aHyp3ur41rvHwnQnjCNlPTe8rhjU,12343
27
27
  trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
28
28
  trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
29
29
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
@@ -40,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
40
40
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
41
41
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
42
42
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
43
- trodo_python-3.0.1.dist-info/METADATA,sha256=-8R6RpKklPbGLr-kXa0oW2pwwJ-3RvIsJZ2cn_ff-tA,25307
44
- trodo_python-3.0.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
45
- trodo_python-3.0.1.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
- trodo_python-3.0.1.dist-info/RECORD,,
43
+ trodo_python-3.0.3.dist-info/METADATA,sha256=efWaheZ0wohogmc70u4LjB59TDMqVLKL5wF8w9okSok,25307
44
+ trodo_python-3.0.3.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
45
+ trodo_python-3.0.3.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-3.0.3.dist-info/RECORD,,