trodo-python 2.23.3__tar.gz → 2.24.0__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.
Files changed (71) hide show
  1. {trodo_python-2.23.3 → trodo_python-2.24.0}/PKG-INFO +1 -1
  2. {trodo_python-2.23.3 → trodo_python-2.24.0}/pyproject.toml +1 -1
  3. trodo_python-2.24.0/tests/test_sender.py +273 -0
  4. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/__init__.py +18 -1
  5. trodo_python-2.24.0/trodo/_version.py +1 -0
  6. trodo_python-2.24.0/trodo/api/_retry.py +30 -0
  7. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/api/async_client.py +8 -4
  8. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/api/endpoints.py +3 -0
  9. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/api/http_client.py +50 -11
  10. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/client.py +40 -7
  11. trodo_python-2.24.0/trodo/delivery/sender.py +500 -0
  12. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/processor.py +19 -14
  13. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/wrap_agent.py +11 -0
  14. trodo_python-2.24.0/trodo/session/__init__.py +0 -0
  15. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo_python.egg-info/PKG-INFO +1 -1
  16. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo_python.egg-info/SOURCES.txt +5 -0
  17. {trodo_python-2.23.3 → trodo_python-2.24.0}/README.md +0 -0
  18. {trodo_python-2.23.3 → trodo_python-2.24.0}/setup.cfg +0 -0
  19. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_anon_distinct_id.py +0 -0
  20. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_auto_instrument_fixes.py +0 -0
  21. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_cross_process_session.py +0 -0
  22. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_datasets.py +0 -0
  23. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_end_run.py +0 -0
  24. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_error_enrichment.py +0 -0
  25. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_experiments.py +0 -0
  26. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_framework_deference.py +0 -0
  27. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_get_prompt_version_types.py +0 -0
  28. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_instrumentor_resolution.py +0 -0
  29. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_llm_usage_cost.py +0 -0
  30. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_multi_agent_shape.py +0 -0
  31. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_processor_methods.py +0 -0
  32. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_prompt_trace.py +0 -0
  33. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_prompts.py +0 -0
  34. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_register_otel.py +0 -0
  35. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_start_run.py +0 -0
  36. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_user_traits.py +0 -0
  37. {trodo_python-2.23.3 → trodo_python-2.24.0}/tests/test_wrap_agent_unchanged.py +0 -0
  38. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/api/__init__.py +0 -0
  39. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/auto/__init__.py +0 -0
  40. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/auto/auto_event_manager.py +0 -0
  41. {trodo_python-2.23.3/trodo/managers → trodo_python-2.24.0/trodo/delivery}/__init__.py +0 -0
  42. {trodo_python-2.23.3/trodo/queue → trodo_python-2.24.0/trodo/managers}/__init__.py +0 -0
  43. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/dataset_manager.py +0 -0
  44. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/experiment_manager.py +0 -0
  45. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/group_manager.py +0 -0
  46. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/people_manager.py +0 -0
  47. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/prompt_manager.py +0 -0
  48. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/managers/user_manager.py +0 -0
  49. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/__init__.py +0 -0
  50. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/auto_instrument.py +0 -0
  51. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/context.py +0 -0
  52. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/helpers.py +0 -0
  53. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/prompt_trace.py +0 -0
  54. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/register.py +0 -0
  55. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/otel/transport.py +0 -0
  56. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/prompts/__init__.py +0 -0
  57. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/prompts/compile.py +0 -0
  58. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/prompts/template.py +0 -0
  59. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/prompts/types.py +0 -0
  60. {trodo_python-2.23.3/trodo/session → trodo_python-2.24.0/trodo/queue}/__init__.py +0 -0
  61. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/queue/batch_flusher.py +0 -0
  62. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/queue/event_queue.py +0 -0
  63. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/session/server_session.py +0 -0
  64. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/session/session_manager.py +0 -0
  65. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/types.py +0 -0
  66. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/user_context.py +0 -0
  67. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/util/__init__.py +0 -0
  68. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo/util/lru.py +0 -0
  69. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo_python.egg-info/dependency_links.txt +0 -0
  70. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo_python.egg-info/requires.txt +0 -0
  71. {trodo_python-2.23.3 → trodo_python-2.24.0}/trodo_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.23.3
3
+ Version: 2.24.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "trodo-python"
7
- version = "2.23.3"
7
+ version = "2.24.0"
8
8
  description = "Trodo Analytics SDK for Python — server-side event tracking"
9
9
  readme = "README.md"
10
10
  license = { text = "ISC" }
@@ -0,0 +1,273 @@
1
+ """Background delivery (2.24): the agent's caller never waits on Trodo, and a
2
+ trace is lost only when the queue is full, the server rejects it, or ten
3
+ minutes of retrying did not get it accepted.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import threading
9
+ import time
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import pytest
13
+
14
+ from trodo.delivery.sender import Sender, detect_mode, parse_retry_after_s
15
+ from trodo.otel.processor import TrodoSpanProcessor
16
+ from trodo.otel.wrap_agent import wrap_agent
17
+
18
+
19
+ class Server:
20
+ """A fake /api/sdk/batch: a scripted list of replies, then 200 for everything."""
21
+
22
+ def __init__(self, script: Optional[List[Any]] = None, delay_s: float = 0.0) -> None:
23
+ self.script = list(script or [])
24
+ self.posts: List[Dict[str, Any]] = []
25
+ self.delay_s = delay_s
26
+ self.lock = threading.Lock()
27
+
28
+ def post_raw(self, path: str, body: Dict[str, Any], timeout: float = 0, gzip_body: bool = True):
29
+ if self.delay_s:
30
+ time.sleep(self.delay_s)
31
+ with self.lock:
32
+ self.posts.append({"path": path, "body": body, "at": time.time()})
33
+ nxt = self.script.pop(0) if self.script else None
34
+ if isinstance(nxt, Exception):
35
+ raise nxt
36
+ if nxt is not None:
37
+ return nxt
38
+ return 200, None, {"results": [{"status": 200} for _ in body["items"]]}
39
+
40
+ def post_run_ingest(self, p):
41
+ self.posts.append({"path": "legacy", "body": {"items": [p]}})
42
+ return {}
43
+
44
+
45
+ def run(rid: str) -> Dict[str, Any]:
46
+ return {"run": {"run_id": rid, "agent_name": "bot"}, "spans": [{"span_id": f"{rid}-s1"}]}
47
+
48
+
49
+ def fast(**o: Any) -> Dict[str, Any]:
50
+ """Sender options that make a test run in well under a second."""
51
+ return {"mode": "background", "on_error": lambda e: None, "register_atexit": False, "flush_interval_s": 0.05, **o}
52
+
53
+
54
+ def wait_for(cond, timeout=3.0):
55
+ end = time.time() + timeout
56
+ while time.time() < end:
57
+ if cond():
58
+ return True
59
+ time.sleep(0.01)
60
+ return cond()
61
+
62
+
63
+ def test_enqueue_returns_at_once_and_the_batch_follows():
64
+ s = Server(delay_s=0.2)
65
+ sender = Sender(s, **fast())
66
+ t0 = time.time()
67
+ sender.enqueue(run("a")); sender.enqueue(run("b"))
68
+ assert time.time() - t0 < 0.05
69
+ assert s.posts == []
70
+ assert wait_for(lambda: len(s.posts) == 1)
71
+ assert s.posts[0]["path"] == "/api/sdk/batch"
72
+ assert s.posts[0]["body"]["items"] == [run("a"), run("b")]
73
+ sender.flush()
74
+ assert sender.stats()["sent"] == 2 and sender.stats()["dropped"] == 0
75
+
76
+
77
+ def test_flush_at_items_sends_without_waiting_for_the_interval():
78
+ s = Server()
79
+ sender = Sender(s, **fast(flush_interval_s=60.0, flush_at=5))
80
+ for i in range(5):
81
+ sender.enqueue(run(f"r{i}"))
82
+ assert wait_for(lambda: len(s.posts) == 1)
83
+ assert len(s.posts[0]["body"]["items"]) == 5
84
+
85
+
86
+ def test_429_pauses_the_sender_and_loses_nothing():
87
+ s = Server([(429, "0.3", {"error": "Rate ceiling exceeded"})])
88
+ sender = Sender(s, **fast())
89
+ sender.enqueue(run("a"))
90
+ assert wait_for(lambda: len(s.posts) == 1)
91
+ sender.enqueue(run("b"))
92
+ time.sleep(0.15)
93
+ assert len(s.posts) == 1 # still paused
94
+ assert sender.stats()["paused_until"] > time.time() - 0.01
95
+ assert wait_for(lambda: len(s.posts) == 2)
96
+ assert s.posts[1]["body"]["items"] == [run("a"), run("b")]
97
+ sender.flush()
98
+ assert sender.stats()["sent"] == 2 and sender.stats()["dropped"] == 0
99
+
100
+
101
+ def test_outage_is_retried_with_backoff_until_the_server_is_back(monkeypatch):
102
+ import trodo.delivery.sender as m
103
+ monkeypatch.setattr(m, "_MIN_BACKOFF_S", 0.02)
104
+ s = Server([(503, None, None), RuntimeError("ECONNRESET"), RuntimeError("timeout")])
105
+ sender = Sender(s, **fast())
106
+ sender.enqueue(run("a"))
107
+ assert wait_for(lambda: sender.stats()["sent"] == 1, timeout=5)
108
+ assert len(s.posts) == 4
109
+ assert sender.stats()["dropped"] == 0
110
+
111
+
112
+ def test_per_item_verdicts_accepted_rejected_deferred():
113
+ s = Server([(200, None, {"results": [{"status": 200}, {"status": 400, "error": "bad run"}, {"status": 429, "deferred": True}], "retry_after_ms": 100})])
114
+ sender = Sender(s, **fast(flush_at=3))
115
+ for rid in ("ok", "bad", "late"):
116
+ sender.enqueue(run(rid))
117
+ assert wait_for(lambda: len(s.posts) == 2)
118
+ assert s.posts[1]["body"]["items"] == [run("late")]
119
+ assert s.posts[1]["body"]["dropped"] == {"count": 1, "by_reason": {"rejected": 1}} # the server is told
120
+ sender.flush()
121
+ st = sender.stats()
122
+ assert st["sent"] == 2 and st["dropped_by_reason"] == {"rejected": 1}
123
+
124
+
125
+ def test_413_splits_the_batch_and_a_single_oversized_item_is_dropped():
126
+ s = Server([(413, None, {"error": "too large"}), (413, None, None)])
127
+ sender = Sender(s, **fast(flush_at=2))
128
+ sender.enqueue(run("a")); sender.enqueue(run("b"))
129
+ assert wait_for(lambda: sender.stats()["sent"] == 1 and sender.stats()["dropped"] == 1)
130
+ assert [len(p["body"]["items"]) for p in s.posts] == [2, 1, 1]
131
+ assert sender.stats()["dropped_by_reason"] == {"too_large": 1}
132
+
133
+
134
+ def test_a_full_queue_drops_the_oldest_and_counts_it():
135
+ s = Server([RuntimeError("down")])
136
+ sender = Sender(s, **fast(flush_interval_s=60.0, max_queue_items=3))
137
+ for rid in "abcde":
138
+ sender.enqueue(run(rid))
139
+ st = sender.stats()
140
+ assert st["queued"] == 3 and st["dropped_by_reason"] == {"queue_full": 2}
141
+ sender.flush(timeout_s=2)
142
+ sent = [i["run"]["run_id"] for p in s.posts for i in p["body"]["items"]]
143
+ assert sent[-3:] == ["c", "d", "e"] # the newest survived
144
+
145
+
146
+ def test_gives_up_after_max_retry(monkeypatch):
147
+ import trodo.delivery.sender as m
148
+ monkeypatch.setattr(m, "_MIN_BACKOFF_S", 0.01)
149
+ monkeypatch.setattr(m, "_MAX_BACKOFF_S", 0.02)
150
+ s = Server([RuntimeError("down")] * 100)
151
+ sender = Sender(s, **fast(max_retry_s=0.2))
152
+ sender.enqueue(run("a"))
153
+ assert wait_for(lambda: sender.stats()["dropped"] == 1, timeout=5)
154
+ assert sender.stats()["dropped_by_reason"] == {"gave_up": 1}
155
+ assert sender.stats()["queued"] == 0
156
+
157
+
158
+ def test_shutdown_flushes_then_stops_accepting():
159
+ s = Server()
160
+ sender = Sender(s, **fast(flush_interval_s=60.0))
161
+ sender.enqueue(run("a"))
162
+ sender.shutdown()
163
+ assert sender.stats()["sent"] == 1
164
+ sender.enqueue(run("b"))
165
+ time.sleep(0.1)
166
+ assert len(s.posts) == 1
167
+
168
+
169
+ def test_legacy_server_without_batch_route_keeps_refused_items():
170
+ s = Server([(404, None, {"error": "Not found"}), (200, None, {"success": True}), (503, None, None)])
171
+ sender = Sender(s, **fast())
172
+ sender.enqueue(run("a")); sender.enqueue({"run_id": "a", "feedback": {"rating": 5}})
173
+ assert wait_for(lambda: sender.stats()["sent"] == 2, timeout=5)
174
+ assert [p["path"] for p in s.posts][:3] == ["/api/sdk/batch", "/api/sdk/runs/ingest", "/api/sdk/runs/a/feedback"]
175
+ assert sender.stats()["dropped"] == 0
176
+
177
+
178
+ def test_on_error_sees_drops():
179
+ seen: List[str] = []
180
+ sender = Sender(Server(), mode="background", on_error=lambda e: seen.append(str(e)), register_atexit=False, max_queue_items=1)
181
+ sender.enqueue(run("a")); sender.enqueue(run("b"))
182
+ assert len(seen) == 1 and "queue is full" in seen[0]
183
+
184
+
185
+ def test_immediate_mode_blocks_only_within_its_budget():
186
+ s = Server([(503, None, None)] * 50)
187
+ slept: List[float] = []
188
+ clock = [0.0]
189
+
190
+ def sleep(x):
191
+ slept.append(x); clock[0] += x
192
+
193
+ sender = Sender(s, mode="immediate", on_error=lambda e: None, immediate_retry_s=15.0, now=lambda: clock[0], sleep=sleep)
194
+ sender.enqueue(run("a"))
195
+ assert sender.stats()["dropped_by_reason"] == {"gave_up": 1}
196
+ assert sum(slept) <= 15.0
197
+
198
+
199
+ def test_immediate_mode_sends_before_returning():
200
+ s = Server([RuntimeError("blip")])
201
+ sender = Sender(s, mode="immediate", on_error=lambda e: None, sleep=lambda x: None)
202
+ sender.enqueue(run("a"))
203
+ assert len(s.posts) == 2 and sender.stats()["sent"] == 1
204
+
205
+
206
+ def test_auto_mode_detection():
207
+ assert detect_mode({}) == "background"
208
+ assert detect_mode({"AWS_LAMBDA_FUNCTION_NAME": "fn"}) == "immediate"
209
+ assert detect_mode({"K_SERVICE": "svc"}) == "immediate"
210
+ assert detect_mode({"VERCEL": "1"}) == "immediate"
211
+ assert detect_mode({"KUBERNETES_SERVICE_HOST": "10.0.0.1"}) == "background"
212
+ assert parse_retry_after_s("12", 0) == 12.0
213
+ assert parse_retry_after_s("soon", 0) is None
214
+
215
+
216
+ # ── through wrap_agent ───────────────────────────────────────────────────────
217
+
218
+ def test_wrap_agent_returns_before_the_send_and_the_run_arrives_whole():
219
+ s = Server(delay_s=0.3)
220
+ sender = Sender(s, **fast())
221
+ processor = TrodoSpanProcessor(http_client=s, sender=sender)
222
+ t0 = time.time()
223
+ with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat", distinct_id="u") as r:
224
+ r.set_input({"q": "hello"}); r.set_output({"a": "world"})
225
+ assert time.time() - t0 < 0.1 # not the server's 300 ms
226
+ sender.flush()
227
+ item = s.posts[0]["body"]["items"][0]
228
+ assert item["run"]["agent_name"] == "chat" and item["run"]["output"] == {"a": "world"}
229
+
230
+
231
+ def test_async_with_wrap_agent_does_not_block_the_event_loop():
232
+ s = Server(delay_s=0.3)
233
+ sender = Sender(s, **fast())
234
+ processor = TrodoSpanProcessor(http_client=s, sender=sender)
235
+
236
+ async def main():
237
+ ticks = 0
238
+
239
+ async def ticker():
240
+ nonlocal ticks
241
+ for _ in range(5):
242
+ await asyncio.sleep(0.01); ticks += 1
243
+
244
+ t = asyncio.create_task(ticker())
245
+ async with wrap_agent(processor=processor, team_site_id="site-x", agent_name="chat") as r:
246
+ await asyncio.sleep(0.02)
247
+ r.set_output("done")
248
+ await t
249
+ return ticks
250
+
251
+ assert asyncio.run(main()) == 5
252
+ sender.flush()
253
+ assert s.posts[0]["body"]["items"][0]["run"]["output"] == "done"
254
+
255
+
256
+ def test_agent_decorator_on_an_async_function_records_the_awaited_result():
257
+ from trodo.client import TrodoClient
258
+
259
+ s = Server()
260
+ client = TrodoClient(site_id="site-x", auto_instrument=False, delivery="background", delivery_options={"flush_interval_s": 0.05})
261
+ client._sender._http = s # the fake server behind the real sender
262
+ client._sender._o["flush_interval_s"] = 0.05
263
+
264
+ @client.agent("async-bot")
265
+ async def bot(q: str) -> str:
266
+ await asyncio.sleep(0.01)
267
+ return q.upper()
268
+
269
+ assert asyncio.run(bot("hi")) == "HI"
270
+ client.flush(timeout=2)
271
+ item = s.posts[0]["body"]["items"][0]
272
+ assert item["run"]["agent_name"] == "async-bot"
273
+ assert item["run"]["output"] == "HI" # not "<coroutine object ...>"
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.21.0"
44
+ __version__ = "2.24.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -163,6 +163,8 @@ def init(
163
163
  auto_instrument: bool = True,
164
164
  disable_instrumentations: Optional[List[str]] = None,
165
165
  otel_mode: str = "trodo",
166
+ delivery: str = "auto",
167
+ delivery_options: Optional[Dict[str, Any]] = None,
166
168
  ) -> TrodoClient:
167
169
  """Initialise the singleton SDK instance.
168
170
 
@@ -171,6 +173,19 @@ def init(
171
173
  any other installed opentelemetry-instrumentation-* package, so
172
174
  LLM calls emit token/cost spans with no further code.
173
175
 
176
+ ``delivery`` (2.24): how traces reach Trodo.
177
+ 'background' — wrap_agent returns at once; a daemon thread posts queued
178
+ runs in batches every second, retries while nobody waits,
179
+ and flushes at exit. The default on servers.
180
+ 'immediate' — the run is sent before wrap_agent returns, with short
181
+ retries (≤ 15 s), for platforms that freeze between
182
+ requests: Lambda, Cloud Functions, Cloud Run, Vercel.
183
+ 'auto' — 'immediate' when such a platform's environment variables
184
+ are present, otherwise 'background'. Default.
185
+ ``delivery_options``: flush_interval_s (1), flush_at (50), max_batch_bytes
186
+ (4 MB), max_queue_items (10,000), max_queue_bytes (64 MB), max_retry_s
187
+ (600), exit_flush_s (5).
188
+
174
189
  ``disable_instrumentations`` turns OFF specific instrumentors by id
175
190
  (e.g. ``['openai']`` when LangChain is your primary framework, to avoid
176
191
  double-counting the same model call). Mirrors Node's
@@ -191,6 +206,8 @@ def init(
191
206
  auto_instrument=auto_instrument,
192
207
  disable_instrumentations=disable_instrumentations,
193
208
  otel_mode=otel_mode,
209
+ delivery=delivery,
210
+ delivery_options=delivery_options,
194
211
  )
195
212
  return _client
196
213
 
@@ -0,0 +1 @@
1
+ SDK_VERSION = "2.24.0"
@@ -0,0 +1,30 @@
1
+ """When to retry a request, and after how long. Mirrors the Node SDK."""
2
+ import random
3
+ from typing import Optional
4
+
5
+
6
+ def retry_delay(status: int, retry_after: Optional[str], attempt: int) -> Optional[float]:
7
+ """Seconds to wait before retrying, or None when not to retry.
8
+
9
+ Retries a 5xx, and a 429 that says when (Retry-After): a rate limit is
10
+ temporary, an exhausted allowance (429 without Retry-After) is not. A
11
+ server that says Retry-After is honoured (capped at 30 s). Otherwise
12
+ exponential backoff with jitter, so a burst of clients hit by the same
13
+ outage does not retry in lockstep and knock it over again.
14
+ """
15
+ retryable = status >= 500 or (status == 429 and retry_after is not None)
16
+ if not retryable:
17
+ return None
18
+ try:
19
+ seconds = float(retry_after) if retry_after is not None else None
20
+ except ValueError:
21
+ seconds = None
22
+ if seconds is not None and seconds >= 0:
23
+ return min(seconds, 30.0) + random.random()
24
+ return jittered_backoff(attempt)
25
+
26
+
27
+ def jittered_backoff(attempt: int) -> float:
28
+ """2**attempt seconds, somewhere in its upper half."""
29
+ base = float(2 ** attempt)
30
+ return base / 2 + random.random() * (base / 2)
@@ -7,6 +7,7 @@ import asyncio
7
7
  from typing import Any, Callable, Dict, Optional
8
8
 
9
9
  from ..types import ApiResult, EventPayload
10
+ from ._retry import jittered_backoff, retry_delay
10
11
 
11
12
 
12
13
  class AsyncHttpClient:
@@ -51,9 +52,12 @@ class AsyncHttpClient:
51
52
  try:
52
53
  async with httpx.AsyncClient(timeout=self.timeout) as client:
53
54
  resp = await client.post(url, json=body, headers=headers)
54
- if resp.status_code >= 500 and attempt < self.retries:
55
- delay = 2 ** attempt
56
- self._log(f"Retry {attempt + 1} after {delay}s")
55
+ delay = (
56
+ retry_delay(resp.status_code, resp.headers.get('retry-after'), attempt)
57
+ if attempt < self.retries else None
58
+ )
59
+ if delay is not None:
60
+ self._log(f"Retry {attempt + 1} after {delay:.1f}s (status {resp.status_code})")
57
61
  await asyncio.sleep(delay)
58
62
  return await self._request(path, body, attempt + 1)
59
63
  try:
@@ -62,7 +66,7 @@ class AsyncHttpClient:
62
66
  return {}
63
67
  except Exception as exc:
64
68
  if attempt < self.retries:
65
- await asyncio.sleep(2 ** attempt)
69
+ await asyncio.sleep(jittered_backoff(attempt))
66
70
  return await self._request(path, body, attempt + 1)
67
71
  self._log(f"Error: {exc}")
68
72
  if self.on_error:
@@ -22,8 +22,11 @@ GROUPS_REMOVE = "/api/sdk/groups/remove_group"
22
22
  GROUPS_PROFILE = "/api/sdk/groups/profile"
23
23
  # Agent run tracking (Lemma-style, run-centric)
24
24
  RUNS_INGEST = "/api/sdk/runs/ingest"
25
+ # Many writes in one request — the background sender (delivery/sender.py).
26
+ BATCH = "/api/sdk/batch"
25
27
  RUNS_START = "/api/sdk/runs/start"
26
28
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
29
+ SPANS_APPEND = "/api/sdk/spans/append"
27
30
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
28
31
  # Datasets & experiments
29
32
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
@@ -10,6 +10,8 @@ import requests
10
10
 
11
11
  from ..types import ApiResult, EventPayload
12
12
  from . import endpoints
13
+ from ._retry import jittered_backoff, retry_delay
14
+ from .._version import SDK_VERSION
13
15
 
14
16
 
15
17
  class HttpClient:
@@ -43,9 +45,12 @@ class HttpClient:
43
45
  self._log(f"POST {url}")
44
46
  try:
45
47
  resp = self._session.post(url, json=body, timeout=self.timeout)
46
- if resp.status_code >= 500 and attempt < self.retries:
47
- delay = 2 ** attempt
48
- self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
48
+ delay = (
49
+ retry_delay(resp.status_code, resp.headers.get('retry-after'), attempt)
50
+ if attempt < self.retries else None
51
+ )
52
+ if delay is not None:
53
+ self._log(f"Retry {attempt + 1} after {delay:.1f}s (status {resp.status_code})")
49
54
  time.sleep(delay)
50
55
  return self._request(path, body, attempt + 1)
51
56
  if resp.status_code >= 400:
@@ -72,15 +77,46 @@ class HttpClient:
72
77
  return {}
73
78
  except Exception as exc:
74
79
  if attempt < self.retries:
75
- delay = 2 ** attempt
76
- self._log(f"Retry {attempt + 1} after {delay}s (network error)")
80
+ delay = jittered_backoff(attempt)
81
+ self._log(f"Retry {attempt + 1} after {delay:.1f}s (network error)")
77
82
  time.sleep(delay)
78
83
  return self._request(path, body, attempt + 1)
79
84
  self._log(f"Error: {exc}")
80
85
  if self.on_error:
81
- self.on_error(exc)
86
+ try:
87
+ self.on_error(exc)
88
+ except Exception:
89
+ pass # a handler that raises must not surface into user code
82
90
  return {}
83
91
 
92
+ def post_raw(
93
+ self, path: str, body: Dict[str, Any], timeout: Optional[float] = None, gzip_body: bool = True,
94
+ ) -> tuple:
95
+ """One attempt, the status told: ``(status, retry_after, parsed_body)``.
96
+
97
+ The background sender (delivery/sender.py) decides what to do with a
98
+ 429, a 5xx or a timeout, so unlike ``_request`` this neither retries
99
+ nor swallows: it raises only on a network error or a timeout. Bodies
100
+ over 8 KB are gzipped.
101
+ """
102
+ import gzip as _gzip
103
+ import json as _json
104
+
105
+ url = f"{self.api_base}{path}"
106
+ raw = _json.dumps(body, default=str).encode("utf-8")
107
+ headers: Dict[str, str] = {"User-Agent": f"trodo-python/{SDK_VERSION}"}
108
+ data = raw
109
+ if gzip_body and len(raw) > 8 * 1024:
110
+ data = _gzip.compress(raw)
111
+ headers["Content-Encoding"] = "gzip"
112
+ self._log(f"POST {url} ({len(raw)} bytes{', gzip' if 'Content-Encoding' in headers else ''})")
113
+ resp = self._session.post(url, data=data, headers=headers, timeout=timeout or self.timeout)
114
+ try:
115
+ parsed = resp.json() if resp.text else None
116
+ except Exception:
117
+ parsed = {"body": resp.text[:300]}
118
+ return resp.status_code, resp.headers.get("retry-after"), parsed
119
+
84
120
  def _get(
85
121
  self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
86
122
  ) -> ApiResult:
@@ -91,9 +127,12 @@ class HttpClient:
91
127
  self._log(f"GET {url}")
92
128
  try:
93
129
  resp = self._session.get(url, params=params or {}, timeout=self.timeout)
94
- if resp.status_code >= 500 and attempt < self.retries:
95
- delay = 2 ** attempt
96
- self._log(f"Retry {attempt + 1} after {delay}s (status {resp.status_code})")
130
+ delay = (
131
+ retry_delay(resp.status_code, resp.headers.get('retry-after'), attempt)
132
+ if attempt < self.retries else None
133
+ )
134
+ if delay is not None:
135
+ self._log(f"Retry {attempt + 1} after {delay:.1f}s (status {resp.status_code})")
97
136
  time.sleep(delay)
98
137
  return self._get(path, params, attempt + 1)
99
138
  try:
@@ -108,8 +147,8 @@ class HttpClient:
108
147
  return data
109
148
  except Exception as exc:
110
149
  if attempt < self.retries:
111
- delay = 2 ** attempt
112
- self._log(f"Retry {attempt + 1} after {delay}s (network error)")
150
+ delay = jittered_backoff(attempt)
151
+ self._log(f"Retry {attempt + 1} after {delay:.1f}s (network error)")
113
152
  time.sleep(delay)
114
153
  return self._get(path, params, attempt + 1)
115
154
  self._log(f"Error: {exc}")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import functools
6
+ import inspect
6
7
  from typing import Any, Callable, Dict, List, Optional, Union
7
8
 
8
9
  from .api.http_client import HttpClient
@@ -14,6 +15,7 @@ from .queue.batch_flusher import BatchFlusher
14
15
  from .auto.auto_event_manager import AutoEventManager
15
16
  from .user_context import UserContext
16
17
  from .otel.processor import TrodoSpanProcessor
18
+ from .delivery.sender import Sender
17
19
  from .otel.wrap_agent import (
18
20
  wrap_agent as wrap_agent_ctx,
19
21
  span as span_ctx,
@@ -61,6 +63,8 @@ class TrodoClient:
61
63
  disable_instrumentations: Optional[List[str]] = None,
62
64
  otel_mode: str = "trodo",
63
65
  max_cached_users: int = 10_000,
66
+ delivery: str = "auto",
67
+ delivery_options: Optional[Dict[str, Any]] = None,
64
68
  ) -> None:
65
69
  if not site_id:
66
70
  raise ValueError("trodo-python: site_id is required")
@@ -105,7 +109,11 @@ class TrodoClient:
105
109
  # API server. An evicted user is simply rebuilt on next use.
106
110
  self._user_context_cache: LruDict[str, UserContext] = LruDict(max_cached_users)
107
111
 
108
- self._span_processor = TrodoSpanProcessor(http_client=self._http)
112
+ # Background delivery (2.24): runs and spans are queued and sent by a
113
+ # daemon thread; 'immediate' on serverless platforms (detected from
114
+ # the environment when delivery='auto').
115
+ self._sender = Sender(self._http, mode=delivery, on_error=on_error, debug=debug, **(delivery_options or {}))
116
+ self._span_processor = TrodoSpanProcessor(http_client=self._http, sender=self._sender)
109
117
  if auto_instrument:
110
118
  import os
111
119
  if os.environ.get("TRODO_LEGACY_INIT") == "1":
@@ -299,10 +307,12 @@ class TrodoClient:
299
307
  # Lifecycle
300
308
  # --------------------------------------------------------------------------
301
309
 
302
- def flush(self) -> None:
310
+ def flush(self, timeout: Optional[float] = None) -> None:
311
+ """Send everything queued and wait for it (bounded by ``timeout`` seconds, if given)."""
303
312
  if self._batch_flusher:
304
313
  self._batch_flusher.flush()
305
314
  self._span_processor.force_flush()
315
+ self._sender.flush(timeout)
306
316
  # 'otlp' mode: drain the OTel BatchSpanProcessor too — it batches for
307
317
  # up to 5s, so without this a short-lived process drops its spans.
308
318
  _flush_otlp_pipeline()
@@ -313,8 +323,13 @@ class TrodoClient:
313
323
  self._batch_flusher.stop()
314
324
  self._batch_flusher.flush()
315
325
  self._span_processor.shutdown()
326
+ self._sender.shutdown()
316
327
  _shutdown_otlp_pipeline()
317
328
 
329
+ def delivery_stats(self) -> Dict[str, Any]:
330
+ """What the background sender is doing: queued, in flight, sent, dropped (by reason), paused-until, mode."""
331
+ return self._sender.stats()
332
+
318
333
  # --------------------------------------------------------------------------
319
334
  # Agent Runs — Lemma-style seamless wrapping
320
335
  # --------------------------------------------------------------------------
@@ -364,16 +379,31 @@ class TrodoClient:
364
379
  """Decorator form of wrap_agent."""
365
380
 
366
381
  def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
367
- @functools.wraps(fn)
368
- def wrapper(*args: Any, **kwargs: Any) -> Any:
369
- with self.wrap_agent(
382
+ def _run():
383
+ return self.wrap_agent(
370
384
  agent_name,
371
385
  distinct_id=distinct_id,
372
386
  conversation_id=conversation_id,
373
387
  parent_run_id=parent_run_id,
374
388
  metadata=metadata,
375
389
  user=user,
376
- ) as run:
390
+ )
391
+
392
+ if inspect.iscoroutinefunction(fn):
393
+ # An ``async def`` agent (2.24). Before, the run closed the
394
+ # moment the coroutine was created and recorded the coroutine
395
+ # object as its output.
396
+ @functools.wraps(fn)
397
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
398
+ async with _run() as run:
399
+ result = await fn(*args, **kwargs)
400
+ run.set_output(result)
401
+ return result
402
+ return async_wrapper
403
+
404
+ @functools.wraps(fn)
405
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
406
+ with _run() as run:
377
407
  result = fn(*args, **kwargs)
378
408
  run.set_output(result)
379
409
  return result
@@ -600,7 +630,10 @@ class TrodoClient:
600
630
  "distinct_id": distinct_id,
601
631
  "attributes": metadata or {},
602
632
  }
603
- return self._http.post_run_feedback(run_id, payload)
633
+ # Queued behind the run it belongs to (2.24): never waits on the
634
+ # network, survives an outage, and lands after its run.
635
+ self._sender.enqueue({"run_id": run_id, "feedback": payload})
636
+ return {}
604
637
 
605
638
  def feedback_props(self, run_id: str, props: FeedbackProps) -> ApiResult:
606
639
  """Convenience — pass a FeedbackProps dataclass."""