trodo-python 2.23.4__py3-none-any.whl → 2.25.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/auto_instrument.py +73 -2
- trodo/otel/processor.py +19 -14
- trodo/otel/prompt_trace.py +185 -1
- trodo/otel/register.py +8 -0
- trodo/otel/wrap_agent.py +11 -0
- trodo/prompts/types.py +6 -0
- {trodo_python-2.23.4.dist-info → trodo_python-2.25.0.dist-info}/METADATA +1 -1
- {trodo_python-2.23.4.dist-info → trodo_python-2.25.0.dist-info}/RECORD +17 -14
- {trodo_python-2.23.4.dist-info → trodo_python-2.25.0.dist-info}/WHEEL +0 -0
- {trodo_python-2.23.4.dist-info → trodo_python-2.25.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.25.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.25.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/auto_instrument.py
CHANGED
|
@@ -14,7 +14,11 @@ from datetime import datetime, timezone
|
|
|
14
14
|
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
15
15
|
|
|
16
16
|
from .context import get_active_context
|
|
17
|
-
from .prompt_trace import
|
|
17
|
+
from .prompt_trace import (
|
|
18
|
+
prompt_attributes,
|
|
19
|
+
prompt_ref_from_fingerprint,
|
|
20
|
+
remember_prompt,
|
|
21
|
+
)
|
|
18
22
|
from .processor import TrodoSpan, TrodoSpanProcessor
|
|
19
23
|
|
|
20
24
|
# Always-on, one-shot warnings. Silent skips (missing opentelemetry-sdk) are the
|
|
@@ -313,7 +317,7 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
313
317
|
if temperature is None:
|
|
314
318
|
temperature = attrs.get("ai.settings.temperature")
|
|
315
319
|
|
|
316
|
-
|
|
320
|
+
trodo_span = TrodoSpan(
|
|
317
321
|
span_id=span_id,
|
|
318
322
|
run_id=run_id,
|
|
319
323
|
parent_span_id=parent_span_id,
|
|
@@ -338,6 +342,26 @@ def otel_span_to_trodo_span(otel_span: Any) -> Optional[TrodoSpan]:
|
|
|
338
342
|
output=completion,
|
|
339
343
|
attributes=attrs or None,
|
|
340
344
|
)
|
|
345
|
+
# Exact-match prompt link for auto-instrumented model calls. compile()
|
|
346
|
+
# before the run leaves no run context and no carrier on this serialised
|
|
347
|
+
# input, so compare the input's content fingerprint with what compile()
|
|
348
|
+
# registered. Overrides the on_start "most recent in this run" guess, which
|
|
349
|
+
# an exact match is strictly better than. No match changes nothing.
|
|
350
|
+
if kind == "llm" and prompt:
|
|
351
|
+
ref = prompt_ref_from_fingerprint(prompt)
|
|
352
|
+
if ref:
|
|
353
|
+
trodo_span.attributes = {**(trodo_span.attributes or {}), **prompt_attributes(ref)}
|
|
354
|
+
state = _span_prompt_state.pop(id(otel_span), None)
|
|
355
|
+
if state is None and ctx is not None:
|
|
356
|
+
state = getattr(ctx, "prompt_state", None)
|
|
357
|
+
remember_prompt(state, ref)
|
|
358
|
+
return trodo_span
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# Run prompt state captured at on_start (contextvars are often gone by on_end),
|
|
362
|
+
# keyed by id(span) and bounded so spans that never end cannot leak.
|
|
363
|
+
_SPAN_STATE_MAX = 1024
|
|
364
|
+
_span_prompt_state: "Dict[int, Any]" = {}
|
|
341
365
|
|
|
342
366
|
|
|
343
367
|
# Subclass the REAL SpanProcessor when the sdk is importable (it always is on
|
|
@@ -385,11 +409,16 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
|
|
|
385
409
|
current = state.get("current") if isinstance(state, dict) else None
|
|
386
410
|
for k, v in prompt_attributes(current).items():
|
|
387
411
|
set_attr(k, v)
|
|
412
|
+
if isinstance(state, dict):
|
|
413
|
+
if len(_span_prompt_state) >= _SPAN_STATE_MAX:
|
|
414
|
+
_span_prompt_state.pop(next(iter(_span_prompt_state)), None)
|
|
415
|
+
_span_prompt_state[id(span)] = state
|
|
388
416
|
except Exception:
|
|
389
417
|
pass # never break user code
|
|
390
418
|
|
|
391
419
|
def on_end(self, span: Any) -> None:
|
|
392
420
|
trodo_span = otel_span_to_trodo_span(span)
|
|
421
|
+
_span_prompt_state.pop(id(span), None)
|
|
393
422
|
if trodo_span is not None:
|
|
394
423
|
self._processor.enqueue_span(trodo_span)
|
|
395
424
|
|
|
@@ -401,6 +430,48 @@ class _OtelAdapter(_SpanProcessorBase): # type: ignore[valid-type,misc]
|
|
|
401
430
|
return True
|
|
402
431
|
|
|
403
432
|
|
|
433
|
+
class OtlpPromptLinker(_SpanProcessorBase): # type: ignore[valid-type,misc]
|
|
434
|
+
"""OTLP mode: link provider spans to a managed prompt by exact fingerprint.
|
|
435
|
+
|
|
436
|
+
In OTLP mode finished spans go straight to the exporter, so there is no
|
|
437
|
+
TrodoSpan to enrich. ``on_ending`` (opentelemetry-sdk >= 1.43) is the last
|
|
438
|
+
point where the span is still writable. Register this BEFORE the batch
|
|
439
|
+
processor. On older SDKs ``on_ending`` is never called and this is inert.
|
|
440
|
+
"""
|
|
441
|
+
|
|
442
|
+
def __init__(self) -> None:
|
|
443
|
+
try:
|
|
444
|
+
super().__init__()
|
|
445
|
+
except TypeError:
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
def on_ending(self, span: Any) -> None:
|
|
452
|
+
try:
|
|
453
|
+
attrs = dict(getattr(span, "attributes", None) or {})
|
|
454
|
+
if _infer_kind(attrs) != "llm":
|
|
455
|
+
return
|
|
456
|
+
inp = _extract_input(attrs)
|
|
457
|
+
ref = prompt_ref_from_fingerprint(inp) if inp else None
|
|
458
|
+
if not ref:
|
|
459
|
+
return
|
|
460
|
+
for k, v in prompt_attributes(ref).items():
|
|
461
|
+
span.set_attribute(k, v)
|
|
462
|
+
except Exception:
|
|
463
|
+
pass # never break the export pipeline
|
|
464
|
+
|
|
465
|
+
def on_end(self, span: Any) -> None:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
def shutdown(self) -> None:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
472
|
+
return True
|
|
473
|
+
|
|
474
|
+
|
|
404
475
|
def _instrument(module_name: str, *preferred: str) -> None:
|
|
405
476
|
"""Import ``module_name`` and call ``instrument()`` on its Instrumentor.
|
|
406
477
|
|
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/prompt_trace.py
CHANGED
|
@@ -9,13 +9,25 @@ moved to a different version.
|
|
|
9
9
|
The link is best-effort and never raises: if there is no active run context
|
|
10
10
|
(the prompt was compiled outside ``wrap_agent``), recording is a silent no-op.
|
|
11
11
|
|
|
12
|
+
A third route covers compile-before-run followed by a model call made by an
|
|
13
|
+
AUTO-INSTRUMENTED provider SDK (no run context at compile time, no carrier on
|
|
14
|
+
the span's serialised input): compile() registers a sha256 fingerprint of the
|
|
15
|
+
rendered (role, text) list in a small bounded registry, and a model span whose
|
|
16
|
+
input normalises to the same fingerprint is linked. Exact match or nothing --
|
|
17
|
+
never a "last compiled prompt wins" guess. Only hashes are retained, never
|
|
18
|
+
message text. Providers that rewrite the messages will not match, by design.
|
|
19
|
+
|
|
12
20
|
Mirrors ``sdks/trodo-node-sdk/src/otel/promptTrace.ts``.
|
|
13
21
|
"""
|
|
14
22
|
|
|
15
23
|
from __future__ import annotations
|
|
16
24
|
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
17
29
|
from collections import OrderedDict
|
|
18
|
-
from typing import Any, Dict, List, Optional
|
|
30
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
31
|
|
|
20
32
|
from .context import get_active_context
|
|
21
33
|
|
|
@@ -176,3 +188,175 @@ def _stamp_active_otel_span(ref: PromptRef) -> None:
|
|
|
176
188
|
span.set_attribute(k, v)
|
|
177
189
|
except Exception: # noqa: BLE001
|
|
178
190
|
pass # not in OTLP mode / opentelemetry not installed — no-op
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Content-fingerprint registry
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
#: Max fingerprints kept process-wide (LRU). Keep in sync with the Node SDK.
|
|
198
|
+
FINGERPRINT_MAX_ENTRIES = 256
|
|
199
|
+
#: How long a fingerprint stays matchable. Keep in sync with the Node SDK.
|
|
200
|
+
FINGERPRINT_TTL_SECONDS = 10 * 60
|
|
201
|
+
|
|
202
|
+
# fingerprint -> (ref, registered_at). Insertion order == age order.
|
|
203
|
+
_fingerprints: "OrderedDict[str, Tuple[PromptRef, float]]" = OrderedDict()
|
|
204
|
+
_fp_lock = threading.Lock()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _now() -> float:
|
|
208
|
+
return time.time()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _text_of_content(content: Any) -> Optional[str]:
|
|
212
|
+
if isinstance(content, str):
|
|
213
|
+
return content
|
|
214
|
+
if not isinstance(content, list):
|
|
215
|
+
return None
|
|
216
|
+
out = ""
|
|
217
|
+
for part in content:
|
|
218
|
+
if isinstance(part, str):
|
|
219
|
+
out += part
|
|
220
|
+
elif isinstance(part, dict):
|
|
221
|
+
# {type:'text', text} (OpenAI/Anthropic/compiled), {content} (OTel GenAI parts)
|
|
222
|
+
if isinstance(part.get("text"), str):
|
|
223
|
+
out += part["text"]
|
|
224
|
+
elif part.get("type") == "text" and isinstance(part.get("content"), str):
|
|
225
|
+
out += part["content"]
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _normalise_message(m: Any) -> Optional[Tuple[str, str]]:
|
|
230
|
+
if not isinstance(m, dict):
|
|
231
|
+
return None
|
|
232
|
+
# OpenInference indexed messages nest as {message: {role, content}}.
|
|
233
|
+
if isinstance(m.get("message"), dict):
|
|
234
|
+
m = m["message"]
|
|
235
|
+
role = m.get("role")
|
|
236
|
+
if not isinstance(role, str):
|
|
237
|
+
return None
|
|
238
|
+
content = m.get("content")
|
|
239
|
+
text = _text_of_content(content if content is not None else m.get("parts"))
|
|
240
|
+
if text is None:
|
|
241
|
+
return None
|
|
242
|
+
role = role.lower()
|
|
243
|
+
return ("system" if role == "developer" else role, text)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def normalise_messages(value: Any) -> Optional[List[Tuple[str, str]]]:
|
|
247
|
+
"""Normalise a provider request's messages into a list of (role, text).
|
|
248
|
+
|
|
249
|
+
Handles an OpenAI-style list, Anthropic ``{system, messages}`` (system
|
|
250
|
+
becomes a leading 'system' message), a CompiledPrompt, and the JSON string
|
|
251
|
+
form provider instrumentations record. None when not message-shaped.
|
|
252
|
+
"""
|
|
253
|
+
v = value
|
|
254
|
+
if hasattr(v, "messages") and not isinstance(v, (dict, list, str)):
|
|
255
|
+
v = getattr(v, "messages") # CompiledPrompt
|
|
256
|
+
if isinstance(v, (bytes, bytearray)):
|
|
257
|
+
return None
|
|
258
|
+
if isinstance(v, str):
|
|
259
|
+
t = v.strip()
|
|
260
|
+
if not t or t[0] not in "[{":
|
|
261
|
+
return None
|
|
262
|
+
try:
|
|
263
|
+
v = json.loads(t)
|
|
264
|
+
except Exception: # noqa: BLE001
|
|
265
|
+
return None
|
|
266
|
+
out: List[Tuple[str, str]] = []
|
|
267
|
+
if isinstance(v, dict):
|
|
268
|
+
items = v.get("messages")
|
|
269
|
+
if not isinstance(items, list):
|
|
270
|
+
return None
|
|
271
|
+
if v.get("system") is not None:
|
|
272
|
+
sys_text = _text_of_content(v["system"])
|
|
273
|
+
if sys_text is not None:
|
|
274
|
+
out.append(("system", sys_text))
|
|
275
|
+
elif isinstance(v, list):
|
|
276
|
+
items = v
|
|
277
|
+
else:
|
|
278
|
+
return None
|
|
279
|
+
for m in items:
|
|
280
|
+
n = _normalise_message(m)
|
|
281
|
+
if n is None:
|
|
282
|
+
return None # any non-message item: not a messages list, no guessing
|
|
283
|
+
out.append(n)
|
|
284
|
+
return out or None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def fingerprint_messages(value: Any) -> Optional[str]:
|
|
288
|
+
"""sha256 over the normalised (role, text) list; length-prefixed."""
|
|
289
|
+
norm = normalise_messages(value)
|
|
290
|
+
if norm is None:
|
|
291
|
+
return None
|
|
292
|
+
h = hashlib.sha256()
|
|
293
|
+
for role, text in norm:
|
|
294
|
+
rb = text.encode("utf-8", "surrogatepass")
|
|
295
|
+
h.update(f"{len(role)}:{role}|{len(rb)}:".encode("utf-8"))
|
|
296
|
+
h.update(rb)
|
|
297
|
+
h.update(b";")
|
|
298
|
+
return h.hexdigest()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _prune(now: float) -> None:
|
|
302
|
+
while _fingerprints:
|
|
303
|
+
key = next(iter(_fingerprints))
|
|
304
|
+
if now - _fingerprints[key][1] > FINGERPRINT_TTL_SECONDS:
|
|
305
|
+
del _fingerprints[key]
|
|
306
|
+
else:
|
|
307
|
+
break # insertion order == age order, so the rest are younger
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def register_compiled_messages(messages: Any, ref: PromptRef) -> None:
|
|
311
|
+
"""Register the rendered messages of a just-compiled prompt.
|
|
312
|
+
|
|
313
|
+
Best-effort, never raises, retains only a hash. Two prompt versions that
|
|
314
|
+
render byte-identical content collide on purpose (the messages cannot tell
|
|
315
|
+
them apart); the most recent compile wins.
|
|
316
|
+
"""
|
|
317
|
+
try:
|
|
318
|
+
if not ref or not ref.get("name"):
|
|
319
|
+
return
|
|
320
|
+
fp = fingerprint_messages(messages)
|
|
321
|
+
if fp is None:
|
|
322
|
+
return
|
|
323
|
+
now = _now()
|
|
324
|
+
with _fp_lock:
|
|
325
|
+
_prune(now)
|
|
326
|
+
_fingerprints.pop(fp, None)
|
|
327
|
+
_fingerprints[fp] = (ref, now)
|
|
328
|
+
while len(_fingerprints) > FINGERPRINT_MAX_ENTRIES:
|
|
329
|
+
_fingerprints.popitem(last=False)
|
|
330
|
+
except Exception: # noqa: BLE001
|
|
331
|
+
pass # traceability must never break compile
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def prompt_ref_from_fingerprint(value: Any) -> Optional[PromptRef]:
|
|
335
|
+
"""Exact-match lookup for a provider span's input.
|
|
336
|
+
|
|
337
|
+
Zero cost when nothing has been compiled: the input is never parsed.
|
|
338
|
+
"""
|
|
339
|
+
if not _fingerprints or value is None:
|
|
340
|
+
return None
|
|
341
|
+
try:
|
|
342
|
+
with _fp_lock:
|
|
343
|
+
_prune(_now())
|
|
344
|
+
if not _fingerprints:
|
|
345
|
+
return None
|
|
346
|
+
fp = fingerprint_messages(value)
|
|
347
|
+
if fp is None:
|
|
348
|
+
return None
|
|
349
|
+
with _fp_lock:
|
|
350
|
+
hit = _fingerprints.get(fp)
|
|
351
|
+
return hit[0] if hit else None
|
|
352
|
+
except Exception: # noqa: BLE001
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _reset_fingerprints_for_tests() -> None:
|
|
357
|
+
with _fp_lock:
|
|
358
|
+
_fingerprints.clear()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _fingerprint_count_for_tests() -> int:
|
|
362
|
+
return len(_fingerprints)
|
trodo/otel/register.py
CHANGED
|
@@ -44,6 +44,12 @@ _registered: Optional[RegisterOtelResult] = None
|
|
|
44
44
|
_otlp_processor: Optional[Any] = None
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _prompt_linker() -> Any:
|
|
48
|
+
from .auto_instrument import OtlpPromptLinker
|
|
49
|
+
|
|
50
|
+
return OtlpPromptLinker()
|
|
51
|
+
|
|
52
|
+
|
|
47
53
|
def flush_otlp_pipeline() -> None:
|
|
48
54
|
"""Force-flush the 'otlp'-mode export pipeline. No-op in 'trodo' mode."""
|
|
49
55
|
if _otlp_processor is not None:
|
|
@@ -172,6 +178,7 @@ def _setup_otlp_mode(
|
|
|
172
178
|
if existing_class not in ("ProxyTracerProvider", "NoOpTracerProvider"):
|
|
173
179
|
# User's pipeline owns the provider — just attach our processor.
|
|
174
180
|
if hasattr(existing, "add_span_processor"):
|
|
181
|
+
existing.add_span_processor(_prompt_linker())
|
|
175
182
|
existing.add_span_processor(batch_processor)
|
|
176
183
|
# Auto-instrumentations are presumably already wired by the existing
|
|
177
184
|
# pipeline. We still set the transport so wrap_agent dispatches.
|
|
@@ -189,6 +196,7 @@ def _setup_otlp_mode(
|
|
|
189
196
|
"trodo.sdk.mode": "otlp",
|
|
190
197
|
})
|
|
191
198
|
provider = TracerProvider(resource=resource)
|
|
199
|
+
provider.add_span_processor(_prompt_linker())
|
|
192
200
|
provider.add_span_processor(batch_processor)
|
|
193
201
|
otel_trace.set_tracer_provider(provider)
|
|
194
202
|
|
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
|
# ----------------------------------------------------------------------
|
trodo/prompts/types.py
CHANGED
|
@@ -95,6 +95,7 @@ class ManagedPrompt:
|
|
|
95
95
|
try:
|
|
96
96
|
from ..otel.prompt_trace import (
|
|
97
97
|
record_compiled_prompt,
|
|
98
|
+
register_compiled_messages,
|
|
98
99
|
tag_carrier_with_prompt,
|
|
99
100
|
)
|
|
100
101
|
|
|
@@ -113,6 +114,11 @@ class ManagedPrompt:
|
|
|
113
114
|
# since callers pass one or the other.
|
|
114
115
|
tag_carrier_with_prompt(compiled, ref)
|
|
115
116
|
tag_carrier_with_prompt(getattr(compiled, "messages", None), ref)
|
|
117
|
+
# (3) a content fingerprint, so an auto-instrumented provider
|
|
118
|
+
# span handed exactly these messages (compiled before the
|
|
119
|
+
# run; no carrier on the serialised input) links by exact
|
|
120
|
+
# match.
|
|
121
|
+
register_compiled_messages(getattr(compiled, "messages", None), ref)
|
|
116
122
|
except Exception: # noqa: BLE001
|
|
117
123
|
pass # traceability must never break compile
|
|
118
124
|
return compiled
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
trodo/__init__.py,sha256=
|
|
2
|
-
trodo/
|
|
1
|
+
trodo/__init__.py,sha256=ftnDcQvvOi9VnJoynkzzZCNwqHRhC7pbXS836bTwizo,27913
|
|
2
|
+
trodo/_version.py,sha256=50fFgmf3lAeDxqh-LvVujTqoWvZoG7fTVAY5gKvc-yQ,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
|
|
@@ -17,18 +20,18 @@ trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8p
|
|
|
17
20
|
trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
|
|
18
21
|
trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
|
|
19
22
|
trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
|
|
20
|
-
trodo/otel/auto_instrument.py,sha256=
|
|
23
|
+
trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
|
|
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=
|
|
24
|
-
trodo/otel/prompt_trace.py,sha256
|
|
25
|
-
trodo/otel/register.py,sha256=
|
|
26
|
+
trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
|
|
27
|
+
trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
|
|
28
|
+
trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
|
|
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
|
|
31
|
-
trodo/prompts/types.py,sha256=
|
|
34
|
+
trodo/prompts/types.py,sha256=Mjn6x6f7dI3TIVHSvzM2xs_gchuh5sCwUfBsW-YLVm0,5654
|
|
32
35
|
trodo/queue/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
36
|
trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,1298
|
|
34
37
|
trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
|
|
@@ -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.25.0.dist-info/METADATA,sha256=_e2LmqFYjaZQdhh6Nr-Uj9wr_f88ZYLZDlD17N431as,25308
|
|
44
|
+
trodo_python-2.25.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
45
|
+
trodo_python-2.25.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
|
|
46
|
+
trodo_python-2.25.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|