trodo-python 2.23.4__py3-none-any.whl → 2.24.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trodo/__init__.py +18 -1
- trodo/_version.py +1 -0
- trodo/api/endpoints.py +3 -0
- trodo/api/http_client.py +33 -1
- trodo/client.py +40 -7
- trodo/delivery/__init__.py +0 -0
- trodo/delivery/sender.py +500 -0
- trodo/otel/processor.py +19 -14
- trodo/otel/wrap_agent.py +11 -0
- {trodo_python-2.23.4.dist-info → trodo_python-2.24.0.dist-info}/METADATA +1 -1
- {trodo_python-2.23.4.dist-info → trodo_python-2.24.0.dist-info}/RECORD +13 -10
- {trodo_python-2.23.4.dist-info → trodo_python-2.24.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.23.4.dist-info → trodo_python-2.24.0.dist-info}/top_level.txt +0 -0
trodo/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
|
trodo/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SDK_VERSION = "2.24.0"
|
trodo/api/endpoints.py
CHANGED
|
@@ -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
|
trodo/api/http_client.py
CHANGED
|
@@ -11,6 +11,7 @@ import requests
|
|
|
11
11
|
from ..types import ApiResult, EventPayload
|
|
12
12
|
from . import endpoints
|
|
13
13
|
from ._retry import jittered_backoff, retry_delay
|
|
14
|
+
from .._version import SDK_VERSION
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class HttpClient:
|
|
@@ -82,9 +83,40 @@ class HttpClient:
|
|
|
82
83
|
return self._request(path, body, attempt + 1)
|
|
83
84
|
self._log(f"Error: {exc}")
|
|
84
85
|
if self.on_error:
|
|
85
|
-
|
|
86
|
+
try:
|
|
87
|
+
self.on_error(exc)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass # a handler that raises must not surface into user code
|
|
86
90
|
return {}
|
|
87
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
|
+
|
|
88
120
|
def _get(
|
|
89
121
|
self, path: str, params: Optional[Dict[str, Any]] = None, attempt: int = 0
|
|
90
122
|
) -> ApiResult:
|
trodo/client.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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."""
|
|
File without changes
|
trodo/delivery/sender.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Background delivery of traces (2.24).
|
|
2
|
+
|
|
3
|
+
Before this, ``wrap_agent`` posted the finished run — and slept through every
|
|
4
|
+
retry and Retry-After — on the caller's thread before handing the agent's
|
|
5
|
+
result back: up to ~90 s added to one agent call when a team was over its
|
|
6
|
+
rate ceiling, the event loop frozen in an asyncio app, and then the trace was
|
|
7
|
+
dropped. Now a finished run (or a flush of spans) goes into this queue and
|
|
8
|
+
the agent returns at once. A daemon thread drains the queue to
|
|
9
|
+
``POST /api/sdk/batch`` every second or 50 items, gzipped.
|
|
10
|
+
|
|
11
|
+
The rules match what Langfuse, LangSmith, Braintrust, OpenTelemetry, Sentry
|
|
12
|
+
and PostHog have converged on (1 Oct 2026 survey):
|
|
13
|
+
- bounded queue (10,000 items / 64 MB). Full → the OLDEST are dropped, so
|
|
14
|
+
the traces being debugged right now survive; drops are counted and
|
|
15
|
+
reported to the server with the next batch.
|
|
16
|
+
- retries while nobody waits: 429 pauses the whole sender until
|
|
17
|
+
Retry-After (a client over its limit delivers late, not never); 5xx, 408
|
|
18
|
+
and network errors back off (1 s → 60 s) for up to 10 minutes per item,
|
|
19
|
+
then it is dropped as ``gave_up``; 413 splits the batch, a single
|
|
20
|
+
oversized item is dropped as ``too_large``; any other 4xx is a bad item:
|
|
21
|
+
dropped as ``rejected``, reported once.
|
|
22
|
+
- exit: ``atexit`` flushes (bounded by ``exit_flush_s``).
|
|
23
|
+
- ``immediate`` mode for platforms that freeze between requests (Lambda,
|
|
24
|
+
Cloud Functions, Cloud Run, Vercel): the item is sent now, with short
|
|
25
|
+
retries, on the caller's thread. ``auto`` picks it from the platform's
|
|
26
|
+
environment variables.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import atexit
|
|
32
|
+
import gzip
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import random
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from collections import deque
|
|
40
|
+
from typing import Any, Callable, Deque, Dict, List, Optional
|
|
41
|
+
|
|
42
|
+
from ..api import endpoints
|
|
43
|
+
|
|
44
|
+
Item = Dict[str, Any]
|
|
45
|
+
|
|
46
|
+
DEFAULTS = dict(
|
|
47
|
+
flush_interval_s=1.0, flush_at=50, max_batch_bytes=4 * 1024 * 1024,
|
|
48
|
+
max_queue_items=10_000, max_queue_bytes=64 * 1024 * 1024,
|
|
49
|
+
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,
|
|
51
|
+
)
|
|
52
|
+
_MIN_BACKOFF_S = 1.0
|
|
53
|
+
_MAX_BACKOFF_S = 60.0
|
|
54
|
+
_TRANSIENT = {408, 425, 429, 500, 502, 503, 504}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def detect_mode(env: Optional[Dict[str, str]] = None) -> str:
|
|
58
|
+
"""'immediate' on serverless / freeze-between-requests platforms, else 'background'."""
|
|
59
|
+
e = os.environ if env is None else env
|
|
60
|
+
if e.get("AWS_LAMBDA_FUNCTION_NAME") or e.get("LAMBDA_TASK_ROOT"):
|
|
61
|
+
return "immediate"
|
|
62
|
+
if e.get("FUNCTION_TARGET") or e.get("FUNCTION_NAME") or e.get("K_SERVICE"):
|
|
63
|
+
return "immediate" # Cloud Functions / Cloud Run (CPU off between requests)
|
|
64
|
+
if e.get("NETLIFY") or e.get("VERCEL") or e.get("FUNCTIONS_WORKER_RUNTIME"):
|
|
65
|
+
return "immediate"
|
|
66
|
+
return "background"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_retry_after_s(value: Optional[str], now: float) -> Optional[float]:
|
|
70
|
+
if not value:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
secs = float(value)
|
|
74
|
+
return secs if secs > 0 else None
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
from email.utils import parsedate_to_datetime
|
|
79
|
+
delta = parsedate_to_datetime(value).timestamp() - now
|
|
80
|
+
return delta if delta > 0 else None
|
|
81
|
+
except Exception:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Queued:
|
|
86
|
+
__slots__ = ("item", "nbytes", "enqueued_at", "first_tried_at", "attempts")
|
|
87
|
+
|
|
88
|
+
def __init__(self, item: Item, nbytes: int, enqueued_at: float) -> None:
|
|
89
|
+
self.item = item
|
|
90
|
+
self.nbytes = nbytes
|
|
91
|
+
self.enqueued_at = enqueued_at
|
|
92
|
+
self.first_tried_at: Optional[float] = None
|
|
93
|
+
self.attempts = 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Sender:
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
http: Any,
|
|
100
|
+
mode: str = "auto",
|
|
101
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
102
|
+
debug: bool = False,
|
|
103
|
+
now: Callable[[], float] = time.time,
|
|
104
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
105
|
+
register_atexit: bool = True,
|
|
106
|
+
**options: Any,
|
|
107
|
+
) -> None:
|
|
108
|
+
self._http = http
|
|
109
|
+
self.mode = mode if mode in ("background", "immediate") else detect_mode()
|
|
110
|
+
self._o = {**DEFAULTS, **{k: v for k, v in options.items() if v is not None}}
|
|
111
|
+
self._on_error = on_error
|
|
112
|
+
self._debug = debug
|
|
113
|
+
self._now = now
|
|
114
|
+
self._sleep = sleep
|
|
115
|
+
self._queue: Deque[_Queued] = deque()
|
|
116
|
+
self._queue_bytes = 0
|
|
117
|
+
self._lock = threading.Lock()
|
|
118
|
+
self._wake = threading.Condition(self._lock)
|
|
119
|
+
self._paused_until: Optional[float] = None
|
|
120
|
+
self._not_before: float = 0.0 # backoff after a failed batch
|
|
121
|
+
self._stopped = False
|
|
122
|
+
self._flush_requested = False
|
|
123
|
+
self._in_flight = 0
|
|
124
|
+
self._stats = {"sent": 0, "dropped": 0, "dropped_by_reason": {}}
|
|
125
|
+
self._unreported = {"count": 0, "by_reason": {}}
|
|
126
|
+
self._warned_drop = False
|
|
127
|
+
self._thread: Optional[threading.Thread] = None
|
|
128
|
+
if self.mode == "background" and register_atexit:
|
|
129
|
+
atexit.register(self._atexit)
|
|
130
|
+
|
|
131
|
+
# ── public ────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def enqueue(self, item: Item) -> None:
|
|
134
|
+
"""Queue an item. Returns at once in background mode; in immediate mode
|
|
135
|
+
returns once the item is sent (or given up on)."""
|
|
136
|
+
if self._stopped:
|
|
137
|
+
return
|
|
138
|
+
if self.mode == "immediate":
|
|
139
|
+
self._send_immediate(item)
|
|
140
|
+
return
|
|
141
|
+
nbytes = _size_of(item)
|
|
142
|
+
if nbytes > self._o["max_batch_bytes"]:
|
|
143
|
+
self._drop(1, "too_large", f"an item of {nbytes} bytes exceeds the {self._o['max_batch_bytes']}-byte request limit")
|
|
144
|
+
return
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._queue.append(_Queued(item, nbytes, self._now()))
|
|
147
|
+
self._queue_bytes += nbytes
|
|
148
|
+
shed = self._shed_locked()
|
|
149
|
+
self._ensure_thread_locked()
|
|
150
|
+
if len(self._queue) >= self._o["flush_at"]:
|
|
151
|
+
self._wake.notify()
|
|
152
|
+
if shed:
|
|
153
|
+
self._drop_queue_full(shed)
|
|
154
|
+
|
|
155
|
+
def flush(self, timeout_s: Optional[float] = None) -> None:
|
|
156
|
+
"""Send everything queued and wait for it, for at most ``timeout_s``."""
|
|
157
|
+
if self.mode == "immediate":
|
|
158
|
+
return
|
|
159
|
+
deadline = None if timeout_s is None else self._now() + timeout_s
|
|
160
|
+
with self._lock:
|
|
161
|
+
self._not_before = 0.0
|
|
162
|
+
self._flush_requested = True
|
|
163
|
+
self._ensure_thread_locked()
|
|
164
|
+
self._wake.notify_all()
|
|
165
|
+
while (self._queue or self._in_flight) and not self._stopped:
|
|
166
|
+
remaining = None if deadline is None else deadline - self._now()
|
|
167
|
+
if remaining is not None and remaining <= 0:
|
|
168
|
+
break
|
|
169
|
+
self._wake.wait(timeout=min(0.1, remaining) if remaining is not None else 0.1)
|
|
170
|
+
self._flush_requested = False
|
|
171
|
+
|
|
172
|
+
def shutdown(self, timeout_s: Optional[float] = None) -> None:
|
|
173
|
+
self.flush(self._o["exit_flush_s"] if timeout_s is None else timeout_s)
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._stopped = True
|
|
176
|
+
left = len(self._queue)
|
|
177
|
+
self._queue.clear()
|
|
178
|
+
self._queue_bytes = 0
|
|
179
|
+
self._wake.notify_all()
|
|
180
|
+
if left:
|
|
181
|
+
self._drop(left, "shutdown", f"{left} items were still queued at shutdown")
|
|
182
|
+
|
|
183
|
+
def stats(self) -> Dict[str, Any]:
|
|
184
|
+
with self._lock:
|
|
185
|
+
return {
|
|
186
|
+
"queued": len(self._queue), "queued_bytes": self._queue_bytes, "in_flight": self._in_flight,
|
|
187
|
+
"sent": self._stats["sent"], "dropped": self._stats["dropped"],
|
|
188
|
+
"dropped_by_reason": dict(self._stats["dropped_by_reason"]),
|
|
189
|
+
"paused_until": self._paused_until, "mode": self.mode,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# ── background thread ─────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def _ensure_thread_locked(self) -> None:
|
|
195
|
+
if self._thread is None or not self._thread.is_alive():
|
|
196
|
+
self._thread = threading.Thread(target=self._run, name="trodo-sender", daemon=True)
|
|
197
|
+
self._thread.start()
|
|
198
|
+
|
|
199
|
+
def _run(self) -> None:
|
|
200
|
+
interval = self._o["flush_interval_s"]
|
|
201
|
+
while True:
|
|
202
|
+
with self._lock:
|
|
203
|
+
while True:
|
|
204
|
+
if self._stopped:
|
|
205
|
+
return
|
|
206
|
+
if not self._queue:
|
|
207
|
+
self._wake.wait(timeout=interval)
|
|
208
|
+
continue
|
|
209
|
+
now = self._now()
|
|
210
|
+
gate = max(self._paused_until or 0.0, self._not_before)
|
|
211
|
+
if gate > now:
|
|
212
|
+
self._wake.wait(timeout=gate - now)
|
|
213
|
+
continue
|
|
214
|
+
if self._flush_requested or len(self._queue) >= self._o["flush_at"]:
|
|
215
|
+
break
|
|
216
|
+
age = now - self._queue[0].enqueued_at
|
|
217
|
+
if age >= interval:
|
|
218
|
+
break
|
|
219
|
+
self._wake.wait(timeout=interval - age)
|
|
220
|
+
batch = self._take_batch_locked()
|
|
221
|
+
self._in_flight += 1
|
|
222
|
+
try:
|
|
223
|
+
self._send_batch(batch)
|
|
224
|
+
finally:
|
|
225
|
+
with self._lock:
|
|
226
|
+
self._in_flight -= 1
|
|
227
|
+
self._wake.notify_all()
|
|
228
|
+
|
|
229
|
+
def _take_batch_locked(self) -> List[_Queued]:
|
|
230
|
+
out: List[_Queued] = []
|
|
231
|
+
nbytes = 0
|
|
232
|
+
while self._queue and len(out) < self._o["flush_at"]:
|
|
233
|
+
nxt = self._queue[0]
|
|
234
|
+
if out and nbytes + nxt.nbytes > self._o["max_batch_bytes"]:
|
|
235
|
+
break
|
|
236
|
+
out.append(self._queue.popleft())
|
|
237
|
+
nbytes += nxt.nbytes
|
|
238
|
+
self._queue_bytes -= nbytes
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
def _send_batch(self, batch: List[_Queued]) -> None:
|
|
242
|
+
t = self._now()
|
|
243
|
+
for q in batch:
|
|
244
|
+
q.attempts += 1
|
|
245
|
+
if q.first_tried_at is None:
|
|
246
|
+
q.first_tried_at = t
|
|
247
|
+
try:
|
|
248
|
+
status, retry_after, body = self._http.post_raw(
|
|
249
|
+
endpoints.BATCH, self._envelope(batch), timeout=self._o["request_timeout_s"], gzip_body=True,
|
|
250
|
+
)
|
|
251
|
+
except Exception as exc: # network error, timeout
|
|
252
|
+
self._log(f"batch of {len(batch)} failed: {exc}")
|
|
253
|
+
self._requeue(batch, None)
|
|
254
|
+
return
|
|
255
|
+
if status == 200 and isinstance(body, dict) and isinstance(body.get("results"), list):
|
|
256
|
+
self._reported()
|
|
257
|
+
self._settle(batch, body)
|
|
258
|
+
return
|
|
259
|
+
if status == 429:
|
|
260
|
+
self._pause(parse_retry_after_s(retry_after, self._now()))
|
|
261
|
+
self._requeue(batch, 0.0)
|
|
262
|
+
return
|
|
263
|
+
if status == 413:
|
|
264
|
+
self._split(batch)
|
|
265
|
+
return
|
|
266
|
+
if status in _TRANSIENT:
|
|
267
|
+
self._requeue(batch, None)
|
|
268
|
+
return
|
|
269
|
+
if status == 404:
|
|
270
|
+
self._legacy_send(batch)
|
|
271
|
+
return
|
|
272
|
+
self._drop(len(batch), "rejected", f"POST {endpoints.BATCH} -> HTTP {status}: {json.dumps(body)[:300]}")
|
|
273
|
+
|
|
274
|
+
def _settle(self, batch: List[_Queued], body: Dict[str, Any]) -> None:
|
|
275
|
+
keep: List[_Queued] = []
|
|
276
|
+
rejected = 0
|
|
277
|
+
first_error = None
|
|
278
|
+
results = body["results"]
|
|
279
|
+
for i, q in enumerate(batch):
|
|
280
|
+
r = results[i] if i < len(results) and isinstance(results[i], dict) else {"status": 500}
|
|
281
|
+
st = int(r.get("status", 500))
|
|
282
|
+
if 200 <= st < 300:
|
|
283
|
+
with self._lock:
|
|
284
|
+
self._stats["sent"] += 1
|
|
285
|
+
continue
|
|
286
|
+
if st == 429 or st >= 500 or st == 408:
|
|
287
|
+
keep.append(q)
|
|
288
|
+
continue
|
|
289
|
+
if st == 413:
|
|
290
|
+
self._drop(1, "too_large", r.get("error") or "payload too large")
|
|
291
|
+
continue
|
|
292
|
+
rejected += 1
|
|
293
|
+
first_error = first_error or f"HTTP {st}: {r.get('error', '')}"
|
|
294
|
+
if rejected:
|
|
295
|
+
self._drop(rejected, "rejected", f"{rejected} item(s) rejected by the server ({first_error})")
|
|
296
|
+
ra = body.get("retry_after_ms")
|
|
297
|
+
if ra:
|
|
298
|
+
self._pause(float(ra) / 1000.0)
|
|
299
|
+
if keep:
|
|
300
|
+
self._requeue(keep, 0.0 if ra else None)
|
|
301
|
+
|
|
302
|
+
def _requeue(self, batch: List[_Queued], wait_s: Optional[float]) -> None:
|
|
303
|
+
t = self._now()
|
|
304
|
+
alive: List[_Queued] = []
|
|
305
|
+
for q in batch:
|
|
306
|
+
if q.first_tried_at is not None and t - q.first_tried_at > self._o["max_retry_s"]:
|
|
307
|
+
self._drop(1, "gave_up", f"an item was retried for {int(self._o['max_retry_s'])} s without being accepted")
|
|
308
|
+
else:
|
|
309
|
+
alive.append(q)
|
|
310
|
+
if not alive:
|
|
311
|
+
return
|
|
312
|
+
with self._lock:
|
|
313
|
+
for q in reversed(alive):
|
|
314
|
+
self._queue.appendleft(q)
|
|
315
|
+
self._queue_bytes += sum(q.nbytes for q in alive)
|
|
316
|
+
shed = self._shed_locked()
|
|
317
|
+
attempts = max(q.attempts for q in alive)
|
|
318
|
+
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
|
+
self._not_before = t + backoff
|
|
320
|
+
if shed:
|
|
321
|
+
self._drop_queue_full(shed)
|
|
322
|
+
|
|
323
|
+
def _split(self, batch: List[_Queued]) -> None:
|
|
324
|
+
if len(batch) == 1:
|
|
325
|
+
self._drop(1, "too_large", f"an item of {batch[0].nbytes} bytes was refused as too large")
|
|
326
|
+
return
|
|
327
|
+
with self._lock:
|
|
328
|
+
for q in reversed(batch):
|
|
329
|
+
self._queue.appendleft(q)
|
|
330
|
+
self._queue_bytes += sum(q.nbytes for q in batch)
|
|
331
|
+
self._o["flush_at"] = max(1, len(batch) // 2)
|
|
332
|
+
self._wake.notify()
|
|
333
|
+
|
|
334
|
+
def _pause(self, secs: Optional[float]) -> None:
|
|
335
|
+
wait = min(secs if secs is not None else 30.0, self._o["max_retry_after_s"])
|
|
336
|
+
with self._lock:
|
|
337
|
+
self._paused_until = self._now() + wait
|
|
338
|
+
self._log(f"rate limited; paused for {int(wait)} s")
|
|
339
|
+
|
|
340
|
+
def _legacy_send(self, batch: List[_Queued]) -> None:
|
|
341
|
+
"""A server before 2.24 has no /batch: each item goes to its own route,
|
|
342
|
+
one attempt, the status read — a refusal keeps the item like the batch
|
|
343
|
+
path would, instead of the old blocking request that dropped it."""
|
|
344
|
+
keep: List[_Queued] = []
|
|
345
|
+
wait: Optional[float] = None
|
|
346
|
+
for q in batch:
|
|
347
|
+
path, body = _legacy_route(q.item)
|
|
348
|
+
try:
|
|
349
|
+
status, retry_after, resp = self._http.post_raw(path, body, timeout=self._o["request_timeout_s"], gzip_body=True)
|
|
350
|
+
except Exception:
|
|
351
|
+
status, retry_after, resp = None, None, None
|
|
352
|
+
if status is not None and 200 <= status < 300:
|
|
353
|
+
with self._lock:
|
|
354
|
+
self._stats["sent"] += 1
|
|
355
|
+
continue
|
|
356
|
+
if status is None or status in _TRANSIENT:
|
|
357
|
+
keep.append(q)
|
|
358
|
+
if status == 429:
|
|
359
|
+
wait = parse_retry_after_s(retry_after, self._now()) or wait
|
|
360
|
+
continue
|
|
361
|
+
self._drop(1, "too_large" if status == 413 else "rejected", f"POST {path} -> HTTP {status}: {json.dumps(resp)[:200]}")
|
|
362
|
+
if wait is not None:
|
|
363
|
+
self._pause(wait)
|
|
364
|
+
if keep:
|
|
365
|
+
self._requeue(keep, 0.0 if wait is not None else None)
|
|
366
|
+
|
|
367
|
+
def _shed_locked(self) -> int:
|
|
368
|
+
"""Drop the oldest until the queue fits; returns how many (the caller reports, outside the lock)."""
|
|
369
|
+
n = 0
|
|
370
|
+
while len(self._queue) > self._o["max_queue_items"] or self._queue_bytes > self._o["max_queue_bytes"]:
|
|
371
|
+
q = self._queue.popleft()
|
|
372
|
+
self._queue_bytes -= q.nbytes
|
|
373
|
+
n += 1
|
|
374
|
+
return n
|
|
375
|
+
|
|
376
|
+
def _drop_queue_full(self, n: int) -> None:
|
|
377
|
+
self._drop(n, "queue_full", f"the delivery queue is full ({self._o['max_queue_items']} items / {self._o['max_queue_bytes'] // 1048576} MB); the {n} oldest were dropped")
|
|
378
|
+
|
|
379
|
+
def _envelope(self, batch: List[_Queued]) -> Dict[str, Any]:
|
|
380
|
+
items = [q.item for q in batch]
|
|
381
|
+
with self._lock:
|
|
382
|
+
if not self._unreported["count"]:
|
|
383
|
+
return {"items": items}
|
|
384
|
+
return {"items": items, "dropped": {"count": self._unreported["count"], "by_reason": dict(self._unreported["by_reason"])}}
|
|
385
|
+
|
|
386
|
+
def _reported(self) -> None:
|
|
387
|
+
with self._lock:
|
|
388
|
+
self._unreported = {"count": 0, "by_reason": {}}
|
|
389
|
+
|
|
390
|
+
def _drop(self, n: int, reason: str, message: str) -> None:
|
|
391
|
+
with self._lock:
|
|
392
|
+
self._stats["dropped"] += n
|
|
393
|
+
self._stats["dropped_by_reason"][reason] = self._stats["dropped_by_reason"].get(reason, 0) + n
|
|
394
|
+
self._unreported["count"] += n
|
|
395
|
+
self._unreported["by_reason"][reason] = self._unreported["by_reason"].get(reason, 0) + n
|
|
396
|
+
warned = self._warned_drop
|
|
397
|
+
self._warned_drop = True
|
|
398
|
+
err = RuntimeError(f"trodo-python: {message}")
|
|
399
|
+
if self._on_error:
|
|
400
|
+
try:
|
|
401
|
+
self._on_error(err)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
return
|
|
405
|
+
if warned:
|
|
406
|
+
self._log(message)
|
|
407
|
+
return
|
|
408
|
+
sys.stderr.write(
|
|
409
|
+
f"[trodo-python] {message}\n"
|
|
410
|
+
"[trodo-python] Traces were dropped. Pass on_error to trodo.init(on_error=...) to handle these yourself; "
|
|
411
|
+
"further drops in this process stay quiet.\n"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
# ── immediate ─────────────────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
def _send_immediate(self, item: Item) -> None:
|
|
417
|
+
deadline = self._now() + self._o["immediate_retry_s"]
|
|
418
|
+
attempt = 0
|
|
419
|
+
q = _Queued(item, 0, self._now())
|
|
420
|
+
while True:
|
|
421
|
+
attempt += 1
|
|
422
|
+
q.attempts = attempt
|
|
423
|
+
try:
|
|
424
|
+
status, retry_after, body = self._http.post_raw(
|
|
425
|
+
endpoints.BATCH, self._envelope([q]),
|
|
426
|
+
timeout=min(self._o["request_timeout_s"], max(1.0, deadline - self._now())), gzip_body=True,
|
|
427
|
+
)
|
|
428
|
+
except Exception:
|
|
429
|
+
status, retry_after, body = None, None, None
|
|
430
|
+
if status == 200 and isinstance(body, dict):
|
|
431
|
+
self._reported()
|
|
432
|
+
results = body.get("results") or []
|
|
433
|
+
r = results[0] if results and isinstance(results[0], dict) else {"status": 200}
|
|
434
|
+
st = int(r.get("status", 200))
|
|
435
|
+
if 200 <= st < 300:
|
|
436
|
+
with self._lock:
|
|
437
|
+
self._stats["sent"] += 1
|
|
438
|
+
return
|
|
439
|
+
if not (st == 429 or st >= 500 or st == 408):
|
|
440
|
+
self._drop(1, "too_large" if st == 413 else "rejected", f"HTTP {st}: {r.get('error', '')}")
|
|
441
|
+
return
|
|
442
|
+
elif status == 404:
|
|
443
|
+
# No /batch on this server: the item's own route, within the same budget.
|
|
444
|
+
path, body = _legacy_route(item)
|
|
445
|
+
try:
|
|
446
|
+
r_status, r_retry, _ = self._http.post_raw(path, body, timeout=min(self._o["request_timeout_s"], max(1.0, deadline - self._now())), gzip_body=True)
|
|
447
|
+
except Exception:
|
|
448
|
+
r_status, r_retry = None, None
|
|
449
|
+
if r_status is not None and 200 <= r_status < 300:
|
|
450
|
+
with self._lock:
|
|
451
|
+
self._stats["sent"] += 1
|
|
452
|
+
return
|
|
453
|
+
if r_status is not None and r_status not in _TRANSIENT:
|
|
454
|
+
self._drop(1, "rejected", f"POST {path} -> HTTP {r_status}")
|
|
455
|
+
return
|
|
456
|
+
status, retry_after = r_status, r_retry
|
|
457
|
+
elif status is not None and status not in _TRANSIENT:
|
|
458
|
+
self._drop(1, "rejected", f"POST {endpoints.BATCH} -> HTTP {status}")
|
|
459
|
+
return
|
|
460
|
+
if status == 429:
|
|
461
|
+
wait = parse_retry_after_s(retry_after, self._now()) or 2.0
|
|
462
|
+
else:
|
|
463
|
+
wait = _MIN_BACKOFF_S * 2 ** (attempt - 1)
|
|
464
|
+
wait = min(wait, deadline - self._now())
|
|
465
|
+
if wait <= 0:
|
|
466
|
+
self._drop(1, "gave_up", f"an item could not be delivered within {int(self._o['immediate_retry_s'])} s (immediate mode)")
|
|
467
|
+
return
|
|
468
|
+
self._sleep(wait)
|
|
469
|
+
|
|
470
|
+
def _atexit(self) -> None:
|
|
471
|
+
try:
|
|
472
|
+
self.flush(self._o["exit_flush_s"])
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
def _log(self, msg: str) -> None:
|
|
477
|
+
if self._debug:
|
|
478
|
+
sys.stderr.write(f"[trodo-python] {msg}\n")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _legacy_route(item: Item):
|
|
482
|
+
"""The pre-2.24 route and body for one item."""
|
|
483
|
+
if "run" in item:
|
|
484
|
+
return endpoints.RUNS_INGEST, item
|
|
485
|
+
if "feedback" in item:
|
|
486
|
+
return f"{endpoints.RUNS_BASE}/{item['run_id']}/feedback", item["feedback"]
|
|
487
|
+
if "run_id" in item:
|
|
488
|
+
return f"{endpoints.RUNS_BASE}/{item['run_id']}/spans", {"spans": item["spans"]}
|
|
489
|
+
return endpoints.SPANS_APPEND, {"spans": item["spans"]}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _size_of(item: Any) -> int:
|
|
493
|
+
try:
|
|
494
|
+
return len(json.dumps(item, default=str).encode("utf-8"))
|
|
495
|
+
except Exception:
|
|
496
|
+
return 0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def gzip_json(body: Any) -> bytes:
|
|
500
|
+
return gzip.compress(json.dumps(body, default=str).encode("utf-8"))
|
trodo/otel/processor.py
CHANGED
|
@@ -107,8 +107,11 @@ class TrodoSpanProcessor:
|
|
|
107
107
|
http_client: Any,
|
|
108
108
|
flush_interval_s: float = 5.0,
|
|
109
109
|
max_spans_per_run: int = 1000,
|
|
110
|
+
sender: Any = None,
|
|
110
111
|
) -> None:
|
|
111
112
|
self._http = http_client
|
|
113
|
+
# The background sender (2.24). Without one, sends block as before 2.24.
|
|
114
|
+
self._sender = sender
|
|
112
115
|
self._flush_interval = flush_interval_s
|
|
113
116
|
self._max_spans = max_spans_per_run
|
|
114
117
|
self._pending: Dict[str, List[TrodoSpan]] = {}
|
|
@@ -142,10 +145,16 @@ class TrodoSpanProcessor:
|
|
|
142
145
|
if len(bucket) < self._max_spans:
|
|
143
146
|
bucket.append(span)
|
|
144
147
|
if joined:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
self._send_spans(span.run_id, [span.to_dict()])
|
|
149
|
+
|
|
150
|
+
def _send_spans(self, run_id: str, spans: List[Dict[str, Any]]) -> None:
|
|
151
|
+
if self._sender is not None:
|
|
152
|
+
self._sender.enqueue({"run_id": run_id, "spans": spans})
|
|
153
|
+
return
|
|
154
|
+
try:
|
|
155
|
+
self._http.post_spans_append(run_id, spans)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
149
158
|
|
|
150
159
|
def get_pending(self, run_id: str) -> List[TrodoSpan]:
|
|
151
160
|
with self._lock:
|
|
@@ -158,6 +167,10 @@ class TrodoSpanProcessor:
|
|
|
158
167
|
payload: Dict[str, Any] = {"run": run.to_dict()}
|
|
159
168
|
if spans:
|
|
160
169
|
payload["spans"] = [s.to_dict() for s in spans]
|
|
170
|
+
if self._sender is not None:
|
|
171
|
+
# Queued: the agent's caller never waits on the network (2.24).
|
|
172
|
+
self._sender.enqueue(payload)
|
|
173
|
+
return
|
|
161
174
|
try:
|
|
162
175
|
self._http.post_run_ingest(payload)
|
|
163
176
|
except Exception:
|
|
@@ -186,10 +199,7 @@ class TrodoSpanProcessor:
|
|
|
186
199
|
"""Stream spans for a long-running or joined run without finalising."""
|
|
187
200
|
if not spans:
|
|
188
201
|
return
|
|
189
|
-
|
|
190
|
-
self._http.post_spans_append(run_id, [s.to_dict() for s in spans])
|
|
191
|
-
except Exception:
|
|
192
|
-
pass
|
|
202
|
+
self._send_spans(run_id, [s.to_dict() for s in spans])
|
|
193
203
|
|
|
194
204
|
def force_flush(self) -> None:
|
|
195
205
|
"""Flush any buffered spans that don't have a run finalise yet."""
|
|
@@ -199,12 +209,7 @@ class TrodoSpanProcessor:
|
|
|
199
209
|
for run_id, spans in pending:
|
|
200
210
|
if not spans:
|
|
201
211
|
continue
|
|
202
|
-
|
|
203
|
-
self._http.post_spans_append(
|
|
204
|
-
run_id, [s.to_dict() for s in spans]
|
|
205
|
-
)
|
|
206
|
-
except Exception:
|
|
207
|
-
pass
|
|
212
|
+
self._send_spans(run_id, [s.to_dict() for s in spans])
|
|
208
213
|
|
|
209
214
|
def shutdown(self) -> None:
|
|
210
215
|
self._shutdown = True
|
trodo/otel/wrap_agent.py
CHANGED
|
@@ -611,6 +611,17 @@ class wrap_agent:
|
|
|
611
611
|
return self._exit_otel(exc_type, exc, tb)
|
|
612
612
|
return self._exit_trodo(exc_type, exc, tb)
|
|
613
613
|
|
|
614
|
+
# ``async with trodo.wrap_agent(...)`` (2.24). Entering and leaving a run
|
|
615
|
+
# is bookkeeping in this process — contextvars and a queue hand-off — so
|
|
616
|
+
# the async form is the sync one; the event loop is never blocked, because
|
|
617
|
+
# the send happens on the sender's thread (or, in immediate mode, is the
|
|
618
|
+
# one blocking call a serverless handler wants before it returns).
|
|
619
|
+
async def __aenter__(self) -> RunHandle:
|
|
620
|
+
return self.__enter__()
|
|
621
|
+
|
|
622
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
623
|
+
return self.__exit__(exc_type, exc, tb)
|
|
624
|
+
|
|
614
625
|
# ----------------------------------------------------------------------
|
|
615
626
|
# Legacy 'trodo' transport — TrodoSpanProcessor + Trodo HTTP API path.
|
|
616
627
|
# ----------------------------------------------------------------------
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
2
|
-
trodo/
|
|
1
|
+
trodo/__init__.py,sha256=ahX3Z2VcSDifpb6xg4RNUJzLe64Oka2TY2vNLey2f8M,27913
|
|
2
|
+
trodo/_version.py,sha256=fSs1-zmlRM8V2BkYDf5P5v8U0xEG-QAN-jPcvTDYQLU,23
|
|
3
|
+
trodo/client.py,sha256=6k6HeKrGV2720kK17--u6BjbhAUFZNUzpd9yEYiaQOs,24137
|
|
3
4
|
trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
|
|
4
5
|
trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
|
|
5
6
|
trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
7
|
trodo/api/_retry.py,sha256=mHhuzfe3QuYa6j9BqRYVClvnYH0o3WkXXngmSwqWfgY,1214
|
|
7
8
|
trodo/api/async_client.py,sha256=qF97NbvoYbNhem8uXtPh14EG4JVAhEAzUCYn7busaaA,3609
|
|
8
|
-
trodo/api/endpoints.py,sha256=
|
|
9
|
-
trodo/api/http_client.py,sha256=
|
|
9
|
+
trodo/api/endpoints.py,sha256=SGgVELcVfwuCrRwlVPW2j0KYkaMtwLFPpdMtGtlqPeA,1574
|
|
10
|
+
trodo/api/http_client.py,sha256=Vnw8l_QyoslCF2lJIxoH2uoUNFAK-8S_YfnWN8f4ZS0,11362
|
|
10
11
|
trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
12
|
trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
|
|
13
|
+
trodo/delivery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
trodo/delivery/sender.py,sha256=urO7m6mRMnSEW7_cNAmJ4A6jtDNxuihZHOxCx1jQScI,21348
|
|
12
15
|
trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
16
|
trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEvm7hE,2827
|
|
14
17
|
trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
|
|
@@ -20,11 +23,11 @@ trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
|
|
|
20
23
|
trodo/otel/auto_instrument.py,sha256=I_F3NCvc0JML2UpX9PaxFM3OGzMSuEYYfxZYTwkQDuo,26224
|
|
21
24
|
trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
|
|
22
25
|
trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
|
|
23
|
-
trodo/otel/processor.py,sha256=
|
|
26
|
+
trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
|
|
24
27
|
trodo/otel/prompt_trace.py,sha256=d2A6UhlWAgHZLIKtU0NxgGUpHk_z3F2Nm4u4QTAC3tA,7332
|
|
25
28
|
trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
|
|
26
29
|
trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
|
|
27
|
-
trodo/otel/wrap_agent.py,sha256=
|
|
30
|
+
trodo/otel/wrap_agent.py,sha256=qx2jnuVIMZTv-PpRt-qFWqMWz1YO4jqY5pwgBQirVDo,44519
|
|
28
31
|
trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
|
|
29
32
|
trodo/prompts/compile.py,sha256=XoKDKz6Yaofhrk-87AVW-SnwbiXLvfp161AL2bAFzNY,8802
|
|
30
33
|
trodo/prompts/template.py,sha256=eGO2ZVNOw6JK7Ffj1UCYDMJFYyX5qQW2POlmFJSQpAo,9975
|
|
@@ -37,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
|
|
|
37
40
|
trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
|
|
38
41
|
trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
|
|
39
42
|
trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
|
|
40
|
-
trodo_python-2.
|
|
41
|
-
trodo_python-2.
|
|
42
|
-
trodo_python-2.
|
|
43
|
-
trodo_python-2.
|
|
43
|
+
trodo_python-2.24.0.dist-info/METADATA,sha256=zDBPYuJWTElYQQgr1S8qeDzl1chRfwbNtESdc-PFe1M,25308
|
|
44
|
+
trodo_python-2.24.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
45
|
+
trodo_python-2.24.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
|
|
46
|
+
trodo_python-2.24.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|