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 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
+ )