trodo-python 3.0.2__tar.gz → 3.0.3__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.2 → trodo_python-3.0.3}/PKG-INFO +1 -1
- {trodo_python-3.0.2 → trodo_python-3.0.3}/pyproject.toml +1 -1
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_sender.py +55 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/__init__.py +1 -1
- trodo_python-3.0.3/trodo/_version.py +1 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/delivery/sender.py +40 -7
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo_python.egg-info/PKG-INFO +1 -1
- trodo_python-3.0.2/trodo/_version.py +0 -1
- {trodo_python-3.0.2 → trodo_python-3.0.3}/README.md +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/setup.cfg +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_cross_process_session.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_datasets.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_end_run.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_end_run_order.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_error_enrichment.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_framework_deference.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_get_prompt_version_types.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_instrumentor_resolution.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_long_runs.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_multi_agent_shape.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_processor_methods.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_prompt_fingerprint.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_prompt_trace.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_prompts.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_register_otel.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_start_run.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_user_traits.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/_retry.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/async_client.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/endpoints.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/http_client.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/api/management.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/auto/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/client.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/delivery/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/group_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/people_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/managers/user_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/context.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/helpers.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/processor.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/register.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/transport.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/prompts/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/prompts/compile.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/prompts/template.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/prompts/types.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/queue/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/queue/event_queue.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/session/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/session/server_session.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/session/session_manager.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/types.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/user_context.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/util/__init__.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo/util/lru.py +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-3.0.2 → trodo_python-3.0.3}/trodo_python.egg-info/top_level.txt +0 -0
|
@@ -333,3 +333,58 @@ def test_exit_says_loudly_what_was_not_delivered(capsys):
|
|
|
333
333
|
err = capsys.readouterr().err
|
|
334
334
|
assert "1 trace item(s), 2 units, were NOT delivered" in err
|
|
335
335
|
assert "Trodo said: this team's backlog is full" in err
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ── Parallel requests (3.0.3) ─────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
class Timed:
|
|
341
|
+
"""A slow /batch that records when each request started and ended."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, delay_s: float) -> None:
|
|
344
|
+
self.delay_s = delay_s
|
|
345
|
+
self.calls: List[Dict[str, Any]] = []
|
|
346
|
+
self.lock = threading.Lock()
|
|
347
|
+
|
|
348
|
+
def post_raw(self, path, body, timeout=0, gzip_body=True):
|
|
349
|
+
start = time.time()
|
|
350
|
+
time.sleep(self.delay_s)
|
|
351
|
+
with self.lock:
|
|
352
|
+
self.calls.append({"start": start, "end": time.time(), "items": body["items"]})
|
|
353
|
+
return 200, None, {"results": [{"status": 200} for _ in body["items"]]}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_up_to_three_requests_are_in_flight_at_once():
|
|
357
|
+
s = Timed(0.3)
|
|
358
|
+
sender = Sender(s, **fast(flush_at=50))
|
|
359
|
+
for i in range(150):
|
|
360
|
+
sender.enqueue(run(f"r{i}"))
|
|
361
|
+
t0 = time.time()
|
|
362
|
+
sender.flush(timeout_s=3)
|
|
363
|
+
assert sender.stats()["sent"] == 150
|
|
364
|
+
assert len(s.calls) == 3
|
|
365
|
+
assert time.time() - t0 < 0.55 # one at a time would take 0.9 s
|
|
366
|
+
starts = sorted(c["start"] for c in s.calls)
|
|
367
|
+
assert starts[-1] - starts[0] < 0.1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_max_in_flight_1_sends_one_at_a_time():
|
|
371
|
+
s = Timed(0.1)
|
|
372
|
+
sender = Sender(s, **fast(flush_at=10, max_in_flight=1))
|
|
373
|
+
for i in range(30):
|
|
374
|
+
sender.enqueue(run(f"r{i}"))
|
|
375
|
+
sender.flush(timeout_s=3)
|
|
376
|
+
calls = sorted(s.calls, key=lambda c: c["start"])
|
|
377
|
+
assert all(b["start"] >= a["end"] for a, b in zip(calls, calls[1:]))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_feedback_waits_while_its_run_is_in_flight_in_another_request():
|
|
381
|
+
s = Timed(0.3)
|
|
382
|
+
sender = Sender(s, **fast(flush_at=1))
|
|
383
|
+
sender.enqueue(run("a"))
|
|
384
|
+
assert wait_for(lambda: sender.stats()["in_flight"] == 1)
|
|
385
|
+
sender.enqueue({"run_id": "a", "feedback": {"rating": 5}})
|
|
386
|
+
sender.enqueue(run("b")) # behind the feedback, also waits: order is kept
|
|
387
|
+
sender.flush(timeout_s=3)
|
|
388
|
+
assert sender.stats()["sent"] == 3
|
|
389
|
+
by = {("fb" if "feedback" in c["items"][0] else c["items"][0]["run"]["run_id"]): c for c in s.calls}
|
|
390
|
+
assert by["fb"]["start"] >= by["a"]["end"]
|
|
@@ -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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SDK_VERSION = "3.0.3"
|
|
@@ -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.
|
|
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):
|
|
@@ -53,7 +57,7 @@ DEFAULTS = dict(
|
|
|
53
57
|
flush_interval_s=1.0, flush_at=50, max_batch_bytes=4 * 1024 * 1024,
|
|
54
58
|
max_queue_items=10_000, max_queue_bytes=64 * 1024 * 1024,
|
|
55
59
|
max_retry_s=600.0, max_retry_after_s=600.0, immediate_retry_s=15.0,
|
|
56
|
-
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,
|
|
57
61
|
)
|
|
58
62
|
_MIN_BACKOFF_S = 1.0
|
|
59
63
|
_MAX_BACKOFF_S = 60.0
|
|
@@ -133,7 +137,8 @@ class Sender:
|
|
|
133
137
|
self._unit_cap: Optional[int] = None # the team's ceiling, once a 429 names it
|
|
134
138
|
self._last_rate: Optional[Dict[str, Any]] = None
|
|
135
139
|
self._warned_paced_at = 0.0
|
|
136
|
-
self.
|
|
140
|
+
self._threads: List[threading.Thread] = []
|
|
141
|
+
self._runs_in_flight: Dict[str, int] = {} # run_id -> requests carrying it
|
|
137
142
|
if self.mode == "background" and register_atexit:
|
|
138
143
|
atexit.register(self._atexit)
|
|
139
144
|
|
|
@@ -203,9 +208,11 @@ class Sender:
|
|
|
203
208
|
# ── background thread ─────────────────────────────────────────────────────
|
|
204
209
|
|
|
205
210
|
def _ensure_thread_locked(self) -> None:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
self.
|
|
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()
|
|
209
216
|
|
|
210
217
|
def _run(self) -> None:
|
|
211
218
|
interval = self._o["flush_interval_s"]
|
|
@@ -229,12 +236,25 @@ class Sender:
|
|
|
229
236
|
break
|
|
230
237
|
self._wake.wait(timeout=interval - age)
|
|
231
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
|
|
232
246
|
self._in_flight += 1
|
|
233
247
|
try:
|
|
234
248
|
self._send_batch(batch)
|
|
235
249
|
finally:
|
|
236
250
|
with self._lock:
|
|
237
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)
|
|
238
258
|
self._wake.notify_all()
|
|
239
259
|
|
|
240
260
|
def _take_batch_locked(self) -> List[_Queued]:
|
|
@@ -249,6 +269,9 @@ class Sender:
|
|
|
249
269
|
u = _units_of(nxt.item)
|
|
250
270
|
if out and cap and units + u > cap:
|
|
251
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
|
|
252
275
|
out.append(self._queue.popleft())
|
|
253
276
|
nbytes += nxt.nbytes
|
|
254
277
|
units += u
|
|
@@ -580,6 +603,16 @@ def _legacy_route(item: Item):
|
|
|
580
603
|
return endpoints.SPANS_APPEND, {"spans": item["spans"]}
|
|
581
604
|
|
|
582
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
|
+
|
|
583
616
|
def _units_of(item: Any) -> int:
|
|
584
617
|
"""What an item costs against the rate ceiling: one per span, one for the run."""
|
|
585
618
|
if not isinstance(item, dict):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
SDK_VERSION = "3.0.2"
|
|
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
|
|
File without changes
|