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/_version.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Provider-agnostic plumbing shared by the OpenAI and Anthropic wrappers.
|
|
2
|
+
|
|
3
|
+
Each instrumented endpoint is described by an :class:`Endpoint`, which knows
|
|
4
|
+
how to name the span and how to read a provider response. The generic
|
|
5
|
+
wrappers in this module take care of the rest: starting the span, recording
|
|
6
|
+
errors without altering them, and wrapping streaming responses in a proxy
|
|
7
|
+
that observes events as the caller consumes them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import logging
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from .._request_hash import request_hash
|
|
20
|
+
from .._serialize import to_jsonable
|
|
21
|
+
from .._span import Span
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .._client import Spanlight
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("spanlight")
|
|
27
|
+
|
|
28
|
+
ClientGetter = Callable[[], "Spanlight"]
|
|
29
|
+
|
|
30
|
+
WRAPPED_MARKER = "__spanlight_wrapped__"
|
|
31
|
+
|
|
32
|
+
# Request options that configure transport/auth rather than describe the
|
|
33
|
+
# prompt. They are never recorded: headers in particular may carry secrets.
|
|
34
|
+
_EXCLUDED_REQUEST_KEYS = frozenset(
|
|
35
|
+
{"api_key", "extra_headers", "extra_query", "extra_body", "timeout", "http_client"}
|
|
36
|
+
)
|
|
37
|
+
_SENTINEL_TYPE_NAMES = frozenset({"NotGiven", "Omit"})
|
|
38
|
+
_RAW_RESPONSE_HEADER = "x-stainless-raw-response"
|
|
39
|
+
|
|
40
|
+
# Keyword a wrapped call accepts to link its span to a compiled prompt. It is removed from the
|
|
41
|
+
# call before the provider SDK sees it.
|
|
42
|
+
PROMPT_KWARG = "spanlight_prompt"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StreamAccumulator(ABC):
|
|
46
|
+
"""Collects streamed events and writes the final result onto a span."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def on_event(self, event: Any, span: Span) -> None:
|
|
50
|
+
"""Observe one streamed event (e.g. to mark time-to-first-token)."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def finish(self, span: Span) -> None:
|
|
54
|
+
"""Record the accumulated model, usage and output on ``span``."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Endpoint(ABC):
|
|
58
|
+
"""Describes how to trace one provider API method."""
|
|
59
|
+
|
|
60
|
+
provider: str
|
|
61
|
+
operation: str
|
|
62
|
+
|
|
63
|
+
def __init__(self, provider: str) -> None:
|
|
64
|
+
self.provider = provider
|
|
65
|
+
|
|
66
|
+
def span_name(self, request: Mapping[str, Any]) -> str:
|
|
67
|
+
"""Return the span name, e.g. ``"chat gpt-4o-mini"``."""
|
|
68
|
+
model = request.get("model")
|
|
69
|
+
return f"{self.operation} {model}" if isinstance(model, str) else self.operation
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def record_response(self, span: Span, response: Any) -> None:
|
|
73
|
+
"""Copy model, usage and output from a non-streaming response onto ``span``."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def new_accumulator(self) -> StreamAccumulator:
|
|
77
|
+
"""Return a fresh accumulator for one streaming response."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def is_stream(self, result: Any) -> bool:
|
|
81
|
+
"""Return whether ``result`` is a streaming response object."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def client_getter(spanlight: Spanlight | None) -> ClientGetter:
|
|
85
|
+
"""Return a callable resolving the client to report to.
|
|
86
|
+
|
|
87
|
+
An explicit ``spanlight`` is bound immediately; otherwise the global client
|
|
88
|
+
is looked up on every call so ``init()`` may run after wrapping.
|
|
89
|
+
"""
|
|
90
|
+
if spanlight is None:
|
|
91
|
+
from .._globals import get_client
|
|
92
|
+
|
|
93
|
+
return get_client
|
|
94
|
+
|
|
95
|
+
def bound() -> Spanlight:
|
|
96
|
+
return spanlight
|
|
97
|
+
|
|
98
|
+
return bound
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sanitize_request(kwargs: Mapping[str, Any]) -> dict[str, Any]:
|
|
102
|
+
"""Return the request parameters worth recording as span input.
|
|
103
|
+
|
|
104
|
+
Drops transport/auth options and the SDKs' "not given" sentinels, then
|
|
105
|
+
converts everything to JSON-compatible values.
|
|
106
|
+
"""
|
|
107
|
+
cleaned: dict[str, Any] = {}
|
|
108
|
+
for key, value in kwargs.items():
|
|
109
|
+
if key in _EXCLUDED_REQUEST_KEYS:
|
|
110
|
+
continue
|
|
111
|
+
if type(value).__name__ in _SENTINEL_TYPE_NAMES:
|
|
112
|
+
continue
|
|
113
|
+
cleaned[key] = to_jsonable(value)
|
|
114
|
+
return cleaned
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_raw_response_call(kwargs: Mapping[str, Any]) -> bool:
|
|
118
|
+
"""Whether the call comes from ``.with_raw_response`` / ``.with_streaming_response``.
|
|
119
|
+
|
|
120
|
+
Those helpers call ``create`` with a marker header and expect the raw
|
|
121
|
+
HTTP response back, so they are passed through untraced.
|
|
122
|
+
"""
|
|
123
|
+
headers = kwargs.get("extra_headers")
|
|
124
|
+
if not isinstance(headers, Mapping):
|
|
125
|
+
return False
|
|
126
|
+
return any(str(key).lower() == _RAW_RESPONSE_HEADER for key in headers)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_field(obj: Any, *path: str) -> Any:
|
|
130
|
+
"""Read a nested attribute or mapping key, returning ``None`` if absent."""
|
|
131
|
+
current = obj
|
|
132
|
+
for name in path:
|
|
133
|
+
if current is None:
|
|
134
|
+
return None
|
|
135
|
+
if isinstance(current, Mapping):
|
|
136
|
+
current = current.get(name)
|
|
137
|
+
else:
|
|
138
|
+
current = getattr(current, name, None)
|
|
139
|
+
return current
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def as_int(value: Any) -> int | None:
|
|
143
|
+
"""Return ``value`` if it is an ``int`` (but not a ``bool``), else ``None``."""
|
|
144
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
145
|
+
return value
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def record_provider_error(span: Span, error: BaseException) -> None:
|
|
150
|
+
"""Mark ``span`` failed with details from a provider SDK exception."""
|
|
151
|
+
span.record_error(error)
|
|
152
|
+
status_code = as_int(getattr(error, "status_code", None))
|
|
153
|
+
if status_code is not None:
|
|
154
|
+
span.set_attribute("http.status_code", status_code)
|
|
155
|
+
request_id = getattr(error, "request_id", None)
|
|
156
|
+
if isinstance(request_id, str):
|
|
157
|
+
span.set_attribute("provider.request_id", request_id)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def pop_prompt(kwargs: dict[str, Any]) -> Any:
|
|
161
|
+
"""Remove and return the ``spanlight_prompt=`` keyword, so it never reaches the provider."""
|
|
162
|
+
return kwargs.pop(PROMPT_KWARG, None)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def link_prompt(span: Span, prompt: Any) -> None:
|
|
166
|
+
"""Link ``span`` to the version of a :class:`~spanlight.CompiledPrompt`.
|
|
167
|
+
|
|
168
|
+
A fallback prompt (no version) clears any link inherited from an enclosing
|
|
169
|
+
``with compiled:`` block, because the call did not use that version.
|
|
170
|
+
"""
|
|
171
|
+
version_id = getattr(prompt, "prompt_version_id", None)
|
|
172
|
+
span.set_prompt_version(version_id if isinstance(version_id, str) else None)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def start_llm_span(
|
|
176
|
+
get_client: ClientGetter,
|
|
177
|
+
endpoint: Endpoint,
|
|
178
|
+
kwargs: Mapping[str, Any],
|
|
179
|
+
prompt: Any = None,
|
|
180
|
+
) -> Span | None:
|
|
181
|
+
"""Start an ``llm`` span for a provider call, or ``None`` to run untraced.
|
|
182
|
+
|
|
183
|
+
``prompt`` is the call's ``spanlight_prompt=`` value, already removed from ``kwargs``.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
if is_raw_response_call(kwargs):
|
|
187
|
+
return None
|
|
188
|
+
client = get_client()
|
|
189
|
+
if not client.enabled:
|
|
190
|
+
return None
|
|
191
|
+
span = client.start_span(endpoint.span_name(kwargs), kind="llm")
|
|
192
|
+
model = kwargs.get("model")
|
|
193
|
+
span.set_model(model if isinstance(model, str) else None, provider=endpoint.provider)
|
|
194
|
+
request = sanitize_request(kwargs)
|
|
195
|
+
span.set_request_hash(_request_hash_or_none(model, request))
|
|
196
|
+
span.set_input(request)
|
|
197
|
+
if kwargs.get("stream") is True:
|
|
198
|
+
span.set_attribute("request.stream", True)
|
|
199
|
+
if prompt is not None:
|
|
200
|
+
link_prompt(span, prompt)
|
|
201
|
+
return span
|
|
202
|
+
except Exception:
|
|
203
|
+
logger.debug("Failed to start LLM span", exc_info=True)
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _request_hash_or_none(model: Any, request: Any) -> str | None:
|
|
208
|
+
"""Return the request hash, or ``None`` when it cannot be computed.
|
|
209
|
+
|
|
210
|
+
An optional fingerprint must never cost the call its span.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
return request_hash(model if isinstance(model, str) else None, request)
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.debug("Failed to hash the request", exc_info=True)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def finish_with_response(span: Span, endpoint: Endpoint, result: Any) -> Any:
|
|
220
|
+
"""Record a successful provider result; wrap it if it is a stream."""
|
|
221
|
+
try:
|
|
222
|
+
if endpoint.is_stream(result):
|
|
223
|
+
return _wrap_stream(result, endpoint.new_accumulator(), span)
|
|
224
|
+
endpoint.record_response(span, result)
|
|
225
|
+
except Exception:
|
|
226
|
+
logger.debug("Failed to record provider response", exc_info=True)
|
|
227
|
+
span.end()
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _wrap_stream(result: Any, accumulator: StreamAccumulator, span: Span) -> Any:
|
|
232
|
+
if hasattr(result, "__anext__"):
|
|
233
|
+
return AsyncStreamProxy(result, accumulator, span)
|
|
234
|
+
return SyncStreamProxy(result, accumulator, span)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def finish_with_error(span: Span, error: BaseException) -> None:
|
|
238
|
+
"""Record a provider exception on ``span`` and end it. Never raises."""
|
|
239
|
+
try:
|
|
240
|
+
record_provider_error(span, error)
|
|
241
|
+
span.end()
|
|
242
|
+
except Exception:
|
|
243
|
+
logger.debug("Failed to record provider error", exc_info=True)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def wrap_sync_method(
|
|
247
|
+
original: Callable[..., Any],
|
|
248
|
+
endpoint: Endpoint,
|
|
249
|
+
get_client: ClientGetter,
|
|
250
|
+
) -> Callable[..., Any]:
|
|
251
|
+
"""Return a traced version of a synchronous provider method."""
|
|
252
|
+
|
|
253
|
+
@functools.wraps(original)
|
|
254
|
+
def traced(*args: Any, **kwargs: Any) -> Any:
|
|
255
|
+
prompt = pop_prompt(kwargs)
|
|
256
|
+
span = start_llm_span(get_client, endpoint, kwargs, prompt)
|
|
257
|
+
if span is None:
|
|
258
|
+
return original(*args, **kwargs)
|
|
259
|
+
try:
|
|
260
|
+
result = original(*args, **kwargs)
|
|
261
|
+
except BaseException as exc:
|
|
262
|
+
finish_with_error(span, exc)
|
|
263
|
+
raise
|
|
264
|
+
return finish_with_response(span, endpoint, result)
|
|
265
|
+
|
|
266
|
+
setattr(traced, WRAPPED_MARKER, True)
|
|
267
|
+
return traced
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def wrap_async_method(
|
|
271
|
+
original: Callable[..., Any],
|
|
272
|
+
endpoint: Endpoint,
|
|
273
|
+
get_client: ClientGetter,
|
|
274
|
+
) -> Callable[..., Any]:
|
|
275
|
+
"""Return a traced version of an ``async`` provider method."""
|
|
276
|
+
|
|
277
|
+
@functools.wraps(original)
|
|
278
|
+
async def traced(*args: Any, **kwargs: Any) -> Any:
|
|
279
|
+
prompt = pop_prompt(kwargs)
|
|
280
|
+
span = start_llm_span(get_client, endpoint, kwargs, prompt)
|
|
281
|
+
if span is None:
|
|
282
|
+
return await original(*args, **kwargs)
|
|
283
|
+
try:
|
|
284
|
+
result = await original(*args, **kwargs)
|
|
285
|
+
except BaseException as exc:
|
|
286
|
+
finish_with_error(span, exc)
|
|
287
|
+
raise
|
|
288
|
+
return finish_with_response(span, endpoint, result)
|
|
289
|
+
|
|
290
|
+
setattr(traced, WRAPPED_MARKER, True)
|
|
291
|
+
return traced
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def patch_method(
|
|
295
|
+
resource: Any,
|
|
296
|
+
attribute: str,
|
|
297
|
+
make_wrapper: Callable[[Callable[..., Any]], Callable[..., Any]],
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Replace ``resource.attribute`` with a wrapped version, at most once."""
|
|
300
|
+
original = getattr(resource, attribute, None)
|
|
301
|
+
if original is None or getattr(original, WRAPPED_MARKER, False):
|
|
302
|
+
return
|
|
303
|
+
setattr(resource, attribute, make_wrapper(original))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _StreamProxyBase:
|
|
307
|
+
"""State shared by the sync and async stream proxies."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, inner: Any, accumulator: StreamAccumulator, span: Span) -> None:
|
|
310
|
+
self._inner = inner
|
|
311
|
+
self._accumulator = accumulator
|
|
312
|
+
self._span = span
|
|
313
|
+
self._finished = False
|
|
314
|
+
|
|
315
|
+
def __getattr__(self, name: str) -> Any:
|
|
316
|
+
# Only called for attributes not found on the proxy itself, so the
|
|
317
|
+
# provider stream's public API (``response``, ``request_id``...) stays
|
|
318
|
+
# available to callers and to the providers' own stream helpers.
|
|
319
|
+
if name == "_inner":
|
|
320
|
+
raise AttributeError(name)
|
|
321
|
+
return getattr(self._inner, name)
|
|
322
|
+
|
|
323
|
+
def _observe(self, event: Any) -> None:
|
|
324
|
+
try:
|
|
325
|
+
self._accumulator.on_event(event, self._span)
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.debug("Failed to observe stream event", exc_info=True)
|
|
328
|
+
|
|
329
|
+
def finalize(self, error: BaseException | None = None) -> None:
|
|
330
|
+
"""End the span once, recording accumulated data and any error."""
|
|
331
|
+
if self._finished:
|
|
332
|
+
return
|
|
333
|
+
self._finished = True
|
|
334
|
+
try:
|
|
335
|
+
self._accumulator.finish(self._span)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.debug("Failed to summarize stream", exc_info=True)
|
|
338
|
+
if error is not None:
|
|
339
|
+
finish_with_error(self._span, error)
|
|
340
|
+
else:
|
|
341
|
+
self._span.end()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class SyncStreamProxy(_StreamProxyBase):
|
|
345
|
+
"""Transparent wrapper around a provider's synchronous ``Stream``.
|
|
346
|
+
|
|
347
|
+
The span ends when the stream is exhausted, closed, or raises.
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
def __iter__(self) -> Iterator[Any]:
|
|
351
|
+
return self
|
|
352
|
+
|
|
353
|
+
def __next__(self) -> Any:
|
|
354
|
+
try:
|
|
355
|
+
event = next(self._inner)
|
|
356
|
+
except StopIteration:
|
|
357
|
+
self.finalize()
|
|
358
|
+
raise
|
|
359
|
+
except BaseException as exc:
|
|
360
|
+
self.finalize(exc)
|
|
361
|
+
raise
|
|
362
|
+
self._observe(event)
|
|
363
|
+
return event
|
|
364
|
+
|
|
365
|
+
def close(self) -> None:
|
|
366
|
+
"""Close the underlying stream and end the span."""
|
|
367
|
+
try:
|
|
368
|
+
self._inner.close()
|
|
369
|
+
finally:
|
|
370
|
+
self.finalize()
|
|
371
|
+
|
|
372
|
+
def __enter__(self) -> SyncStreamProxy:
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
def __exit__(
|
|
376
|
+
self,
|
|
377
|
+
exc_type: type[BaseException] | None,
|
|
378
|
+
exc: BaseException | None,
|
|
379
|
+
traceback: TracebackType | None,
|
|
380
|
+
) -> None:
|
|
381
|
+
# An exception raised by the caller's own code inside the ``with``
|
|
382
|
+
# block is not a provider failure, so the span simply ends here.
|
|
383
|
+
self.close()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class AsyncStreamProxy(_StreamProxyBase):
|
|
387
|
+
"""Transparent wrapper around a provider's ``AsyncStream``."""
|
|
388
|
+
|
|
389
|
+
def __aiter__(self) -> AsyncIterator[Any]:
|
|
390
|
+
return self
|
|
391
|
+
|
|
392
|
+
async def __anext__(self) -> Any:
|
|
393
|
+
try:
|
|
394
|
+
event = await self._inner.__anext__()
|
|
395
|
+
except StopAsyncIteration:
|
|
396
|
+
self.finalize()
|
|
397
|
+
raise
|
|
398
|
+
except BaseException as exc:
|
|
399
|
+
self.finalize(exc)
|
|
400
|
+
raise
|
|
401
|
+
self._observe(event)
|
|
402
|
+
return event
|
|
403
|
+
|
|
404
|
+
async def close(self) -> None:
|
|
405
|
+
"""Close the underlying stream and end the span."""
|
|
406
|
+
try:
|
|
407
|
+
await self._inner.close()
|
|
408
|
+
finally:
|
|
409
|
+
self.finalize()
|
|
410
|
+
|
|
411
|
+
async def __aenter__(self) -> AsyncStreamProxy:
|
|
412
|
+
return self
|
|
413
|
+
|
|
414
|
+
async def __aexit__(
|
|
415
|
+
self,
|
|
416
|
+
exc_type: type[BaseException] | None,
|
|
417
|
+
exc: BaseException | None,
|
|
418
|
+
traceback: TracebackType | None,
|
|
419
|
+
) -> None:
|
|
420
|
+
# An exception raised by the caller's own code inside the ``with``
|
|
421
|
+
# block is not a provider failure, so the span simply ends here.
|
|
422
|
+
await self.close()
|