spanlight 0.1.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.
- spanlight/__init__.py +59 -0
- spanlight/_client.py +459 -0
- spanlight/_config.py +138 -0
- spanlight/_context.py +79 -0
- spanlight/_decorator.py +345 -0
- spanlight/_experiments.py +256 -0
- spanlight/_experiments_api.py +182 -0
- spanlight/_experiments_parse.py +68 -0
- spanlight/_experiments_report.py +100 -0
- spanlight/_experiments_runs.py +122 -0
- spanlight/_experiments_setup.py +85 -0
- spanlight/_exporter.py +462 -0
- spanlight/_globals.py +198 -0
- spanlight/_ids.py +45 -0
- spanlight/_prompt_client.py +241 -0
- spanlight/_prompts.py +247 -0
- spanlight/_request_hash.py +58 -0
- spanlight/_scores.py +57 -0
- spanlight/_serialize.py +126 -0
- spanlight/_span.py +337 -0
- spanlight/_templates.py +238 -0
- spanlight/_version.py +3 -0
- spanlight/integrations/__init__.py +5 -0
- spanlight/integrations/_common.py +422 -0
- spanlight/integrations/anthropic.py +349 -0
- spanlight/integrations/openai.py +356 -0
- spanlight/py.typed +0 -0
- spanlight-0.1.0.dist-info/METADATA +447 -0
- spanlight-0.1.0.dist-info/RECORD +31 -0
- spanlight-0.1.0.dist-info/WHEEL +4 -0
- spanlight-0.1.0.dist-info/licenses/LICENSE +21 -0
spanlight/_exporter.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Background batching exporter for ``POST /v1/traces`` and ``POST /v1/scores``.
|
|
2
|
+
|
|
3
|
+
Design goals, in priority order:
|
|
4
|
+
|
|
5
|
+
1. **Never hurt the host application.** :meth:`BatchExporter.export` only
|
|
6
|
+
appends to an in-memory queue under a lock; all network I/O happens on a
|
|
7
|
+
daemon thread. Nothing in this module raises into caller code.
|
|
8
|
+
2. **Bounded memory.** The queue holds at most ``max_queue`` spans. When it is
|
|
9
|
+
full the *oldest* span is dropped (recent data is usually more useful) and
|
|
10
|
+
the drop is counted.
|
|
11
|
+
3. **Deliver what we can.** Batches are retried with jittered exponential
|
|
12
|
+
backoff on network errors, ``429`` and ``5xx``, honouring ``Retry-After``.
|
|
13
|
+
Other ``4xx`` responses are permanent failures and are logged, not retried.
|
|
14
|
+
|
|
15
|
+
Spans and scores share one queue (and so one ``max_queue`` bound). Each cycle
|
|
16
|
+
takes up to ``batch_size`` items, sends the spans first and the scores after,
|
|
17
|
+
so a score recorded after its span never reaches the server before it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import email.utils
|
|
23
|
+
import gzip
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import random
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from collections import deque
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from ._serialize import JsonValue
|
|
38
|
+
from ._version import __version__
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("spanlight")
|
|
41
|
+
|
|
42
|
+
SpanPayload = dict[str, JsonValue]
|
|
43
|
+
|
|
44
|
+
_MAX_LOGGED_REJECTIONS = 10
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _Target:
|
|
49
|
+
"""One endpoint the exporter delivers to."""
|
|
50
|
+
|
|
51
|
+
url: str
|
|
52
|
+
body_key: str # the list's key in the request body, and the plural noun in logs
|
|
53
|
+
id_field: str # the payload field logged when the server rejects an item
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_SPANS = "spans"
|
|
57
|
+
_SCORES = "scores"
|
|
58
|
+
_Item = tuple[str, SpanPayload]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BatchExporter:
|
|
62
|
+
"""Queue spans and scores in memory and ship them to the ingestion API in batches.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
url: Full ingestion URL, e.g. ``http://localhost:8000/v1/traces``.
|
|
66
|
+
scores_url: Full score ingestion URL, e.g. ``http://localhost:8000/v1/scores``.
|
|
67
|
+
Without it, scores are dropped.
|
|
68
|
+
api_key: Project API key sent as a Bearer token.
|
|
69
|
+
batch_size: Maximum spans per request. A full batch is sent at once.
|
|
70
|
+
flush_interval: Maximum seconds a span waits before being sent.
|
|
71
|
+
max_queue: Maximum spans buffered in memory.
|
|
72
|
+
gzip_body: Compress request bodies with gzip.
|
|
73
|
+
timeout: Per-request timeout in seconds.
|
|
74
|
+
max_retries: Retries per batch after the first attempt.
|
|
75
|
+
transport: Optional custom ``httpx`` transport (useful for tests and
|
|
76
|
+
proxies).
|
|
77
|
+
sleep: Function used to wait between retries. Defaults to a wait that
|
|
78
|
+
is interrupted by :meth:`shutdown`.
|
|
79
|
+
jitter: Function returning a float in ``[0, 1)`` used to randomize
|
|
80
|
+
backoff delays.
|
|
81
|
+
base_backoff: Backoff before the first retry, in seconds.
|
|
82
|
+
max_backoff: Upper bound for any single wait, in seconds.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
url: str,
|
|
89
|
+
api_key: str,
|
|
90
|
+
scores_url: str | None = None,
|
|
91
|
+
batch_size: int = 100,
|
|
92
|
+
flush_interval: float = 2.0,
|
|
93
|
+
max_queue: int = 10_000,
|
|
94
|
+
gzip_body: bool = False,
|
|
95
|
+
timeout: float = 10.0,
|
|
96
|
+
max_retries: int = 5,
|
|
97
|
+
transport: httpx.BaseTransport | None = None,
|
|
98
|
+
sleep: Callable[[float], None] | None = None,
|
|
99
|
+
jitter: Callable[[], float] = random.random,
|
|
100
|
+
base_backoff: float = 0.5,
|
|
101
|
+
max_backoff: float = 30.0,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._targets: dict[str, _Target] = {_SPANS: _Target(url, _SPANS, "span_id")}
|
|
104
|
+
if scores_url is not None:
|
|
105
|
+
self._targets[_SCORES] = _Target(scores_url, _SCORES, "id")
|
|
106
|
+
self._scores_unsupported_logged = False
|
|
107
|
+
self._headers = {
|
|
108
|
+
"Authorization": f"Bearer {api_key}",
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
"User-Agent": f"spanlight-python/{__version__}",
|
|
111
|
+
}
|
|
112
|
+
if gzip_body:
|
|
113
|
+
self._headers["Content-Encoding"] = "gzip"
|
|
114
|
+
self._batch_size = batch_size
|
|
115
|
+
self._flush_interval = flush_interval
|
|
116
|
+
self._max_queue = max_queue
|
|
117
|
+
self._gzip = gzip_body
|
|
118
|
+
self._timeout = timeout
|
|
119
|
+
self._max_retries = max_retries
|
|
120
|
+
self._transport = transport
|
|
121
|
+
self._sleep = sleep if sleep is not None else self._interruptible_sleep
|
|
122
|
+
self._jitter = jitter
|
|
123
|
+
self._base_backoff = base_backoff
|
|
124
|
+
self._max_backoff = max_backoff
|
|
125
|
+
|
|
126
|
+
self._condition = threading.Condition()
|
|
127
|
+
self._queue: deque[_Item] = deque()
|
|
128
|
+
self._in_flight = 0
|
|
129
|
+
self._flush_requested = False
|
|
130
|
+
self._closed = False
|
|
131
|
+
self._abort_retries = threading.Event()
|
|
132
|
+
self._worker: threading.Thread | None = None
|
|
133
|
+
self._client: httpx.Client | None = None
|
|
134
|
+
self._pid = os.getpid()
|
|
135
|
+
|
|
136
|
+
self._dropped = 0
|
|
137
|
+
self._failed = 0
|
|
138
|
+
self._sent = 0
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ #
|
|
141
|
+
# Public API (called from user threads)
|
|
142
|
+
# ------------------------------------------------------------------ #
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def dropped_count(self) -> int:
|
|
146
|
+
"""Spans and scores discarded because the queue was full."""
|
|
147
|
+
return self._dropped
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def failed_count(self) -> int:
|
|
151
|
+
"""Spans and scores lost because their batch could not be delivered."""
|
|
152
|
+
return self._failed
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def sent_count(self) -> int:
|
|
156
|
+
"""Spans and scores delivered and accepted by the server."""
|
|
157
|
+
return self._sent
|
|
158
|
+
|
|
159
|
+
def export(self, span: SpanPayload) -> None:
|
|
160
|
+
"""Enqueue one serialized span. Never blocks on I/O and never raises."""
|
|
161
|
+
self._enqueue(_SPANS, span)
|
|
162
|
+
|
|
163
|
+
def export_score(self, score: SpanPayload) -> None:
|
|
164
|
+
"""Enqueue one serialized score. Never blocks on I/O and never raises."""
|
|
165
|
+
if _SCORES not in self._targets:
|
|
166
|
+
logger.debug("No score endpoint configured; dropping a score")
|
|
167
|
+
return
|
|
168
|
+
self._enqueue(_SCORES, score)
|
|
169
|
+
|
|
170
|
+
def _enqueue(self, kind: str, payload: SpanPayload) -> None:
|
|
171
|
+
try:
|
|
172
|
+
self._check_fork()
|
|
173
|
+
with self._condition:
|
|
174
|
+
if self._closed:
|
|
175
|
+
return
|
|
176
|
+
if len(self._queue) >= self._max_queue:
|
|
177
|
+
self._queue.popleft()
|
|
178
|
+
self._dropped += 1
|
|
179
|
+
if self._dropped == 1:
|
|
180
|
+
logger.warning(
|
|
181
|
+
"Spanlight queue is full (%d items); dropping the oldest",
|
|
182
|
+
self._max_queue,
|
|
183
|
+
)
|
|
184
|
+
self._queue.append((kind, payload))
|
|
185
|
+
if len(self._queue) >= self._batch_size:
|
|
186
|
+
self._condition.notify_all()
|
|
187
|
+
self._ensure_worker()
|
|
188
|
+
except Exception:
|
|
189
|
+
logger.debug("Failed to enqueue %s", kind, exc_info=True)
|
|
190
|
+
|
|
191
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
192
|
+
"""Send everything queued so far and wait until it is delivered.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
timeout: Maximum seconds to wait; ``None`` waits indefinitely.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
``True`` if the queue drained in time, ``False`` otherwise.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
self._ensure_worker()
|
|
202
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
203
|
+
with self._condition:
|
|
204
|
+
self._flush_requested = True
|
|
205
|
+
self._condition.notify_all()
|
|
206
|
+
while self._queue or self._in_flight:
|
|
207
|
+
if self._worker is None or not self._worker.is_alive():
|
|
208
|
+
return False
|
|
209
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
210
|
+
if remaining is not None and remaining <= 0:
|
|
211
|
+
return False
|
|
212
|
+
self._condition.wait(remaining)
|
|
213
|
+
return True
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.debug("Flush failed", exc_info=True)
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
def shutdown(self, timeout: float = 5.0) -> None:
|
|
219
|
+
"""Flush remaining spans (bounded by ``timeout``) and stop the worker.
|
|
220
|
+
|
|
221
|
+
Spans that cannot be delivered within ``timeout`` are discarded so the
|
|
222
|
+
interpreter can exit promptly. Calling ``shutdown`` twice is harmless.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
deadline = time.monotonic() + timeout
|
|
226
|
+
self.flush(timeout)
|
|
227
|
+
with self._condition:
|
|
228
|
+
self._closed = True
|
|
229
|
+
self._condition.notify_all()
|
|
230
|
+
self._abort_retries.set()
|
|
231
|
+
worker = self._worker
|
|
232
|
+
if worker is not None and worker is not threading.current_thread():
|
|
233
|
+
worker.join(max(deadline - time.monotonic(), 0.0))
|
|
234
|
+
if self._client is not None:
|
|
235
|
+
self._client.close()
|
|
236
|
+
except Exception:
|
|
237
|
+
logger.debug("Shutdown failed", exc_info=True)
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
# Worker thread
|
|
241
|
+
# ------------------------------------------------------------------ #
|
|
242
|
+
|
|
243
|
+
def _check_fork(self) -> None:
|
|
244
|
+
"""Reset state in a forked child, where the parent's thread does not exist."""
|
|
245
|
+
if os.getpid() == self._pid:
|
|
246
|
+
return
|
|
247
|
+
self._pid = os.getpid()
|
|
248
|
+
self._condition = threading.Condition()
|
|
249
|
+
self._queue = deque()
|
|
250
|
+
self._in_flight = 0
|
|
251
|
+
self._worker = None
|
|
252
|
+
self._client = None
|
|
253
|
+
|
|
254
|
+
def _ensure_worker(self) -> None:
|
|
255
|
+
with self._condition:
|
|
256
|
+
if self._closed:
|
|
257
|
+
return
|
|
258
|
+
if self._worker is not None and self._worker.is_alive():
|
|
259
|
+
return
|
|
260
|
+
self._worker = threading.Thread(
|
|
261
|
+
target=self._run,
|
|
262
|
+
name="spanlight-exporter",
|
|
263
|
+
daemon=True,
|
|
264
|
+
)
|
|
265
|
+
self._worker.start()
|
|
266
|
+
|
|
267
|
+
def _run(self) -> None:
|
|
268
|
+
while True:
|
|
269
|
+
batch = self._next_batch()
|
|
270
|
+
if batch is None:
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
for kind in (_SPANS, _SCORES):
|
|
274
|
+
payloads = [payload for item_kind, payload in batch if item_kind == kind]
|
|
275
|
+
if payloads:
|
|
276
|
+
self._send_guarded(self._targets[kind], payloads)
|
|
277
|
+
finally:
|
|
278
|
+
with self._condition:
|
|
279
|
+
self._in_flight -= len(batch)
|
|
280
|
+
self._condition.notify_all()
|
|
281
|
+
|
|
282
|
+
def _next_batch(self) -> list[_Item] | None:
|
|
283
|
+
"""Block until a batch is due; return ``None`` when the exporter is closed."""
|
|
284
|
+
with self._condition:
|
|
285
|
+
deadline = time.monotonic() + self._flush_interval
|
|
286
|
+
while self._should_wait():
|
|
287
|
+
remaining = deadline - time.monotonic()
|
|
288
|
+
if remaining <= 0:
|
|
289
|
+
break
|
|
290
|
+
self._condition.wait(remaining)
|
|
291
|
+
|
|
292
|
+
if not self._queue:
|
|
293
|
+
self._flush_requested = False
|
|
294
|
+
self._condition.notify_all()
|
|
295
|
+
return None if self._closed else []
|
|
296
|
+
|
|
297
|
+
size = min(self._batch_size, len(self._queue))
|
|
298
|
+
batch = [self._queue.popleft() for _ in range(size)]
|
|
299
|
+
self._in_flight += len(batch)
|
|
300
|
+
return batch
|
|
301
|
+
|
|
302
|
+
def _should_wait(self) -> bool:
|
|
303
|
+
if self._closed or self._flush_requested:
|
|
304
|
+
return False
|
|
305
|
+
return len(self._queue) < self._batch_size
|
|
306
|
+
|
|
307
|
+
def _http_client(self) -> httpx.Client:
|
|
308
|
+
if self._client is None:
|
|
309
|
+
self._client = httpx.Client(transport=self._transport, timeout=self._timeout)
|
|
310
|
+
return self._client
|
|
311
|
+
|
|
312
|
+
def _send_guarded(self, target: _Target, batch: list[SpanPayload]) -> None:
|
|
313
|
+
"""Send one target's part of a cycle; a crash here never stops the next part."""
|
|
314
|
+
try:
|
|
315
|
+
self._send_batch(target, batch)
|
|
316
|
+
except Exception:
|
|
317
|
+
self._failed += len(batch)
|
|
318
|
+
logger.warning(
|
|
319
|
+
"Unexpected error exporting %d %s", len(batch), target.body_key, exc_info=True
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def _send_batch(self, target: _Target, batch: list[SpanPayload]) -> None:
|
|
323
|
+
if not batch:
|
|
324
|
+
return
|
|
325
|
+
body = json.dumps({target.body_key: batch}, separators=(",", ":"), default=str).encode()
|
|
326
|
+
if self._gzip:
|
|
327
|
+
body = gzip.compress(body)
|
|
328
|
+
|
|
329
|
+
for attempt in range(self._max_retries + 1):
|
|
330
|
+
retry_after: float | None = None
|
|
331
|
+
try:
|
|
332
|
+
response = self._http_client().post(target.url, content=body, headers=self._headers)
|
|
333
|
+
except httpx.HTTPError as exc:
|
|
334
|
+
failure = f"{type(exc).__name__}: {exc}"
|
|
335
|
+
else:
|
|
336
|
+
status = response.status_code
|
|
337
|
+
if 200 <= status < 300:
|
|
338
|
+
self._handle_success(target, response, batch)
|
|
339
|
+
return
|
|
340
|
+
if status == 413 and len(batch) > 1:
|
|
341
|
+
self._split_and_send(target, batch)
|
|
342
|
+
return
|
|
343
|
+
if not _is_retryable(status):
|
|
344
|
+
self._failed += len(batch)
|
|
345
|
+
self._log_rejected_batch(target, response, len(batch))
|
|
346
|
+
return
|
|
347
|
+
failure = f"HTTP {status}"
|
|
348
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
349
|
+
|
|
350
|
+
if attempt >= self._max_retries or self._abort_retries.is_set():
|
|
351
|
+
self._failed += len(batch)
|
|
352
|
+
logger.warning(
|
|
353
|
+
"Dropping %d %s after %d attempts (%s)",
|
|
354
|
+
len(batch),
|
|
355
|
+
target.body_key,
|
|
356
|
+
attempt + 1,
|
|
357
|
+
failure,
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
delay = self._retry_delay(attempt, retry_after)
|
|
361
|
+
logger.debug("Export failed (%s); retrying in %.2fs", failure, delay)
|
|
362
|
+
self._sleep(delay)
|
|
363
|
+
|
|
364
|
+
def _split_and_send(self, target: _Target, batch: list[SpanPayload]) -> None:
|
|
365
|
+
"""Retry an oversized (HTTP 413) batch as two smaller ones."""
|
|
366
|
+
middle = len(batch) // 2
|
|
367
|
+
self._send_batch(target, batch[:middle])
|
|
368
|
+
self._send_batch(target, batch[middle:])
|
|
369
|
+
|
|
370
|
+
def _log_rejected_batch(self, target: _Target, response: httpx.Response, size: int) -> None:
|
|
371
|
+
if target.body_key == _SCORES and response.status_code == 404:
|
|
372
|
+
# A server older than score ingestion: say so once instead of on every batch.
|
|
373
|
+
if not self._scores_unsupported_logged:
|
|
374
|
+
self._scores_unsupported_logged = True
|
|
375
|
+
logger.warning(
|
|
376
|
+
"This Spanlight server does not accept scores (HTTP 404); dropping them"
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
logger.error(
|
|
380
|
+
"Spanlight rejected a batch of %d %s: HTTP %d %s",
|
|
381
|
+
size,
|
|
382
|
+
target.body_key,
|
|
383
|
+
response.status_code,
|
|
384
|
+
_short_body(response),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _handle_success(
|
|
388
|
+
self, target: _Target, response: httpx.Response, batch: list[SpanPayload]
|
|
389
|
+
) -> None:
|
|
390
|
+
batch_size = len(batch)
|
|
391
|
+
try:
|
|
392
|
+
result: Any = response.json()
|
|
393
|
+
except ValueError:
|
|
394
|
+
self._sent += batch_size
|
|
395
|
+
return
|
|
396
|
+
rejected = result.get("rejected") if isinstance(result, dict) else None
|
|
397
|
+
if not isinstance(rejected, list) or not rejected:
|
|
398
|
+
self._sent += batch_size
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
self._sent += batch_size - len(rejected)
|
|
402
|
+
self._failed += len(rejected)
|
|
403
|
+
logger.warning(
|
|
404
|
+
"Spanlight rejected %d of %d %s",
|
|
405
|
+
len(rejected),
|
|
406
|
+
batch_size,
|
|
407
|
+
target.body_key,
|
|
408
|
+
)
|
|
409
|
+
for item in rejected[:_MAX_LOGGED_REJECTIONS]:
|
|
410
|
+
if isinstance(item, dict):
|
|
411
|
+
index = item.get("index")
|
|
412
|
+
sent = batch[index] if isinstance(index, int) and 0 <= index < batch_size else {}
|
|
413
|
+
logger.warning(
|
|
414
|
+
" %s %s (index %s): %s",
|
|
415
|
+
target.body_key[:-1],
|
|
416
|
+
item.get(target.id_field) or sent.get(target.id_field),
|
|
417
|
+
index,
|
|
418
|
+
item.get("reason"),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _retry_delay(self, attempt: int, retry_after: float | None) -> float:
|
|
422
|
+
"""Return seconds to wait before retry number ``attempt + 1``.
|
|
423
|
+
|
|
424
|
+
``Retry-After`` wins when present. Otherwise use "equal jitter"
|
|
425
|
+
exponential backoff: half of the exponential step is fixed and the
|
|
426
|
+
other half random, which spreads retries out without ever retrying
|
|
427
|
+
immediately.
|
|
428
|
+
"""
|
|
429
|
+
if retry_after is not None:
|
|
430
|
+
return min(max(retry_after, 0.0), self._max_backoff)
|
|
431
|
+
step = min(self._max_backoff, self._base_backoff * (2.0**attempt))
|
|
432
|
+
return step / 2 + self._jitter() * step / 2
|
|
433
|
+
|
|
434
|
+
def _interruptible_sleep(self, seconds: float) -> None:
|
|
435
|
+
self._abort_retries.wait(seconds)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _is_retryable(status: int) -> bool:
|
|
439
|
+
return status == 429 or status >= 500
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
443
|
+
"""Parse a ``Retry-After`` header (delta-seconds or HTTP-date) into seconds."""
|
|
444
|
+
if value is None:
|
|
445
|
+
return None
|
|
446
|
+
value = value.strip()
|
|
447
|
+
try:
|
|
448
|
+
return max(float(value), 0.0)
|
|
449
|
+
except ValueError:
|
|
450
|
+
pass
|
|
451
|
+
try:
|
|
452
|
+
moment = email.utils.parsedate_to_datetime(value)
|
|
453
|
+
except (TypeError, ValueError):
|
|
454
|
+
return None
|
|
455
|
+
return max(moment.timestamp() - time.time(), 0.0)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _short_body(response: httpx.Response) -> str:
|
|
459
|
+
try:
|
|
460
|
+
return response.text[:500]
|
|
461
|
+
except Exception:
|
|
462
|
+
return "<unreadable body>"
|
spanlight/_globals.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Process-wide default client and the module-level convenience API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar, overload
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._client import SpanContext, Spanlight
|
|
13
|
+
from ._decorator import build_observe
|
|
14
|
+
from ._prompt_client import Fallback
|
|
15
|
+
from ._prompts import Prompt
|
|
16
|
+
from ._scores import ScoreValue
|
|
17
|
+
from ._span import SpanKind
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("spanlight")
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
R = TypeVar("R")
|
|
23
|
+
|
|
24
|
+
_client: Spanlight | None = None
|
|
25
|
+
_client_lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def init(
|
|
29
|
+
*,
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
host: str | None = None,
|
|
32
|
+
environment: str | None = None,
|
|
33
|
+
release: str | None = None,
|
|
34
|
+
enabled: bool | None = None,
|
|
35
|
+
flush_interval: float = 2.0,
|
|
36
|
+
batch_size: int = 100,
|
|
37
|
+
max_queue: int = 10_000,
|
|
38
|
+
gzip: bool = False,
|
|
39
|
+
timeout: float = 10.0,
|
|
40
|
+
max_retries: int = 5,
|
|
41
|
+
shutdown_timeout: float = 5.0,
|
|
42
|
+
prompt_cache_ttl: float = 60.0,
|
|
43
|
+
transport: httpx.BaseTransport | None = None,
|
|
44
|
+
) -> Spanlight:
|
|
45
|
+
"""Configure the global client used by :func:`observe`, :func:`span` and the wrappers.
|
|
46
|
+
|
|
47
|
+
Accepts the same arguments as :class:`Spanlight`. Calling ``init`` again
|
|
48
|
+
replaces the global client; the previous one is flushed and shut down.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The new global :class:`Spanlight`.
|
|
52
|
+
"""
|
|
53
|
+
global _client
|
|
54
|
+
client = Spanlight(
|
|
55
|
+
api_key=api_key,
|
|
56
|
+
host=host,
|
|
57
|
+
environment=environment,
|
|
58
|
+
release=release,
|
|
59
|
+
enabled=enabled,
|
|
60
|
+
flush_interval=flush_interval,
|
|
61
|
+
batch_size=batch_size,
|
|
62
|
+
max_queue=max_queue,
|
|
63
|
+
gzip=gzip,
|
|
64
|
+
timeout=timeout,
|
|
65
|
+
max_retries=max_retries,
|
|
66
|
+
shutdown_timeout=shutdown_timeout,
|
|
67
|
+
prompt_cache_ttl=prompt_cache_ttl,
|
|
68
|
+
transport=transport,
|
|
69
|
+
)
|
|
70
|
+
with _client_lock:
|
|
71
|
+
previous, _client = _client, client
|
|
72
|
+
if previous is not None:
|
|
73
|
+
previous.shutdown()
|
|
74
|
+
return client
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_client() -> Spanlight:
|
|
78
|
+
"""Return the global client, creating one from ``SPANLIGHT_*`` variables if needed."""
|
|
79
|
+
global _client
|
|
80
|
+
with _client_lock:
|
|
81
|
+
if _client is None:
|
|
82
|
+
_client = Spanlight()
|
|
83
|
+
return _client
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def span(
|
|
87
|
+
name: str,
|
|
88
|
+
*,
|
|
89
|
+
kind: SpanKind = "chain",
|
|
90
|
+
input: Any = None,
|
|
91
|
+
attributes: Mapping[str, Any] | None = None,
|
|
92
|
+
) -> SpanContext:
|
|
93
|
+
"""Trace a block of code with the global client. See :meth:`Spanlight.span`."""
|
|
94
|
+
return get_client().span(name, kind=kind, input=input, attributes=attributes)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def observe(func: Callable[P, R], /) -> Callable[P, R]: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@overload
|
|
102
|
+
def observe(
|
|
103
|
+
name: str | None = None,
|
|
104
|
+
*,
|
|
105
|
+
kind: SpanKind = "chain",
|
|
106
|
+
capture_input: bool = True,
|
|
107
|
+
capture_output: bool = True,
|
|
108
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def observe(
|
|
112
|
+
name: Callable[P, R] | str | None = None,
|
|
113
|
+
*,
|
|
114
|
+
kind: SpanKind = "chain",
|
|
115
|
+
capture_input: bool = True,
|
|
116
|
+
capture_output: bool = True,
|
|
117
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
118
|
+
"""Trace every call of the decorated function as a span.
|
|
119
|
+
|
|
120
|
+
Works on plain functions, ``async def`` coroutines, generators and async
|
|
121
|
+
generators. For generators the span stays open until iteration finishes.
|
|
122
|
+
The global client is looked up at call time, so the decorator can be
|
|
123
|
+
applied at import time, before :func:`init` runs.
|
|
124
|
+
|
|
125
|
+
Example:
|
|
126
|
+
>>> @observe # doctest: +SKIP
|
|
127
|
+
... def answer(question: str) -> str: ...
|
|
128
|
+
>>> @observe(name="retrieve", kind="retrieval") # doctest: +SKIP
|
|
129
|
+
... async def search(query: str) -> list[str]: ...
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
name: Span name; defaults to the function's ``__name__``. (In the bare
|
|
133
|
+
``@observe`` form this parameter receives the function itself.)
|
|
134
|
+
kind: Span kind: ``llm``, ``tool``, ``retrieval``, ``chain``,
|
|
135
|
+
``http`` or ``other``.
|
|
136
|
+
capture_input: Record the call arguments as the span input.
|
|
137
|
+
capture_output: Record the return value (or yielded items) as output.
|
|
138
|
+
"""
|
|
139
|
+
return build_observe(
|
|
140
|
+
get_client,
|
|
141
|
+
name,
|
|
142
|
+
kind=kind,
|
|
143
|
+
capture_input=capture_input,
|
|
144
|
+
capture_output=capture_output,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _GlobalPrompts:
|
|
149
|
+
"""``spanlight.prompts``: the prompt client of whichever global client is current."""
|
|
150
|
+
|
|
151
|
+
def get(
|
|
152
|
+
self,
|
|
153
|
+
name: str,
|
|
154
|
+
label: str = "production",
|
|
155
|
+
*,
|
|
156
|
+
version: int | None = None,
|
|
157
|
+
fallback: Fallback | None = None,
|
|
158
|
+
) -> Prompt:
|
|
159
|
+
"""Fetch a prompt with the global client. See :meth:`PromptClient.get`."""
|
|
160
|
+
return get_client().prompts.get(name, label, version=version, fallback=fallback)
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
return "spanlight.prompts"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
prompts = _GlobalPrompts()
|
|
167
|
+
"""Fetch prompts with the global client: ``spanlight.prompts.get("support/answer")``."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def score(
|
|
171
|
+
trace_id: str,
|
|
172
|
+
name: str,
|
|
173
|
+
value: ScoreValue,
|
|
174
|
+
*,
|
|
175
|
+
span_id: str | None = None,
|
|
176
|
+
comment: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Record a score with the global client. See :meth:`Spanlight.score`."""
|
|
179
|
+
try:
|
|
180
|
+
get_client().score(trace_id, name, value, span_id=span_id, comment=comment)
|
|
181
|
+
except Exception:
|
|
182
|
+
logger.debug("score() failed", exc_info=True)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def flush(timeout: float | None = None) -> bool:
|
|
186
|
+
"""Flush the global client. See :meth:`Spanlight.flush`."""
|
|
187
|
+
with _client_lock:
|
|
188
|
+
client = _client
|
|
189
|
+
return client.flush(timeout) if client is not None else True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def shutdown(timeout: float | None = None) -> None:
|
|
193
|
+
"""Shut down the global client. See :meth:`Spanlight.shutdown`."""
|
|
194
|
+
global _client
|
|
195
|
+
with _client_lock:
|
|
196
|
+
client, _client = _client, None
|
|
197
|
+
if client is not None:
|
|
198
|
+
client.shutdown(timeout)
|
spanlight/_ids.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""W3C Trace Context compatible identifier generation.
|
|
2
|
+
|
|
3
|
+
Trace ids are 16 random bytes rendered as 32 lowercase hex characters and span
|
|
4
|
+
ids are 8 random bytes rendered as 16 lowercase hex characters. The all-zero
|
|
5
|
+
value is invalid in W3C Trace Context, so it is never returned.
|
|
6
|
+
|
|
7
|
+
Record ids the server deduplicates on (scores) are UUIDv7: time-ordered, so
|
|
8
|
+
they index well, and random enough that two clients never collide.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import secrets
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
|
|
17
|
+
_TRACE_ID_BYTES = 16
|
|
18
|
+
_SPAN_ID_BYTES = 8
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _random_hex(num_bytes: int) -> str:
|
|
22
|
+
while True:
|
|
23
|
+
value = secrets.token_hex(num_bytes)
|
|
24
|
+
if value.strip("0"):
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def new_trace_id() -> str:
|
|
29
|
+
"""Return a new random 32-hex-character trace id."""
|
|
30
|
+
return _random_hex(_TRACE_ID_BYTES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def new_span_id() -> str:
|
|
34
|
+
"""Return a new random 16-hex-character span id."""
|
|
35
|
+
return _random_hex(_SPAN_ID_BYTES)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_uuid7() -> str:
|
|
39
|
+
"""Return a new UUIDv7 (RFC 9562): 48-bit Unix milliseconds, then 74 random bits."""
|
|
40
|
+
millis = time.time_ns() // 1_000_000
|
|
41
|
+
random_bits = int.from_bytes(secrets.token_bytes(10), "big")
|
|
42
|
+
rand_a = random_bits >> 68 # 12 bits
|
|
43
|
+
rand_b = random_bits & ((1 << 62) - 1) # 62 bits
|
|
44
|
+
value = (millis & ((1 << 48) - 1)) << 80 | 0x7 << 76 | rand_a << 64 | 0b10 << 62 | rand_b
|
|
45
|
+
return str(uuid.UUID(int=value))
|