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/_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))