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/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Spanlight: observability for LLM applications.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import spanlight
|
|
6
|
+
from openai import OpenAI
|
|
7
|
+
|
|
8
|
+
spanlight.init() # reads SPANLIGHT_API_KEY / SPANLIGHT_HOST from the environment
|
|
9
|
+
client = spanlight.wrap_openai(OpenAI())
|
|
10
|
+
|
|
11
|
+
@spanlight.observe
|
|
12
|
+
def answer(question: str) -> str:
|
|
13
|
+
response = client.chat.completions.create(
|
|
14
|
+
model="gpt-4o-mini",
|
|
15
|
+
messages=[{"role": "user", "content": question}],
|
|
16
|
+
)
|
|
17
|
+
return response.choices[0].message.content or ""
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._client import SpanContext, Spanlight, update_trace
|
|
21
|
+
from ._context import get_current_span, get_current_trace_id
|
|
22
|
+
from ._globals import flush, get_client, init, observe, prompts, score, shutdown, span
|
|
23
|
+
from ._prompt_client import PromptClient
|
|
24
|
+
from ._prompts import CompiledPrompt, Prompt, PromptUnavailableError, no_prompt
|
|
25
|
+
from ._span import Span, SpanKind, SpanStatus
|
|
26
|
+
from ._templates import InvalidTemplateError, MissingVariablesError, TemplateTooLargeError
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .integrations.anthropic import wrap_anthropic
|
|
29
|
+
from .integrations.openai import wrap_openai
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CompiledPrompt",
|
|
33
|
+
"InvalidTemplateError",
|
|
34
|
+
"MissingVariablesError",
|
|
35
|
+
"Prompt",
|
|
36
|
+
"PromptClient",
|
|
37
|
+
"PromptUnavailableError",
|
|
38
|
+
"Span",
|
|
39
|
+
"SpanContext",
|
|
40
|
+
"SpanKind",
|
|
41
|
+
"SpanStatus",
|
|
42
|
+
"Spanlight",
|
|
43
|
+
"TemplateTooLargeError",
|
|
44
|
+
"__version__",
|
|
45
|
+
"flush",
|
|
46
|
+
"get_client",
|
|
47
|
+
"get_current_span",
|
|
48
|
+
"get_current_trace_id",
|
|
49
|
+
"init",
|
|
50
|
+
"no_prompt",
|
|
51
|
+
"observe",
|
|
52
|
+
"prompts",
|
|
53
|
+
"score",
|
|
54
|
+
"shutdown",
|
|
55
|
+
"span",
|
|
56
|
+
"update_trace",
|
|
57
|
+
"wrap_anthropic",
|
|
58
|
+
"wrap_openai",
|
|
59
|
+
]
|
spanlight/_client.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""The :class:`Spanlight` client: configuration, span creation, export, prompts and scores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
8
|
+
from contextvars import Token
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Any, ParamSpec, TypeVar, overload
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._config import SpanlightConfig, resolve_config
|
|
15
|
+
from ._context import (
|
|
16
|
+
get_current_prompt_version_id,
|
|
17
|
+
get_current_span,
|
|
18
|
+
restore_current_span,
|
|
19
|
+
set_current_span,
|
|
20
|
+
)
|
|
21
|
+
from ._exporter import BatchExporter
|
|
22
|
+
from ._prompt_client import PromptClient
|
|
23
|
+
from ._scores import ScoreValue, build_score
|
|
24
|
+
from ._span import Span, SpanKind, TraceState, normalize_kind
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("spanlight")
|
|
27
|
+
|
|
28
|
+
P = ParamSpec("P")
|
|
29
|
+
R = TypeVar("R")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Spanlight:
|
|
33
|
+
"""Spanlight client.
|
|
34
|
+
|
|
35
|
+
A ``Spanlight`` client owns the configuration and the background
|
|
36
|
+
exporter. Most applications create one at startup with
|
|
37
|
+
:func:`spanlight.init` and then use the module-level helpers; creating
|
|
38
|
+
instances directly is useful for tests or for sending to several
|
|
39
|
+
projects from one process.
|
|
40
|
+
|
|
41
|
+
Every argument left as ``None`` is read from the environment:
|
|
42
|
+
|
|
43
|
+
=================== ========================= ==========================
|
|
44
|
+
Argument Environment variable Default
|
|
45
|
+
=================== ========================= ==========================
|
|
46
|
+
``api_key`` ``SPANLIGHT_API_KEY`` none (export disabled)
|
|
47
|
+
``host`` ``SPANLIGHT_HOST`` ``http://localhost:8000``
|
|
48
|
+
``environment`` ``SPANLIGHT_ENVIRONMENT`` none
|
|
49
|
+
``release`` ``SPANLIGHT_RELEASE`` none
|
|
50
|
+
``enabled`` ``SPANLIGHT_ENABLED`` ``True``
|
|
51
|
+
=================== ========================= ==========================
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
api_key: Project API key (``spl_live_...``).
|
|
55
|
+
host: Base URL of the Spanlight API.
|
|
56
|
+
environment: Deployment environment, e.g. ``"production"``.
|
|
57
|
+
release: Application version, e.g. a git SHA.
|
|
58
|
+
enabled: Set ``False`` to turn tracing and scores into a no-op. Prompts are
|
|
59
|
+
still fetched when an API key is set, because they are application data.
|
|
60
|
+
flush_interval: Maximum seconds a span waits before it is sent.
|
|
61
|
+
batch_size: Maximum spans per request (capped at 1000).
|
|
62
|
+
max_queue: Maximum spans buffered in memory; oldest dropped first.
|
|
63
|
+
gzip: Compress request bodies.
|
|
64
|
+
timeout: HTTP timeout per request, in seconds.
|
|
65
|
+
max_retries: Retries per batch for network errors, 429 and 5xx.
|
|
66
|
+
shutdown_timeout: Seconds allowed for the final flush at exit.
|
|
67
|
+
prompt_cache_ttl: Seconds a fetched prompt is served before it is revalidated.
|
|
68
|
+
transport: Custom ``httpx`` transport (advanced; mainly for tests).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
host: str | None = None,
|
|
76
|
+
environment: str | None = None,
|
|
77
|
+
release: str | None = None,
|
|
78
|
+
enabled: bool | None = None,
|
|
79
|
+
flush_interval: float = 2.0,
|
|
80
|
+
batch_size: int = 100,
|
|
81
|
+
max_queue: int = 10_000,
|
|
82
|
+
gzip: bool = False,
|
|
83
|
+
timeout: float = 10.0,
|
|
84
|
+
max_retries: int = 5,
|
|
85
|
+
shutdown_timeout: float = 5.0,
|
|
86
|
+
prompt_cache_ttl: float = 60.0,
|
|
87
|
+
transport: httpx.BaseTransport | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self.config: SpanlightConfig = resolve_config(
|
|
90
|
+
api_key=api_key,
|
|
91
|
+
host=host,
|
|
92
|
+
environment=environment,
|
|
93
|
+
release=release,
|
|
94
|
+
enabled=enabled,
|
|
95
|
+
flush_interval=flush_interval,
|
|
96
|
+
batch_size=batch_size,
|
|
97
|
+
max_queue=max_queue,
|
|
98
|
+
gzip=gzip,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
max_retries=max_retries,
|
|
101
|
+
shutdown_timeout=shutdown_timeout,
|
|
102
|
+
prompt_cache_ttl=prompt_cache_ttl,
|
|
103
|
+
)
|
|
104
|
+
self._exporter: BatchExporter | None = None
|
|
105
|
+
self.prompts: PromptClient = PromptClient(
|
|
106
|
+
base_url=self.config.prompts_url,
|
|
107
|
+
api_key=self.config.api_key,
|
|
108
|
+
timeout=self.config.timeout,
|
|
109
|
+
cache_ttl=self.config.prompt_cache_ttl,
|
|
110
|
+
transport=transport,
|
|
111
|
+
)
|
|
112
|
+
"""Fetches prompts from Spanlight: ``client.prompts.get("support/answer")``."""
|
|
113
|
+
|
|
114
|
+
if self.config.enabled and not self.config.api_key:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Spanlight: no API key configured (set SPANLIGHT_API_KEY or pass "
|
|
117
|
+
"api_key=...); tracing is disabled."
|
|
118
|
+
)
|
|
119
|
+
elif self.config.enabled and self.config.api_key:
|
|
120
|
+
self._exporter = BatchExporter(
|
|
121
|
+
url=self.config.ingest_url,
|
|
122
|
+
api_key=self.config.api_key,
|
|
123
|
+
scores_url=self.config.scores_url,
|
|
124
|
+
batch_size=self.config.batch_size,
|
|
125
|
+
flush_interval=self.config.flush_interval,
|
|
126
|
+
max_queue=self.config.max_queue,
|
|
127
|
+
gzip_body=self.config.gzip,
|
|
128
|
+
timeout=self.config.timeout,
|
|
129
|
+
max_retries=self.config.max_retries,
|
|
130
|
+
transport=transport,
|
|
131
|
+
)
|
|
132
|
+
atexit.register(self._flush_at_exit)
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
# State
|
|
136
|
+
# ------------------------------------------------------------------ #
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def enabled(self) -> bool:
|
|
140
|
+
"""Whether spans are recorded and exported."""
|
|
141
|
+
return self._exporter is not None
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def exporter(self) -> BatchExporter | None:
|
|
145
|
+
"""The background exporter, or ``None`` when disabled."""
|
|
146
|
+
return self._exporter
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
# Spans
|
|
150
|
+
# ------------------------------------------------------------------ #
|
|
151
|
+
|
|
152
|
+
def start_span(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
*,
|
|
156
|
+
kind: SpanKind = "chain",
|
|
157
|
+
parent: Span | None = None,
|
|
158
|
+
) -> Span:
|
|
159
|
+
"""Create and start a span without making it current.
|
|
160
|
+
|
|
161
|
+
Most code should prefer :meth:`span` or :func:`observe`, which also
|
|
162
|
+
manage the current-span context and end the span for you. Use this
|
|
163
|
+
for spans whose lifetime does not match a block of code; remember to
|
|
164
|
+
call :meth:`Span.end`.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
name: Human-readable operation name.
|
|
168
|
+
kind: One of ``llm``, ``tool``, ``retrieval``, ``chain``,
|
|
169
|
+
``http`` or ``other``.
|
|
170
|
+
parent: Parent span; defaults to the current span. Without a
|
|
171
|
+
parent the span starts a new trace.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The started span (non-recording when the SDK is disabled).
|
|
175
|
+
"""
|
|
176
|
+
try:
|
|
177
|
+
parent = parent if parent is not None else get_current_span()
|
|
178
|
+
if parent is not None:
|
|
179
|
+
trace = parent.trace
|
|
180
|
+
parent_span_id: str | None = parent.span_id
|
|
181
|
+
else:
|
|
182
|
+
trace = TraceState(
|
|
183
|
+
name=name,
|
|
184
|
+
environment=self.config.environment,
|
|
185
|
+
release=self.config.release,
|
|
186
|
+
)
|
|
187
|
+
parent_span_id = None
|
|
188
|
+
span = Span(
|
|
189
|
+
name=name,
|
|
190
|
+
kind=normalize_kind(kind),
|
|
191
|
+
trace=trace,
|
|
192
|
+
parent_span_id=parent_span_id,
|
|
193
|
+
on_end=self._export_span,
|
|
194
|
+
recording=self.enabled,
|
|
195
|
+
)
|
|
196
|
+
prompt_version_id = get_current_prompt_version_id()
|
|
197
|
+
if prompt_version_id is not None:
|
|
198
|
+
span.set_prompt_version(prompt_version_id)
|
|
199
|
+
return span
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.debug("Failed to start span %r", name, exc_info=True)
|
|
202
|
+
return _non_recording_span(name)
|
|
203
|
+
|
|
204
|
+
def span(
|
|
205
|
+
self,
|
|
206
|
+
name: str,
|
|
207
|
+
*,
|
|
208
|
+
kind: SpanKind = "chain",
|
|
209
|
+
input: Any = None,
|
|
210
|
+
attributes: Mapping[str, Any] | None = None,
|
|
211
|
+
) -> SpanContext:
|
|
212
|
+
"""Trace a block of code as a span.
|
|
213
|
+
|
|
214
|
+
Works with both ``with`` and ``async with``. The span becomes the
|
|
215
|
+
current span inside the block, so nested spans and wrapped LLM calls
|
|
216
|
+
attach to it. An exception escaping the block marks the span as
|
|
217
|
+
failed and is re-raised unchanged.
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> with tracer.span("retrieve", kind="retrieval") as span: # doctest: +SKIP
|
|
221
|
+
... docs = search(query)
|
|
222
|
+
... span.set_output(docs)
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
name: Human-readable operation name.
|
|
226
|
+
kind: Span kind (see :meth:`start_span`).
|
|
227
|
+
input: Optional input recorded on the span immediately.
|
|
228
|
+
attributes: Optional custom attributes.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
A context manager yielding the :class:`Span`.
|
|
232
|
+
"""
|
|
233
|
+
return SpanContext(self, name, kind=kind, input=input, attributes=attributes)
|
|
234
|
+
|
|
235
|
+
@overload
|
|
236
|
+
def observe(self, func: Callable[P, R], /) -> Callable[P, R]: ...
|
|
237
|
+
|
|
238
|
+
@overload
|
|
239
|
+
def observe(
|
|
240
|
+
self,
|
|
241
|
+
name: str | None = None,
|
|
242
|
+
*,
|
|
243
|
+
kind: SpanKind = "chain",
|
|
244
|
+
capture_input: bool = True,
|
|
245
|
+
capture_output: bool = True,
|
|
246
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
247
|
+
|
|
248
|
+
def observe(
|
|
249
|
+
self,
|
|
250
|
+
name: Callable[P, R] | str | None = None,
|
|
251
|
+
*,
|
|
252
|
+
kind: SpanKind = "chain",
|
|
253
|
+
capture_input: bool = True,
|
|
254
|
+
capture_output: bool = True,
|
|
255
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
256
|
+
"""Decorator tracing every call of a function with this client.
|
|
257
|
+
|
|
258
|
+
Identical to :func:`spanlight.observe` but bound to this
|
|
259
|
+
``Spanlight`` instead of the global client.
|
|
260
|
+
"""
|
|
261
|
+
from ._decorator import build_observe
|
|
262
|
+
|
|
263
|
+
return build_observe(
|
|
264
|
+
lambda: self,
|
|
265
|
+
name,
|
|
266
|
+
kind=kind,
|
|
267
|
+
capture_input=capture_input,
|
|
268
|
+
capture_output=capture_output,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def update_trace(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
name: str | None = None,
|
|
275
|
+
user_id: str | None = None,
|
|
276
|
+
session_id: str | None = None,
|
|
277
|
+
tags: Iterable[str] | None = None,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Set fields on the current trace. See :func:`spanlight.update_trace`."""
|
|
280
|
+
update_trace(name=name, user_id=user_id, session_id=session_id, tags=tags)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------ #
|
|
283
|
+
# Scores
|
|
284
|
+
# ------------------------------------------------------------------ #
|
|
285
|
+
|
|
286
|
+
def score(
|
|
287
|
+
self,
|
|
288
|
+
trace_id: str,
|
|
289
|
+
name: str,
|
|
290
|
+
value: ScoreValue,
|
|
291
|
+
*,
|
|
292
|
+
span_id: str | None = None,
|
|
293
|
+
comment: str | None = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Record a score on a trace (or one of its spans). Never raises.
|
|
296
|
+
|
|
297
|
+
Scores are sent in the background with the spans, after the spans
|
|
298
|
+
of the same cycle. A ``bool`` becomes a boolean score, a ``str`` a
|
|
299
|
+
categorical one, any other number a numeric one.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
trace_id: The trace to score, e.g. ``spanlight.get_current_trace_id()``.
|
|
303
|
+
name: The score name, e.g. ``"helpfulness"``.
|
|
304
|
+
value: The score: a number, ``True``/``False`` or a category.
|
|
305
|
+
span_id: Score one span of the trace instead of the whole trace.
|
|
306
|
+
comment: Free-text note shown next to the score.
|
|
307
|
+
"""
|
|
308
|
+
exporter = self._exporter
|
|
309
|
+
if exporter is None:
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
payload = build_score(trace_id, name, value, span_id=span_id, comment=comment)
|
|
313
|
+
if payload is not None:
|
|
314
|
+
exporter.export_score(payload)
|
|
315
|
+
except Exception:
|
|
316
|
+
logger.debug("Failed to record score %r", name, exc_info=True)
|
|
317
|
+
|
|
318
|
+
# ------------------------------------------------------------------ #
|
|
319
|
+
# Lifecycle
|
|
320
|
+
# ------------------------------------------------------------------ #
|
|
321
|
+
|
|
322
|
+
def flush(self, timeout: float | None = None) -> bool:
|
|
323
|
+
"""Block until every span ended and score recorded so far has been sent.
|
|
324
|
+
|
|
325
|
+
Call this at the end of short-lived scripts, serverless handlers and
|
|
326
|
+
tests. Long-running servers do not need it.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
timeout: Maximum seconds to wait; ``None`` waits indefinitely.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
``True`` if everything was delivered (or the SDK is disabled).
|
|
333
|
+
"""
|
|
334
|
+
if self._exporter is None:
|
|
335
|
+
return True
|
|
336
|
+
return self._exporter.flush(timeout)
|
|
337
|
+
|
|
338
|
+
def shutdown(self, timeout: float | None = None) -> None:
|
|
339
|
+
"""Flush and stop the background exporter. The client is disabled afterwards.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
timeout: Maximum seconds to spend; defaults to ``shutdown_timeout``.
|
|
343
|
+
"""
|
|
344
|
+
self.prompts.close()
|
|
345
|
+
exporter = self._exporter
|
|
346
|
+
if exporter is None:
|
|
347
|
+
return
|
|
348
|
+
self._exporter = None
|
|
349
|
+
atexit.unregister(self._flush_at_exit)
|
|
350
|
+
exporter.shutdown(self.config.shutdown_timeout if timeout is None else timeout)
|
|
351
|
+
|
|
352
|
+
def _flush_at_exit(self) -> None:
|
|
353
|
+
self.shutdown()
|
|
354
|
+
|
|
355
|
+
def _export_span(self, span: Span) -> None:
|
|
356
|
+
exporter = self._exporter
|
|
357
|
+
if exporter is not None:
|
|
358
|
+
exporter.export(span.to_payload())
|
|
359
|
+
|
|
360
|
+
def __repr__(self) -> str:
|
|
361
|
+
return f"Spanlight(host={self.config.host!r}, enabled={self.enabled})"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class SpanContext:
|
|
365
|
+
"""Context manager returned by :meth:`Spanlight.span` (sync and async)."""
|
|
366
|
+
|
|
367
|
+
def __init__(
|
|
368
|
+
self,
|
|
369
|
+
client: Spanlight,
|
|
370
|
+
name: str,
|
|
371
|
+
*,
|
|
372
|
+
kind: SpanKind,
|
|
373
|
+
input: Any,
|
|
374
|
+
attributes: Mapping[str, Any] | None,
|
|
375
|
+
) -> None:
|
|
376
|
+
self._client = client
|
|
377
|
+
self._name = name
|
|
378
|
+
self._kind = kind
|
|
379
|
+
self._input = input
|
|
380
|
+
self._attributes = attributes
|
|
381
|
+
self._span: Span | None = None
|
|
382
|
+
self._previous: Span | None = None
|
|
383
|
+
self._token: Token[Span | None] | None = None
|
|
384
|
+
|
|
385
|
+
def __enter__(self) -> Span:
|
|
386
|
+
span = self._client.start_span(self._name, kind=self._kind)
|
|
387
|
+
if self._input is not None:
|
|
388
|
+
span.set_input(self._input)
|
|
389
|
+
if self._attributes:
|
|
390
|
+
span.set_attributes(self._attributes)
|
|
391
|
+
self._span = span
|
|
392
|
+
self._previous = get_current_span()
|
|
393
|
+
self._token = set_current_span(span)
|
|
394
|
+
return span
|
|
395
|
+
|
|
396
|
+
def __exit__(
|
|
397
|
+
self,
|
|
398
|
+
exc_type: type[BaseException] | None,
|
|
399
|
+
exc: BaseException | None,
|
|
400
|
+
traceback: TracebackType | None,
|
|
401
|
+
) -> None:
|
|
402
|
+
span = self._span
|
|
403
|
+
if span is None:
|
|
404
|
+
return
|
|
405
|
+
if self._token is not None:
|
|
406
|
+
restore_current_span(self._token, self._previous)
|
|
407
|
+
if exc is not None and not isinstance(exc, GeneratorExit):
|
|
408
|
+
span.record_error(exc)
|
|
409
|
+
span.end()
|
|
410
|
+
|
|
411
|
+
async def __aenter__(self) -> Span:
|
|
412
|
+
return self.__enter__()
|
|
413
|
+
|
|
414
|
+
async def __aexit__(
|
|
415
|
+
self,
|
|
416
|
+
exc_type: type[BaseException] | None,
|
|
417
|
+
exc: BaseException | None,
|
|
418
|
+
traceback: TracebackType | None,
|
|
419
|
+
) -> None:
|
|
420
|
+
self.__exit__(exc_type, exc, traceback)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def update_trace(
|
|
424
|
+
*,
|
|
425
|
+
name: str | None = None,
|
|
426
|
+
user_id: str | None = None,
|
|
427
|
+
session_id: str | None = None,
|
|
428
|
+
tags: Iterable[str] | None = None,
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Attach trace-level fields to the trace of the current span.
|
|
431
|
+
|
|
432
|
+
Fields left as ``None`` are unchanged; tags are added to the existing
|
|
433
|
+
set. Has no effect (beyond a debug log) when called outside any span.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
name: Trace name shown in the UI (defaults to the root span name).
|
|
437
|
+
user_id: Your application's user identifier.
|
|
438
|
+
session_id: Groups traces into a conversation/session.
|
|
439
|
+
tags: Free-form labels for filtering.
|
|
440
|
+
"""
|
|
441
|
+
span = get_current_span()
|
|
442
|
+
if span is None:
|
|
443
|
+
logger.debug("update_trace() called outside of a span; ignoring")
|
|
444
|
+
return
|
|
445
|
+
try:
|
|
446
|
+
span.trace.update(name=name, user_id=user_id, session_id=session_id, tags=tags)
|
|
447
|
+
except Exception:
|
|
448
|
+
logger.debug("update_trace() failed", exc_info=True)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _non_recording_span(name: str) -> Span:
|
|
452
|
+
return Span(
|
|
453
|
+
name=name,
|
|
454
|
+
kind="other",
|
|
455
|
+
trace=TraceState(),
|
|
456
|
+
parent_span_id=None,
|
|
457
|
+
on_end=None,
|
|
458
|
+
recording=False,
|
|
459
|
+
)
|
spanlight/_config.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Configuration resolution for the SDK.
|
|
2
|
+
|
|
3
|
+
Every option can be passed explicitly to :class:`~spanlight.Spanlight`
|
|
4
|
+
or :func:`~spanlight.init`; anything left as ``None`` falls back to
|
|
5
|
+
a ``SPANLIGHT_*`` environment variable and then to a default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
DEFAULT_HOST = "http://localhost:8000"
|
|
14
|
+
INGEST_PATH = "/v1/traces"
|
|
15
|
+
SCORES_PATH = "/v1/scores"
|
|
16
|
+
PROMPTS_PATH = "/v1/prompts"
|
|
17
|
+
|
|
18
|
+
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
|
19
|
+
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class SpanlightConfig:
|
|
24
|
+
"""Resolved, immutable SDK configuration.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
api_key: Project API key (``spl_live_...``). ``None`` disables export.
|
|
28
|
+
host: Base URL of the Spanlight API.
|
|
29
|
+
environment: Deployment environment attached to every trace.
|
|
30
|
+
release: Application version attached to every trace.
|
|
31
|
+
enabled: Whether spans are recorded and exported at all.
|
|
32
|
+
flush_interval: Seconds between background flushes.
|
|
33
|
+
batch_size: Maximum spans per ingestion request.
|
|
34
|
+
max_queue: Maximum buffered spans; the oldest are dropped beyond this.
|
|
35
|
+
gzip: Whether request bodies are gzip-compressed.
|
|
36
|
+
timeout: Per-request HTTP timeout in seconds.
|
|
37
|
+
max_retries: Retries per batch for retryable failures.
|
|
38
|
+
shutdown_timeout: Seconds the interpreter-exit flush may take.
|
|
39
|
+
prompt_cache_ttl: Seconds a fetched prompt is served before it is revalidated.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
api_key: str | None
|
|
43
|
+
host: str
|
|
44
|
+
environment: str | None
|
|
45
|
+
release: str | None
|
|
46
|
+
enabled: bool
|
|
47
|
+
flush_interval: float
|
|
48
|
+
batch_size: int
|
|
49
|
+
max_queue: int
|
|
50
|
+
gzip: bool
|
|
51
|
+
timeout: float
|
|
52
|
+
max_retries: int
|
|
53
|
+
shutdown_timeout: float
|
|
54
|
+
prompt_cache_ttl: float
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ingest_url(self) -> str:
|
|
58
|
+
"""Full URL of the batch ingestion endpoint."""
|
|
59
|
+
return self.host.rstrip("/") + INGEST_PATH
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def scores_url(self) -> str:
|
|
63
|
+
"""Full URL of the score ingestion endpoint."""
|
|
64
|
+
return self.host.rstrip("/") + SCORES_PATH
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def prompts_url(self) -> str:
|
|
68
|
+
"""Full URL of the prompt collection; a prompt name is appended to it."""
|
|
69
|
+
return self.host.rstrip("/") + PROMPTS_PATH
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_bool(raw: str | None, default: bool) -> bool:
|
|
73
|
+
"""Interpret an environment-variable string as a boolean.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
raw: The raw value, or ``None`` when the variable is unset.
|
|
77
|
+
default: Value used when ``raw`` is unset, empty or unrecognised.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The parsed boolean.
|
|
81
|
+
"""
|
|
82
|
+
if raw is None:
|
|
83
|
+
return default
|
|
84
|
+
normalized = raw.strip().lower()
|
|
85
|
+
if normalized in _TRUE_VALUES:
|
|
86
|
+
return True
|
|
87
|
+
if normalized in _FALSE_VALUES:
|
|
88
|
+
return False
|
|
89
|
+
return default
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _env(name: str) -> str | None:
|
|
93
|
+
value = os.environ.get(name)
|
|
94
|
+
if value is None or not value.strip():
|
|
95
|
+
return None
|
|
96
|
+
return value.strip()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_config(
|
|
100
|
+
*,
|
|
101
|
+
api_key: str | None,
|
|
102
|
+
host: str | None,
|
|
103
|
+
environment: str | None,
|
|
104
|
+
release: str | None,
|
|
105
|
+
enabled: bool | None,
|
|
106
|
+
flush_interval: float,
|
|
107
|
+
batch_size: int,
|
|
108
|
+
max_queue: int,
|
|
109
|
+
gzip: bool,
|
|
110
|
+
timeout: float,
|
|
111
|
+
max_retries: int,
|
|
112
|
+
shutdown_timeout: float,
|
|
113
|
+
prompt_cache_ttl: float = 60.0,
|
|
114
|
+
) -> SpanlightConfig:
|
|
115
|
+
"""Merge explicit arguments with ``SPANLIGHT_*`` environment variables.
|
|
116
|
+
|
|
117
|
+
Explicit arguments always win over the environment. Numeric options are
|
|
118
|
+
clamped to sane minimums rather than rejected, because configuration
|
|
119
|
+
mistakes must never crash the host application.
|
|
120
|
+
"""
|
|
121
|
+
resolved_enabled = (
|
|
122
|
+
enabled if enabled is not None else parse_bool(_env("SPANLIGHT_ENABLED"), True)
|
|
123
|
+
)
|
|
124
|
+
return SpanlightConfig(
|
|
125
|
+
api_key=api_key or _env("SPANLIGHT_API_KEY"),
|
|
126
|
+
host=host or _env("SPANLIGHT_HOST") or DEFAULT_HOST,
|
|
127
|
+
environment=environment or _env("SPANLIGHT_ENVIRONMENT"),
|
|
128
|
+
release=release or _env("SPANLIGHT_RELEASE"),
|
|
129
|
+
enabled=resolved_enabled,
|
|
130
|
+
flush_interval=max(flush_interval, 0.05),
|
|
131
|
+
batch_size=min(max(batch_size, 1), 1000),
|
|
132
|
+
max_queue=max(max_queue, 1),
|
|
133
|
+
gzip=gzip,
|
|
134
|
+
timeout=max(timeout, 0.1),
|
|
135
|
+
max_retries=max(max_retries, 0),
|
|
136
|
+
shutdown_timeout=max(shutdown_timeout, 0.0),
|
|
137
|
+
prompt_cache_ttl=max(prompt_cache_ttl, 0.0),
|
|
138
|
+
)
|