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/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Package version, kept in one place."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Auto-instrumentation for LLM provider SDKs.
2
+
3
+ The provider packages are optional: they are imported only when the
4
+ corresponding ``wrap_*`` function is called.
5
+ """
@@ -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()