trodo-python 3.0.1__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.1 → trodo_python-3.0.3}/PKG-INFO +1 -1
- {trodo_python-3.0.1 → trodo_python-3.0.3}/pyproject.toml +1 -1
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_long_runs.py +27 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_sender.py +117 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/__init__.py +1 -1
- trodo_python-3.0.3/trodo/_version.py +1 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/delivery/sender.py +152 -20
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/processor.py +66 -10
- {trodo_python-3.0.1 → trodo_python-3.0.3}/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.3}/README.md +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/setup.cfg +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_anon_distinct_id.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_auto_instrument_fixes.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_cross_process_session.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_datasets.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_end_run.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_end_run_order.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_error_enrichment.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_framework_deference.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_get_prompt_version_types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_instrumentor_resolution.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_llm_usage_cost.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_multi_agent_shape.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_processor_methods.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_prompt_fingerprint.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_prompt_trace.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_prompts.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_register_otel.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_start_run.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_user_traits.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/tests/test_wrap_agent_unchanged.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/_retry.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/async_client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/endpoints.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/http_client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/api/management.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/auto/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/auto/auto_event_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/client.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/delivery/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/dataset_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/group_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/people_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/prompt_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/managers/user_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/auto_instrument.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/context.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/helpers.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/prompt_trace.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/register.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/transport.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/otel/wrap_agent.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/prompts/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/prompts/compile.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/prompts/template.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/prompts/types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/queue/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/queue/batch_flusher.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/queue/event_queue.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/session/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/session/server_session.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/session/session_manager.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/types.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/user_context.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/util/__init__.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo/util/lru.py +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo_python.egg-info/SOURCES.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo_python.egg-info/dependency_links.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/trodo_python.egg-info/requires.txt +0 -0
- {trodo_python-3.0.1 → trodo_python-3.0.3}/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,120 @@ 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
|
|
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):
|
|
@@ -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)
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
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()
|
|
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
|
|
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
|
|
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"))
|
|
@@ -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
|