spanlight 0.1.0__tar.gz
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-0.1.0/.gitignore +8 -0
- spanlight-0.1.0/LICENSE +21 -0
- spanlight-0.1.0/PKG-INFO +447 -0
- spanlight-0.1.0/README.md +420 -0
- spanlight-0.1.0/examples/support_bot.py +155 -0
- spanlight-0.1.0/pyproject.toml +87 -0
- spanlight-0.1.0/src/spanlight/__init__.py +59 -0
- spanlight-0.1.0/src/spanlight/_client.py +459 -0
- spanlight-0.1.0/src/spanlight/_config.py +138 -0
- spanlight-0.1.0/src/spanlight/_context.py +79 -0
- spanlight-0.1.0/src/spanlight/_decorator.py +345 -0
- spanlight-0.1.0/src/spanlight/_experiments.py +256 -0
- spanlight-0.1.0/src/spanlight/_experiments_api.py +182 -0
- spanlight-0.1.0/src/spanlight/_experiments_parse.py +68 -0
- spanlight-0.1.0/src/spanlight/_experiments_report.py +100 -0
- spanlight-0.1.0/src/spanlight/_experiments_runs.py +122 -0
- spanlight-0.1.0/src/spanlight/_experiments_setup.py +85 -0
- spanlight-0.1.0/src/spanlight/_exporter.py +462 -0
- spanlight-0.1.0/src/spanlight/_globals.py +198 -0
- spanlight-0.1.0/src/spanlight/_ids.py +45 -0
- spanlight-0.1.0/src/spanlight/_prompt_client.py +241 -0
- spanlight-0.1.0/src/spanlight/_prompts.py +247 -0
- spanlight-0.1.0/src/spanlight/_request_hash.py +58 -0
- spanlight-0.1.0/src/spanlight/_scores.py +57 -0
- spanlight-0.1.0/src/spanlight/_serialize.py +126 -0
- spanlight-0.1.0/src/spanlight/_span.py +337 -0
- spanlight-0.1.0/src/spanlight/_templates.py +238 -0
- spanlight-0.1.0/src/spanlight/_version.py +3 -0
- spanlight-0.1.0/src/spanlight/integrations/__init__.py +5 -0
- spanlight-0.1.0/src/spanlight/integrations/_common.py +422 -0
- spanlight-0.1.0/src/spanlight/integrations/anthropic.py +349 -0
- spanlight-0.1.0/src/spanlight/integrations/openai.py +356 -0
- spanlight-0.1.0/src/spanlight/py.typed +0 -0
- spanlight-0.1.0/tests/conftest.py +59 -0
- spanlight-0.1.0/tests/fixtures/template_cases.json +723 -0
- spanlight-0.1.0/tests/provider_mocks.py +424 -0
- spanlight-0.1.0/tests/support.py +63 -0
- spanlight-0.1.0/tests/test_anthropic.py +253 -0
- spanlight-0.1.0/tests/test_decorator.py +444 -0
- spanlight-0.1.0/tests/test_exporter.py +395 -0
- spanlight-0.1.0/tests/test_openai.py +304 -0
- spanlight-0.1.0/tests/test_package.py +29 -0
- spanlight-0.1.0/tests/test_spans_and_context.py +209 -0
- spanlight-0.1.0/uv.lock +983 -0
spanlight-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dheeraj Karwasra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
spanlight-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: spanlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Spanlight, open-source observability for LLM applications.
|
|
5
|
+
Author: Dheeraj
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: anthropic,llm,observability,openai,tracing
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: System :: Monitoring
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx<1,>=0.27
|
|
22
|
+
Provides-Extra: anthropic
|
|
23
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
24
|
+
Provides-Extra: openai
|
|
25
|
+
Requires-Dist: openai>=1.66; extra == 'openai'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Spanlight Python SDK
|
|
29
|
+
|
|
30
|
+
Tracing for LLM applications. Instrument your code with a decorator, wrap your
|
|
31
|
+
OpenAI or Anthropic client, and every request shows up in
|
|
32
|
+
[Spanlight](https://github.com/dheerajkarwasra/spanlight/blob/main/README.md) with latency, tokens, cost, errors and the
|
|
33
|
+
full prompt and completion.
|
|
34
|
+
|
|
35
|
+
- **Low overhead.** Tracing never waits on the network: spans are queued in
|
|
36
|
+
memory and sent in batches from a background thread.
|
|
37
|
+
- **Tracing never breaks your app.** SDK failures are logged and swallowed.
|
|
38
|
+
Exceptions from your code and from the providers are recorded and re-raised
|
|
39
|
+
exactly as they were. (`prompts.get` is the one call that can block and
|
|
40
|
+
raise; see [Prompts](#prompts).)
|
|
41
|
+
- **One dependency.** Only `httpx` is required. `openai` and `anthropic` are
|
|
42
|
+
optional extras.
|
|
43
|
+
- **Typed.** Ships `py.typed` and passes `mypy --strict`.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install "spanlight[anthropic]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The core package needs only `httpx`. Add the extra for the provider you use:
|
|
52
|
+
`spanlight[openai]`, `spanlight[anthropic]` or `spanlight[openai,anthropic]`.
|
|
53
|
+
Plain `pip install spanlight` is enough if you only use `@observe` and `span`.
|
|
54
|
+
With uv, run `uv add "spanlight[anthropic]"`.
|
|
55
|
+
|
|
56
|
+
To run the code on `main` instead of the last release, install from the
|
|
57
|
+
repository's `sdks/python` subdirectory:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install "spanlight[anthropic] @ git+https://github.com/dheerajkarwasra/spanlight#subdirectory=sdks/python"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Python 3.10 or newer is required.
|
|
64
|
+
|
|
65
|
+
## Quickstart
|
|
66
|
+
|
|
67
|
+
Create a project in Spanlight, generate an API key under
|
|
68
|
+
**Settings → API keys**, and export it:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export SPANLIGHT_API_KEY=spl_live_...
|
|
72
|
+
export SPANLIGHT_HOST=http://localhost:8000 # or your hosted instance
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import spanlight
|
|
77
|
+
|
|
78
|
+
spanlight.init(environment="production", release="2026.10.07")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@spanlight.observe(kind="retrieval")
|
|
82
|
+
def search_docs(query: str) -> list[str]:
|
|
83
|
+
return ["Refunds take 5-7 business days."]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@spanlight.observe(name="support-request")
|
|
87
|
+
def handle(question: str, user_id: str) -> str:
|
|
88
|
+
spanlight.update_trace(user_id=user_id, session_id="chat-123", tags=["beta"])
|
|
89
|
+
docs = search_docs(question)
|
|
90
|
+
|
|
91
|
+
with spanlight.span("format-answer", kind="chain") as span:
|
|
92
|
+
answer = f"From our docs: {docs[0]}"
|
|
93
|
+
span.set_output(answer)
|
|
94
|
+
return answer
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
handle("How long do refunds take?", user_id="u_42")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This produces one trace with three nested spans: `support-request`,
|
|
101
|
+
`search_docs` and `format-answer`. Long-running servers need nothing else.
|
|
102
|
+
Short scripts, CLIs and serverless handlers should call `spanlight.flush()`
|
|
103
|
+
before they exit. An `atexit` hook also flushes, with a timeout, when the
|
|
104
|
+
interpreter shuts down.
|
|
105
|
+
|
|
106
|
+
## OpenAI
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import spanlight
|
|
110
|
+
from openai import OpenAI
|
|
111
|
+
|
|
112
|
+
spanlight.init()
|
|
113
|
+
client = spanlight.wrap_openai(OpenAI())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@spanlight.observe
|
|
117
|
+
def answer(question: str) -> str:
|
|
118
|
+
completion = client.chat.completions.create(
|
|
119
|
+
model="gpt-4o-mini",
|
|
120
|
+
messages=[{"role": "user", "content": question}],
|
|
121
|
+
)
|
|
122
|
+
return completion.choices[0].message.content or ""
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`wrap_openai` instruments `chat.completions.create` and `responses.create` on
|
|
126
|
+
the client it is given. It works with `OpenAI`, `AsyncOpenAI` and the Azure
|
|
127
|
+
variants. The SDK's own `.stream()` helpers are traced as well, because they
|
|
128
|
+
call `create` internally.
|
|
129
|
+
|
|
130
|
+
Streaming works without any changes to your code. Usage is recorded when
|
|
131
|
+
OpenAI sends it; for Chat Completions that means opting in:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
stream = client.chat.completions.create(
|
|
135
|
+
model="gpt-4o-mini",
|
|
136
|
+
messages=[{"role": "user", "content": "Tell me a joke"}],
|
|
137
|
+
stream=True,
|
|
138
|
+
stream_options={"include_usage": True}, # include token usage in the stream
|
|
139
|
+
)
|
|
140
|
+
for chunk in stream:
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The span ends when the stream is exhausted or closed, and records
|
|
145
|
+
time-to-first-token. If you point the OpenAI SDK at a compatible API, set the
|
|
146
|
+
provider name explicitly: `wrap_openai(client, provider="openrouter")`.
|
|
147
|
+
|
|
148
|
+
## Anthropic
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
import spanlight
|
|
152
|
+
from anthropic import Anthropic
|
|
153
|
+
|
|
154
|
+
spanlight.init()
|
|
155
|
+
client = spanlight.wrap_anthropic(Anthropic())
|
|
156
|
+
|
|
157
|
+
message = client.messages.create(
|
|
158
|
+
model="claude-haiku-4-5",
|
|
159
|
+
max_tokens=512,
|
|
160
|
+
messages=[{"role": "user", "content": "Summarize our refund policy."}],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# The streaming helper is traced too, including time-to-first-token.
|
|
164
|
+
with client.messages.stream(
|
|
165
|
+
model="claude-haiku-4-5",
|
|
166
|
+
max_tokens=512,
|
|
167
|
+
messages=[{"role": "user", "content": "Write a haiku about latency."}],
|
|
168
|
+
) as stream:
|
|
169
|
+
for text in stream.text_stream:
|
|
170
|
+
print(text, end="")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`wrap_anthropic` instruments `messages.create` (including `stream=True`) and
|
|
174
|
+
`messages.stream` on both `Anthropic` and `AsyncAnthropic`.
|
|
175
|
+
|
|
176
|
+
## Prompts
|
|
177
|
+
|
|
178
|
+
Keep prompts in Spanlight and fetch them by label, so a new version ships
|
|
179
|
+
without a deploy. The API key needs the `prompts:read` scope.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
import spanlight
|
|
183
|
+
from openai import OpenAI
|
|
184
|
+
|
|
185
|
+
spanlight.init()
|
|
186
|
+
client = spanlight.wrap_openai(OpenAI())
|
|
187
|
+
|
|
188
|
+
prompt = spanlight.prompts.get(
|
|
189
|
+
"support/answer", # label "production" by default
|
|
190
|
+
fallback=[{"role": "user", "content": "Answer briefly: {{question}}"}],
|
|
191
|
+
)
|
|
192
|
+
compiled = prompt.compile(question="How do refunds work?")
|
|
193
|
+
|
|
194
|
+
completion = client.chat.completions.create(
|
|
195
|
+
model=prompt.model_config.get("model", "gpt-4o-mini"),
|
|
196
|
+
messages=compiled.messages,
|
|
197
|
+
spanlight_prompt=compiled, # links the span to this prompt version
|
|
198
|
+
)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`get(name, label="production", *, version=None, fallback=None)` returns a
|
|
202
|
+
`Prompt` with `name`, `version`, `version_id`, `kind` (`text` or `chat`),
|
|
203
|
+
`template`, `variables` and `model_config`. Pass `version=3` to pin a number
|
|
204
|
+
instead of a label.
|
|
205
|
+
|
|
206
|
+
- **Caching.** A fetched prompt is served from memory for `prompt_cache_ttl`
|
|
207
|
+
seconds (60 by default), then revalidated with `If-None-Match`, so an
|
|
208
|
+
unchanged prompt costs a `304`. Concurrent first calls for the same prompt
|
|
209
|
+
make one request.
|
|
210
|
+
- **Outages.** After the TTL, if the server cannot be reached or answers with
|
|
211
|
+
an error, the cached copy keeps being served and one warning per prompt per
|
|
212
|
+
minute is logged. With nothing cached, `fallback` (a text template or a list
|
|
213
|
+
of `{"role", "content"}` messages) is used and the prompt has
|
|
214
|
+
`version=None`. With neither, `get` raises `PromptUnavailableError`, the one
|
|
215
|
+
exception the SDK raises on purpose. A failed fetch is remembered for a few
|
|
216
|
+
seconds, so concurrent callers do not each wait out the timeout, and while
|
|
217
|
+
one caller revalidates an expired prompt the others get the cached copy.
|
|
218
|
+
- **Disabled SDK.** `enabled=False` turns off tracing and scores, but
|
|
219
|
+
`prompts.get` still calls the server when an API key is set. Pass no key (and
|
|
220
|
+
a `fallback`) to keep tests offline.
|
|
221
|
+
- **Blocking.** `get` is synchronous. In `async` code a cache miss blocks the
|
|
222
|
+
event loop for up to `timeout`; call it at startup or through
|
|
223
|
+
`asyncio.to_thread`.
|
|
224
|
+
|
|
225
|
+
`Prompt.compile(**variables)` fills in `{{name}}` variables (spaces inside the
|
|
226
|
+
braces are allowed; values are inserted as they are and never expanded again).
|
|
227
|
+
Values are strings; numbers and booleans are converted with `str()`, and any
|
|
228
|
+
other type raises `TypeError`.
|
|
229
|
+
The result has `text` for a text prompt, `messages` (plain dicts for the OpenAI
|
|
230
|
+
and Anthropic SDKs) for a chat prompt, and `unused_variables` for variables you
|
|
231
|
+
passed that the template does not use. A missing variable raises
|
|
232
|
+
`MissingVariablesError` naming all of them; a malformed template raises
|
|
233
|
+
`InvalidTemplateError`.
|
|
234
|
+
|
|
235
|
+
To link spans to a prompt version, pass `spanlight_prompt=compiled` to a
|
|
236
|
+
wrapped OpenAI or Anthropic call (the keyword is removed before the provider
|
|
237
|
+
sees it), or enter the compiled prompt; every span started inside the block is
|
|
238
|
+
linked:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
with compiled:
|
|
242
|
+
answer = run_agent(compiled.messages)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The link is a `contextvars` value, so it follows the same rules as the current
|
|
246
|
+
span: an `asyncio` task created inside the block keeps the link for its whole
|
|
247
|
+
life, and a thread pool worker does not see it unless you copy the context.
|
|
248
|
+
Start long-lived work inside `with spanlight.no_prompt():` to keep its spans
|
|
249
|
+
unlinked. Passing a fallback (`version=None`) as `spanlight_prompt=` clears the
|
|
250
|
+
link for that call.
|
|
251
|
+
|
|
252
|
+
## Scores
|
|
253
|
+
|
|
254
|
+
Attach quality scores to a trace, or to one of its spans, from your code:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
trace_id = spanlight.get_current_trace_id()
|
|
258
|
+
spanlight.score(trace_id, "helpfulness", 0.8)
|
|
259
|
+
spanlight.score(trace_id, "resolved", True, comment="user said thanks")
|
|
260
|
+
spanlight.score(trace_id, "tone", "friendly", span_id=span.span_id)
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
A number makes a numeric score, `True`/`False` a boolean score and a string a
|
|
264
|
+
categorical one. Scores are sent in the background with the spans, after the
|
|
265
|
+
spans of the same batch, and each gets a unique id so a retried request never
|
|
266
|
+
stores one twice. `score` never raises. The API key needs the `scores:write`
|
|
267
|
+
scope; a server that predates scores answers `404`, which is logged once.
|
|
268
|
+
|
|
269
|
+
## Experiments in CI
|
|
270
|
+
|
|
271
|
+
The SDK includes the runner behind the `eval-gate` GitHub Action. It runs a
|
|
272
|
+
prompt version over a dataset on the server, scores the answers with your
|
|
273
|
+
evaluators and exits non-zero when a score is below its threshold:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
export SPANLIGHT_TOKEN=spl_pat_... # a personal access token with the write scope
|
|
277
|
+
python -m spanlight._experiments \
|
|
278
|
+
--host https://spanlight.example.com --project-id <project id> \
|
|
279
|
+
--dataset support-questions --prompt support/answer@production \
|
|
280
|
+
--evaluators relevance,format --fail-below relevance=0.8,format=1 \
|
|
281
|
+
--credential-id <credential id> --model claude-haiku-5-5
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`--prompt` is `name`, `name@label` or `name:version`. Also pass `--credential-id`
|
|
285
|
+
and `--model` (with the evaluators they select the experiment), and optionally
|
|
286
|
+
`--max-cost-usd` to cap a new experiment's runs. Exit code `0` means
|
|
287
|
+
every threshold held, `1` a score was below its threshold and `2` the run could
|
|
288
|
+
not be set up, failed or timed out (`--timeout`, 1800 seconds by default). The
|
|
289
|
+
host must be `https://` unless it is localhost. Each
|
|
290
|
+
run makes real provider calls. In a GitHub workflow use the
|
|
291
|
+
`.github/actions/eval-gate` Action instead of calling the module yourself.
|
|
292
|
+
|
|
293
|
+
## What gets recorded
|
|
294
|
+
|
|
295
|
+
For every LLM call:
|
|
296
|
+
|
|
297
|
+
| Field | Source |
|
|
298
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
299
|
+
| `provider` | `openai` / `anthropic` |
|
|
300
|
+
| `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
|
|
301
|
+
| `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
|
|
302
|
+
| `usage.output_tokens` | Completion tokens |
|
|
303
|
+
| `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
|
|
304
|
+
| `time_to_first_token_ms` | Streaming calls only |
|
|
305
|
+
| `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
|
|
306
|
+
| `output` | The assistant message, including tool calls |
|
|
307
|
+
| `status` / `status_message` | `error` plus the provider's message when the call fails |
|
|
308
|
+
|
|
309
|
+
The Spanlight server also redacts common secret patterns from
|
|
310
|
+
payloads on ingestion. Set `capture_payloads = false` on a project to drop
|
|
311
|
+
inputs and outputs entirely. To keep payloads out of the SDK side for a
|
|
312
|
+
specific function, use `@observe(capture_input=False, capture_output=False)`.
|
|
313
|
+
|
|
314
|
+
## API reference
|
|
315
|
+
|
|
316
|
+
### `init(**options) -> Spanlight`
|
|
317
|
+
|
|
318
|
+
Configures the global client used by `observe`, `span` and the wrappers.
|
|
319
|
+
Calling it again replaces the previous client after flushing it.
|
|
320
|
+
`tracer = Spanlight(**options)` creates an independent client with the same
|
|
321
|
+
options. Use `tracer.observe`, `tracer.span` and
|
|
322
|
+
`wrap_openai(client, spanlight=tracer)` to report through it.
|
|
323
|
+
|
|
324
|
+
| Option | Environment variable | Default | Meaning |
|
|
325
|
+
| ------------------ | ----------------------- | ----------------------- | ------------------------------------------------------------------------ |
|
|
326
|
+
| `api_key` | `SPANLIGHT_API_KEY` | none | Project API key. Without one the SDK logs a warning and runs as a no-op. |
|
|
327
|
+
| `host` | `SPANLIGHT_HOST` | `http://localhost:8000` | Base URL of the Spanlight API |
|
|
328
|
+
| `environment` | `SPANLIGHT_ENVIRONMENT` | none | Attached to every trace, for example `production` |
|
|
329
|
+
| `release` | `SPANLIGHT_RELEASE` | none | Your app version or git SHA |
|
|
330
|
+
| `enabled` | `SPANLIGHT_ENABLED` | `true` | `false`/`0`/`no`/`off` turns tracing and scores into a no-op |
|
|
331
|
+
| `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
|
|
332
|
+
| `batch_size` | | `100` | Spans per request (max 1000) |
|
|
333
|
+
| `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
|
|
334
|
+
| `gzip` | | `False` | Gzip-compress request bodies |
|
|
335
|
+
| `timeout` | | `10.0` | HTTP timeout per request, in seconds |
|
|
336
|
+
| `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
|
|
337
|
+
| `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
|
|
338
|
+
| `prompt_cache_ttl` | | `60.0` | Seconds a fetched prompt is served before it is revalidated |
|
|
339
|
+
|
|
340
|
+
### `@observe(name=None, kind="chain", capture_input=True, capture_output=True)`
|
|
341
|
+
|
|
342
|
+
Traces each call of a function. It supports plain functions, `async def`,
|
|
343
|
+
generators and async generators. For generators, the span stays open until
|
|
344
|
+
iteration finishes or the generator is closed, and the yielded items become
|
|
345
|
+
the output (strings are joined). `kind` is one of `llm`, `tool`, `retrieval`,
|
|
346
|
+
`chain`, `http` or `other`. Both `@observe` and `@observe(...)` forms work.
|
|
347
|
+
|
|
348
|
+
### `span(name, kind="chain", input=None, attributes=None)`
|
|
349
|
+
|
|
350
|
+
A context manager for `with` or `async with` that yields a `Span`:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
with spanlight.span("rerank", kind="retrieval") as span:
|
|
354
|
+
span.set_input({"candidates": 50})
|
|
355
|
+
span.set_attribute("reranker", "bge-v2")
|
|
356
|
+
span.set_output(top_k)
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
|
|
360
|
+
`set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
|
|
361
|
+
cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
|
|
362
|
+
`mark_first_token()` and `record_error(exc_or_message)`. An
|
|
363
|
+
exception that escapes the block marks the span as failed and is re-raised
|
|
364
|
+
unchanged.
|
|
365
|
+
|
|
366
|
+
### `update_trace(name=None, user_id=None, session_id=None, tags=None)`
|
|
367
|
+
|
|
368
|
+
Sets trace-level fields on the trace of the current span. You can call it
|
|
369
|
+
anywhere inside the trace. Tags accumulate across calls.
|
|
370
|
+
|
|
371
|
+
### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
|
|
372
|
+
|
|
373
|
+
`flush` blocks until every span ended and score recorded so far has been sent. `shutdown` flushes
|
|
374
|
+
and then stops the background thread.
|
|
375
|
+
|
|
376
|
+
### Context propagation
|
|
377
|
+
|
|
378
|
+
The current span is stored in a `contextvars.ContextVar`. Nested calls and
|
|
379
|
+
`asyncio` tasks pick it up automatically. Threads do not inherit context, so
|
|
380
|
+
copy it explicitly when you hand work to a thread pool:
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
import contextvars
|
|
384
|
+
|
|
385
|
+
context = contextvars.copy_context()
|
|
386
|
+
executor.submit(context.run, do_work, item)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Trace ids are 32 lowercase hex characters and span ids are 16, both
|
|
390
|
+
W3C Trace Context compatible. `spanlight.get_current_trace_id()` returns the
|
|
391
|
+
current trace id so you can put it in your logs.
|
|
392
|
+
|
|
393
|
+
## Delivery guarantees
|
|
394
|
+
|
|
395
|
+
Spans are sent by a background daemon thread. A batch goes out when it reaches
|
|
396
|
+
`batch_size` spans or after `flush_interval` seconds. Failed requests are
|
|
397
|
+
retried with jittered exponential backoff on network errors, `429` and `5xx`,
|
|
398
|
+
and `Retry-After` is honored. Other `4xx` responses are not retried. They are
|
|
399
|
+
logged to the `spanlight` logger, as are any spans the server rejects. If the
|
|
400
|
+
queue fills up, the oldest spans are dropped and counted
|
|
401
|
+
(`spanlight.get_client().exporter.dropped_count`).
|
|
402
|
+
|
|
403
|
+
To see what the SDK is doing:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
import logging
|
|
407
|
+
|
|
408
|
+
logging.getLogger("spanlight").setLevel(logging.DEBUG)
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Example
|
|
412
|
+
|
|
413
|
+
[`examples/support_bot.py`](https://github.com/dheerajkarwasra/spanlight/blob/main/sdks/python/examples/support_bot.py) is a small three-step
|
|
414
|
+
support bot (classify, retrieve, answer with streaming). It runs against
|
|
415
|
+
Anthropic or OpenAI, whichever key you have set:
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
export SPANLIGHT_API_KEY=spl_live_... ANTHROPIC_API_KEY=sk-ant-...
|
|
419
|
+
uv run --extra anthropic --extra openai python examples/support_bot.py "How do refunds work?"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Development
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
cd sdks/python
|
|
426
|
+
uv sync
|
|
427
|
+
uv run ruff check
|
|
428
|
+
uv run ruff format --check
|
|
429
|
+
uv run mypy src
|
|
430
|
+
uv run pytest
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The wrapper tests run the real `openai` and `anthropic` SDKs against an
|
|
434
|
+
in-process mock HTTP transport. The mock returns recorded-shape JSON and SSE
|
|
435
|
+
responses, so no network access or API keys are needed.
|
|
436
|
+
|
|
437
|
+
## Limitations
|
|
438
|
+
|
|
439
|
+
- The wrappers instrument the client instance you pass in. Calls made through
|
|
440
|
+
`.with_raw_response` / `.with_streaming_response` are passed through
|
|
441
|
+
untraced.
|
|
442
|
+
- Wrapped streaming calls return a transparent proxy, not the provider's
|
|
443
|
+
`Stream` class, so `isinstance(result, openai.Stream)` is `False`. Iteration,
|
|
444
|
+
context-manager use, `close()` and attributes such as `.response` all
|
|
445
|
+
behave as before.
|
|
446
|
+
- If a stream is abandoned without being exhausted or closed, its span is
|
|
447
|
+
never sent.
|