trodo-python 3.0.1__tar.gz → 3.0.2__tar.gz
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_python-3.0.1 → trodo_python-3.0.2}/PKG-INFO +1 -1
- {trodo_python-3.0.1 → trodo_python-3.0.2}/pyproject.toml +1 -1
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_long_runs.py +27 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_sender.py +62 -0
- trodo_python-3.0.2/trodo/_version.py +1 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/delivery/sender.py +112 -13
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/processor.py +66 -10
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo_python.egg-info/PKG-INFO +1 -1
- trodo_python-3.0.1/trodo/_version.py +0 -1
- {trodo_python-3.0.1 → trodo_python-3.0.2}/README.md +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/setup.cfg +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_cross_process_session.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_datasets.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_end_run.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_end_run_order.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_error_enrichment.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_framework_deference.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_get_prompt_version_types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_instrumentor_resolution.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_multi_agent_shape.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_processor_methods.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_prompt_fingerprint.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_prompt_trace.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_prompts.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_register_otel.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_start_run.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_user_traits.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/_retry.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/async_client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/endpoints.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/http_client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/api/management.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/auto/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/delivery/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/group_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/people_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/managers/user_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/context.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/helpers.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/register.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/transport.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/prompts/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/prompts/compile.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/prompts/template.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/prompts/types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/queue/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/queue/event_queue.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/session/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/session/server_session.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/session/session_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/user_context.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/util/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo/util/lru.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.2}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -32,3 +32,30 @@ def test_runs_do_not_share_buffers(http):
|
|
|
32
32
|
p.enqueue_span(span(1, "a")); p.enqueue_span(span(2, "b")); p.enqueue_span(span(3, "a"))
|
|
33
33
|
assert [rid for rid, _ in http.spans_append] == ["a"]
|
|
34
34
|
assert len(p.get_pending("b")) == 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def big(i, run="r1", kb=300):
|
|
38
|
+
return TrodoSpan(span_id=f"b{i}", run_id=run, parent_span_id=None, kind="llm", name="chunk",
|
|
39
|
+
input=[{"role": "user", "content": "x" * (kb * 1024)}], output="ok", input_tokens=1, output_tokens=1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_spans_with_long_prompts_go_in_pieces_under_the_request_limit(http):
|
|
43
|
+
# 12 spans of ~300 KB: one item would be ~3.6 MB and the sender would drop the whole run.
|
|
44
|
+
p = TrodoSpanProcessor(http, max_spans_per_run=1000)
|
|
45
|
+
for i in range(12):
|
|
46
|
+
p.enqueue_span(big(i))
|
|
47
|
+
p.ingest_run(TrodoRun(run_id="r1", agent_name="contracts"))
|
|
48
|
+
import json as _json
|
|
49
|
+
sizes = [len(_json.dumps(spans)) for _, spans in http.spans_append] + [len(_json.dumps(http.run_ingest[-1]["spans"]))]
|
|
50
|
+
assert max(sizes) <= 1_000_000 + 320 * 1024, sizes
|
|
51
|
+
sent = [s["span_id"] for _, spans in http.spans_append for s in spans] + [s["span_id"] for s in http.run_ingest[-1]["spans"]]
|
|
52
|
+
assert sorted(sent) == sorted(f"b{i}" for i in range(12)) and len(sent) == len(set(sent))
|
|
53
|
+
assert _aggregate(p.get_pending("r1"))["span_count"] == 0 # all handed over
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_totals_count_spans_sent_ahead_by_size(http):
|
|
57
|
+
p = TrodoSpanProcessor(http, max_spans_per_run=1000)
|
|
58
|
+
for i in range(8):
|
|
59
|
+
p.enqueue_span(big(i, kb=400))
|
|
60
|
+
assert http.spans_append, "a piece went ahead before the run ended"
|
|
61
|
+
assert _aggregate(p.get_pending("r1"))["span_count"] == 8
|
|
@@ -271,3 +271,65 @@ def test_agent_decorator_on_an_async_function_records_the_awaited_result():
|
|
|
271
271
|
item = s.posts[0]["body"]["items"][0]
|
|
272
272
|
assert item["run"]["agent_name"] == "async-bot"
|
|
273
273
|
assert item["run"]["output"] == "HI" # not "<coroutine object ...>"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── Pacing (3.0.2): a rate 429 is the server answering, not failing ────────────
|
|
277
|
+
|
|
278
|
+
def test_rate_paced_items_are_never_given_up_on():
|
|
279
|
+
# 30 rate refusals over well past max_retry_s, then accepted: nothing lost.
|
|
280
|
+
paced = (200, None, {"results": [{"status": 429, "deferred": True, "ceiling_units_min": 600, "retry_after_seconds": 1}], "retry_after_ms": 10})
|
|
281
|
+
s = Server([paced] * 30)
|
|
282
|
+
sender = Sender(s, **fast(max_retry_s=0.05))
|
|
283
|
+
sender.enqueue(run("a"))
|
|
284
|
+
assert wait_for(lambda: sender.stats()["sent"] == 1, timeout=5)
|
|
285
|
+
assert len(s.posts) == 31
|
|
286
|
+
assert sender.stats()["dropped"] == 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_an_exhausted_allowance_still_gives_up(monkeypatch):
|
|
290
|
+
import trodo.delivery.sender as m
|
|
291
|
+
monkeypatch.setattr(m, "_MIN_BACKOFF_S", 0.01)
|
|
292
|
+
monkeypatch.setattr(m, "_MAX_BACKOFF_S", 0.02)
|
|
293
|
+
refused = (200, None, {"results": [{"status": 429, "error": "Unit allowance exhausted", "reason": "limit-reached-upgrade-required"}]})
|
|
294
|
+
s = Server([refused] * 200)
|
|
295
|
+
sender = Sender(s, **fast(max_retry_s=0.2))
|
|
296
|
+
sender.enqueue(run("a"))
|
|
297
|
+
assert wait_for(lambda: sender.stats()["dropped"] == 1, timeout=5)
|
|
298
|
+
assert sender.stats()["dropped_by_reason"] == {"gave_up": 1}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_batches_are_sized_to_the_ceiling_a_429_names():
|
|
302
|
+
# Each run() item is 2 units; a 5-unit ceiling fits two items per batch.
|
|
303
|
+
s = Server([(429, "0.05", {"error": "Rate ceiling exceeded", "ceiling_units_min": 5, "retry_after_seconds": 1})])
|
|
304
|
+
sender = Sender(s, **fast(flush_at=50, flush_interval_s=60.0))
|
|
305
|
+
for rid in "abcdef":
|
|
306
|
+
sender.enqueue(run(rid))
|
|
307
|
+
sender.flush(timeout_s=3)
|
|
308
|
+
assert sender.stats()["sent"] == 6
|
|
309
|
+
assert [len(p["body"]["items"]) for p in s.posts] == [6, 2, 2, 2]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def test_server_held_items_count_as_sent_and_warn_once(capsys):
|
|
313
|
+
held = (200, None, {"results": [{"status": 202, "queued": True}], "paced": {"queued": 1, "refused": 0, "ceiling_units_min": 600}})
|
|
314
|
+
s = Server([held, held])
|
|
315
|
+
sender = Sender(s, **fast())
|
|
316
|
+
sender.enqueue(run("a"))
|
|
317
|
+
sender.flush(timeout_s=2)
|
|
318
|
+
sender.enqueue(run("b"))
|
|
319
|
+
sender.flush(timeout_s=2)
|
|
320
|
+
assert sender.stats()["sent"] == 2 and sender.stats()["dropped"] == 0
|
|
321
|
+
err = capsys.readouterr().err
|
|
322
|
+
assert err.count("faster than its rate limit (600 units/min)") == 1
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def test_exit_says_loudly_what_was_not_delivered(capsys):
|
|
326
|
+
full = (200, None, {"results": [{"status": 429, "deferred": True, "reason": "backlog_full", "ceiling_units_min": 60,
|
|
327
|
+
"retry_after_seconds": 30, "message": "this team's backlog is full"}], "retry_after_ms": 30000})
|
|
328
|
+
s = Server([full])
|
|
329
|
+
sender = Sender(s, **fast(exit_flush_s=0.3))
|
|
330
|
+
sender.enqueue(run("a"))
|
|
331
|
+
assert wait_for(lambda: len(s.posts) == 1)
|
|
332
|
+
sender._atexit()
|
|
333
|
+
err = capsys.readouterr().err
|
|
334
|
+
assert "1 trace item(s), 2 units, were NOT delivered" in err
|
|
335
|
+
assert "Trodo said: this team's backlog is full" in err
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SDK_VERSION = "3.0.2"
|
|
@@ -14,12 +14,18 @@ and PostHog have converged on (1 Oct 2026 survey):
|
|
|
14
14
|
the traces being debugged right now survive; drops are counted and
|
|
15
15
|
reported to the server with the next batch.
|
|
16
16
|
- retries while nobody waits: 429 pauses the whole sender until
|
|
17
|
-
Retry-After (a client over its limit delivers late, not never)
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
Retry-After (a client over its limit delivers late, not never). A rate
|
|
18
|
+
429 never counts toward giving up (3.0.2): the server answered, it is
|
|
19
|
+
only pacing. 5xx, 408, network errors and an exhausted allowance back off
|
|
20
|
+
(1 s → 60 s) for up to 10 minutes per item, then it is dropped as
|
|
21
|
+
``gave_up``. Once a 429 names the team's ceiling (units per minute), a
|
|
22
|
+
batch carries at most that many units, so each one is admissible; 413 splits the batch, a single
|
|
20
23
|
oversized item is dropped as ``too_large``; any other 4xx is a bad item:
|
|
21
24
|
dropped as ``rejected``, reported once.
|
|
22
|
-
- exit: ``atexit`` flushes (bounded by ``exit_flush_s``)
|
|
25
|
+
- exit: ``atexit`` flushes (bounded by ``exit_flush_s``) and says on stderr
|
|
26
|
+
how many traces were still undelivered, and why, if any were.
|
|
27
|
+
- the server holds over-rate items itself (202 ``queued``) and stores them
|
|
28
|
+
at the team's rate; the sender says so on stderr once per 10 minutes.
|
|
23
29
|
- ``immediate`` mode for platforms that freeze between requests (Lambda,
|
|
24
30
|
Cloud Functions, Cloud Run, Vercel): the item is sent now, with short
|
|
25
31
|
retries, on the caller's thread. ``auto`` picks it from the platform's
|
|
@@ -124,6 +130,9 @@ class Sender:
|
|
|
124
130
|
self._stats = {"sent": 0, "dropped": 0, "dropped_by_reason": {}}
|
|
125
131
|
self._unreported = {"count": 0, "by_reason": {}}
|
|
126
132
|
self._warned_drop = False
|
|
133
|
+
self._unit_cap: Optional[int] = None # the team's ceiling, once a 429 names it
|
|
134
|
+
self._last_rate: Optional[Dict[str, Any]] = None
|
|
135
|
+
self._warned_paced_at = 0.0
|
|
127
136
|
self._thread: Optional[threading.Thread] = None
|
|
128
137
|
if self.mode == "background" and register_atexit:
|
|
129
138
|
atexit.register(self._atexit)
|
|
@@ -174,11 +183,13 @@ class Sender:
|
|
|
174
183
|
with self._lock:
|
|
175
184
|
self._stopped = True
|
|
176
185
|
left = len(self._queue)
|
|
186
|
+
spans_left = sum(_units_of(q.item) for q in self._queue)
|
|
177
187
|
self._queue.clear()
|
|
178
188
|
self._queue_bytes = 0
|
|
179
189
|
self._wake.notify_all()
|
|
180
190
|
if left:
|
|
181
191
|
self._drop(left, "shutdown", f"{left} items were still queued at shutdown")
|
|
192
|
+
self._warn_undelivered(left, spans_left)
|
|
182
193
|
|
|
183
194
|
def stats(self) -> Dict[str, Any]:
|
|
184
195
|
with self._lock:
|
|
@@ -229,21 +240,24 @@ class Sender:
|
|
|
229
240
|
def _take_batch_locked(self) -> List[_Queued]:
|
|
230
241
|
out: List[_Queued] = []
|
|
231
242
|
nbytes = 0
|
|
243
|
+
units = 0
|
|
244
|
+
cap = self._unit_cap
|
|
232
245
|
while self._queue and len(out) < self._o["flush_at"]:
|
|
233
246
|
nxt = self._queue[0]
|
|
234
247
|
if out and nbytes + nxt.nbytes > self._o["max_batch_bytes"]:
|
|
235
248
|
break
|
|
249
|
+
u = _units_of(nxt.item)
|
|
250
|
+
if out and cap and units + u > cap:
|
|
251
|
+
break
|
|
236
252
|
out.append(self._queue.popleft())
|
|
237
253
|
nbytes += nxt.nbytes
|
|
254
|
+
units += u
|
|
238
255
|
self._queue_bytes -= nbytes
|
|
239
256
|
return out
|
|
240
257
|
|
|
241
258
|
def _send_batch(self, batch: List[_Queued]) -> None:
|
|
242
|
-
t = self._now()
|
|
243
259
|
for q in batch:
|
|
244
260
|
q.attempts += 1
|
|
245
|
-
if q.first_tried_at is None:
|
|
246
|
-
q.first_tried_at = t
|
|
247
261
|
try:
|
|
248
262
|
status, retry_after, body = self._http.post_raw(
|
|
249
263
|
endpoints.BATCH, self._envelope(batch), timeout=self._o["request_timeout_s"], gzip_body=True,
|
|
@@ -257,8 +271,9 @@ class Sender:
|
|
|
257
271
|
self._settle(batch, body)
|
|
258
272
|
return
|
|
259
273
|
if status == 429:
|
|
274
|
+
paced = self._note_rate(body if isinstance(body, dict) else {}, retry_after)
|
|
260
275
|
self._pause(parse_retry_after_s(retry_after, self._now()))
|
|
261
|
-
self._requeue(batch, 0.0)
|
|
276
|
+
self._requeue(batch, 0.0, paced=paced)
|
|
262
277
|
return
|
|
263
278
|
if status == 413:
|
|
264
279
|
self._split(batch)
|
|
@@ -273,16 +288,23 @@ class Sender:
|
|
|
273
288
|
|
|
274
289
|
def _settle(self, batch: List[_Queued], body: Dict[str, Any]) -> None:
|
|
275
290
|
keep: List[_Queued] = []
|
|
291
|
+
paced: List[_Queued] = []
|
|
276
292
|
rejected = 0
|
|
293
|
+
queued = 0
|
|
277
294
|
first_error = None
|
|
278
295
|
results = body["results"]
|
|
279
296
|
for i, q in enumerate(batch):
|
|
280
297
|
r = results[i] if i < len(results) and isinstance(results[i], dict) else {"status": 500}
|
|
281
298
|
st = int(r.get("status", 500))
|
|
282
299
|
if 200 <= st < 300:
|
|
300
|
+
if r.get("queued"):
|
|
301
|
+
queued += 1
|
|
283
302
|
with self._lock:
|
|
284
303
|
self._stats["sent"] += 1
|
|
285
304
|
continue
|
|
305
|
+
if st == 429 and self._note_rate(r, None):
|
|
306
|
+
paced.append(q)
|
|
307
|
+
continue
|
|
286
308
|
if st == 429 or st >= 500 or st == 408:
|
|
287
309
|
keep.append(q)
|
|
288
310
|
continue
|
|
@@ -293,17 +315,60 @@ class Sender:
|
|
|
293
315
|
first_error = first_error or f"HTTP {st}: {r.get('error', '')}"
|
|
294
316
|
if rejected:
|
|
295
317
|
self._drop(rejected, "rejected", f"{rejected} item(s) rejected by the server ({first_error})")
|
|
318
|
+
if queued:
|
|
319
|
+
self._warn_paced(body.get("paced") if isinstance(body.get("paced"), dict) else {})
|
|
296
320
|
ra = body.get("retry_after_ms")
|
|
297
321
|
if ra:
|
|
298
322
|
self._pause(float(ra) / 1000.0)
|
|
323
|
+
if paced:
|
|
324
|
+
self._requeue(paced, 0.0, paced=True)
|
|
299
325
|
if keep:
|
|
300
326
|
self._requeue(keep, 0.0 if ra else None)
|
|
301
327
|
|
|
302
|
-
def
|
|
328
|
+
def _note_rate(self, body: Dict[str, Any], retry_after: Optional[str]) -> bool:
|
|
329
|
+
"""Is this 429 the rate ceiling (pacing: retry, never give up) rather
|
|
330
|
+
than an exhausted allowance? Learns the team's ceiling from it."""
|
|
331
|
+
ceiling = body.get("ceiling_units_min")
|
|
332
|
+
rate = bool(retry_after) or body.get("retry_after_seconds") is not None or body.get("deferred") or ceiling is not None
|
|
333
|
+
if not rate:
|
|
334
|
+
return False
|
|
335
|
+
try:
|
|
336
|
+
c = int(ceiling) if ceiling is not None else 0
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
c = 0
|
|
339
|
+
with self._lock:
|
|
340
|
+
if c > 0:
|
|
341
|
+
self._unit_cap = c
|
|
342
|
+
self._last_rate = {"ceiling": c or None, "reason": body.get("reason"), "message": body.get("message")}
|
|
343
|
+
return True
|
|
344
|
+
|
|
345
|
+
def _warn_paced(self, paced: Dict[str, Any]) -> None:
|
|
346
|
+
t = self._now()
|
|
347
|
+
with self._lock:
|
|
348
|
+
if t - self._warned_paced_at < 600:
|
|
349
|
+
return
|
|
350
|
+
self._warned_paced_at = t
|
|
351
|
+
c = paced.get("ceiling_units_min")
|
|
352
|
+
limit = f" ({c} units/min)" if c else ""
|
|
353
|
+
sys.stderr.write(
|
|
354
|
+
f"[trodo-python] This team is sending faster than its rate limit{limit}. Trodo is holding the extra traces "
|
|
355
|
+
"and storing them at that rate, so they will appear late. Contact Trodo for a higher limit.\n"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _requeue(self, batch: List[_Queued], wait_s: Optional[float], paced: bool = False) -> None:
|
|
303
359
|
t = self._now()
|
|
304
360
|
alive: List[_Queued] = []
|
|
305
361
|
for q in batch:
|
|
306
|
-
if
|
|
362
|
+
if paced:
|
|
363
|
+
# The server answered and is only pacing: the give-up clock
|
|
364
|
+
# (for outages and refusals) starts over.
|
|
365
|
+
q.first_tried_at = None
|
|
366
|
+
q.attempts = 0
|
|
367
|
+
alive.append(q)
|
|
368
|
+
elif q.first_tried_at is None:
|
|
369
|
+
q.first_tried_at = t
|
|
370
|
+
alive.append(q)
|
|
371
|
+
elif t - q.first_tried_at > self._o["max_retry_s"]:
|
|
307
372
|
self._drop(1, "gave_up", f"an item was retried for {int(self._o['max_retry_s'])} s without being accepted")
|
|
308
373
|
else:
|
|
309
374
|
alive.append(q)
|
|
@@ -314,7 +379,7 @@ class Sender:
|
|
|
314
379
|
self._queue.appendleft(q)
|
|
315
380
|
self._queue_bytes += sum(q.nbytes for q in alive)
|
|
316
381
|
shed = self._shed_locked()
|
|
317
|
-
attempts = max(q.attempts for q in alive)
|
|
382
|
+
attempts = max(1, max(q.attempts for q in alive))
|
|
318
383
|
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
384
|
self._not_before = t + backoff
|
|
320
385
|
if shed:
|
|
@@ -342,6 +407,7 @@ class Sender:
|
|
|
342
407
|
one attempt, the status read — a refusal keeps the item like the batch
|
|
343
408
|
path would, instead of the old blocking request that dropped it."""
|
|
344
409
|
keep: List[_Queued] = []
|
|
410
|
+
paced: List[_Queued] = []
|
|
345
411
|
wait: Optional[float] = None
|
|
346
412
|
for q in batch:
|
|
347
413
|
path, body = _legacy_route(q.item)
|
|
@@ -353,14 +419,18 @@ class Sender:
|
|
|
353
419
|
with self._lock:
|
|
354
420
|
self._stats["sent"] += 1
|
|
355
421
|
continue
|
|
422
|
+
if status == 429 and self._note_rate(resp if isinstance(resp, dict) else {}, retry_after):
|
|
423
|
+
paced.append(q)
|
|
424
|
+
wait = parse_retry_after_s(retry_after, self._now()) or wait
|
|
425
|
+
continue
|
|
356
426
|
if status is None or status in _TRANSIENT:
|
|
357
427
|
keep.append(q)
|
|
358
|
-
if status == 429:
|
|
359
|
-
wait = parse_retry_after_s(retry_after, self._now()) or wait
|
|
360
428
|
continue
|
|
361
429
|
self._drop(1, "too_large" if status == 413 else "rejected", f"POST {path} -> HTTP {status}: {json.dumps(resp)[:200]}")
|
|
362
430
|
if wait is not None:
|
|
363
431
|
self._pause(wait)
|
|
432
|
+
if paced:
|
|
433
|
+
self._requeue(paced, 0.0, paced=True)
|
|
364
434
|
if keep:
|
|
365
435
|
self._requeue(keep, 0.0 if wait is not None else None)
|
|
366
436
|
|
|
@@ -472,6 +542,27 @@ class Sender:
|
|
|
472
542
|
self.flush(self._o["exit_flush_s"])
|
|
473
543
|
except Exception:
|
|
474
544
|
pass
|
|
545
|
+
with self._lock:
|
|
546
|
+
left = len(self._queue) + self._in_flight
|
|
547
|
+
units = sum(_units_of(q.item) for q in self._queue)
|
|
548
|
+
if left:
|
|
549
|
+
self._warn_undelivered(left, units)
|
|
550
|
+
|
|
551
|
+
def _warn_undelivered(self, items: int, units: int) -> None:
|
|
552
|
+
"""Always on stderr, whatever was said before: data is about to be lost."""
|
|
553
|
+
with self._lock:
|
|
554
|
+
rate = self._last_rate if self._paused_until and self._paused_until > self._now() else None
|
|
555
|
+
why = ""
|
|
556
|
+
if rate and rate.get("message"):
|
|
557
|
+
why = f" Trodo said: {rate['message']}"
|
|
558
|
+
elif rate:
|
|
559
|
+
c = rate.get("ceiling")
|
|
560
|
+
why = f" The team is over its rate limit{f' ({c} units/min)' if c else ''} and Trodo could not hold more."
|
|
561
|
+
sys.stderr.write(
|
|
562
|
+
f"[trodo-python] {items} trace item(s), {units} units, were NOT delivered before the process exited "
|
|
563
|
+
f"(waited {self._o['exit_flush_s']:g} s).{why} Call trodo.flush() before exiting (it waits until they are "
|
|
564
|
+
"delivered), or raise delivery_options exit_flush_s.\n"
|
|
565
|
+
)
|
|
475
566
|
|
|
476
567
|
def _log(self, msg: str) -> None:
|
|
477
568
|
if self._debug:
|
|
@@ -489,6 +580,14 @@ def _legacy_route(item: Item):
|
|
|
489
580
|
return endpoints.SPANS_APPEND, {"spans": item["spans"]}
|
|
490
581
|
|
|
491
582
|
|
|
583
|
+
def _units_of(item: Any) -> int:
|
|
584
|
+
"""What an item costs against the rate ceiling: one per span, one for the run."""
|
|
585
|
+
if not isinstance(item, dict):
|
|
586
|
+
return 0
|
|
587
|
+
spans = item.get("spans")
|
|
588
|
+
return (len(spans) if isinstance(spans, list) else 0) + (1 if isinstance(item.get("run"), dict) else 0)
|
|
589
|
+
|
|
590
|
+
|
|
492
591
|
def _size_of(item: Any) -> int:
|
|
493
592
|
try:
|
|
494
593
|
return len(json.dumps(item, default=str).encode("utf-8"))
|
|
@@ -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, [
|
|
219
|
+
self._send_spans(span.run_id, [d])
|
|
172
220
|
elif chunk:
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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 +0,0 @@
|
|
|
1
|
-
SDK_VERSION = "3.0.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|