spanlight 0.1.2__tar.gz → 0.2.1__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.
Files changed (77) hide show
  1. {spanlight-0.1.2 → spanlight-0.2.1}/PKG-INFO +121 -5
  2. {spanlight-0.1.2 → spanlight-0.2.1}/README.md +120 -4
  3. {spanlight-0.1.2 → spanlight-0.2.1}/pyproject.toml +1 -1
  4. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/__init__.py +2 -0
  5. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_client.py +15 -5
  6. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_decorator.py +40 -5
  7. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments_report.py +47 -1
  8. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_exporter.py +29 -2
  9. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_exporter_delivery.py +49 -10
  10. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_globals.py +1 -1
  11. spanlight-0.2.1/src/spanlight/_metadata.py +110 -0
  12. spanlight-0.2.1/src/spanlight/_ratelimit_headers.py +101 -0
  13. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_serialize.py +44 -20
  14. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_span.py +47 -126
  15. spanlight-0.2.1/src/spanlight/_tiers.py +94 -0
  16. spanlight-0.2.1/src/spanlight/_trace_state.py +131 -0
  17. spanlight-0.2.1/src/spanlight/_usage.py +146 -0
  18. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_version.py +1 -1
  19. spanlight-0.2.1/src/spanlight/integrations/_anthropic_batch.py +135 -0
  20. spanlight-0.2.1/src/spanlight/integrations/_anthropic_message.py +66 -0
  21. spanlight-0.1.2/src/spanlight/integrations/anthropic.py → spanlight-0.2.1/src/spanlight/integrations/_anthropic_stream.py +46 -161
  22. spanlight-0.2.1/src/spanlight/integrations/_batch.py +167 -0
  23. spanlight-0.2.1/src/spanlight/integrations/_common.py +259 -0
  24. spanlight-0.2.1/src/spanlight/integrations/_modifiers.py +64 -0
  25. spanlight-0.2.1/src/spanlight/integrations/_openai_batch.py +181 -0
  26. spanlight-0.2.1/src/spanlight/integrations/_openai_response.py +108 -0
  27. spanlight-0.2.1/src/spanlight/integrations/_openai_stream.py +190 -0
  28. spanlight-0.2.1/src/spanlight/integrations/_provider_errors.py +202 -0
  29. spanlight-0.2.1/src/spanlight/integrations/_streams.py +188 -0
  30. spanlight-0.2.1/src/spanlight/integrations/_wrappers.py +91 -0
  31. spanlight-0.2.1/src/spanlight/integrations/anthropic.py +140 -0
  32. spanlight-0.2.1/src/spanlight/integrations/openai.py +199 -0
  33. {spanlight-0.1.2 → spanlight-0.2.1}/tests/provider_mocks.py +127 -11
  34. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_anthropic.py +187 -0
  35. spanlight-0.2.1/tests/test_anthropic_batch_results.py +235 -0
  36. spanlight-0.2.1/tests/test_anthropic_cut_stream.py +214 -0
  37. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_decorator.py +19 -0
  38. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_exporter.py +34 -7
  39. spanlight-0.2.1/tests/test_exporter_counters.py +163 -0
  40. spanlight-0.2.1/tests/test_gateway_refusal.py +193 -0
  41. spanlight-0.2.1/tests/test_metadata.py +86 -0
  42. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_openai.py +70 -5
  43. spanlight-0.2.1/tests/test_openai_batch_output.py +250 -0
  44. spanlight-0.2.1/tests/test_openai_tier_and_search.py +257 -0
  45. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_package.py +5 -0
  46. spanlight-0.2.1/tests/test_provider_errors.py +350 -0
  47. spanlight-0.2.1/tests/test_ratelimit_allowlist.py +103 -0
  48. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_spans_and_context.py +174 -0
  49. spanlight-0.2.1/tests/test_tiers.py +62 -0
  50. {spanlight-0.1.2 → spanlight-0.2.1}/uv.lock +1 -1
  51. spanlight-0.1.2/src/spanlight/integrations/_common.py +0 -422
  52. spanlight-0.1.2/src/spanlight/integrations/openai.py +0 -356
  53. {spanlight-0.1.2 → spanlight-0.2.1}/.gitignore +0 -0
  54. {spanlight-0.1.2 → spanlight-0.2.1}/LICENSE +0 -0
  55. {spanlight-0.1.2 → spanlight-0.2.1}/examples/support_bot.py +0 -0
  56. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_config.py +0 -0
  57. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_context.py +0 -0
  58. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments.py +0 -0
  59. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments_api.py +0 -0
  60. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments_parse.py +0 -0
  61. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments_runs.py +0 -0
  62. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_experiments_setup.py +0 -0
  63. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_ids.py +0 -0
  64. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_prompt_client.py +0 -0
  65. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_prompts.py +0 -0
  66. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_request_hash.py +0 -0
  67. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_scores.py +0 -0
  68. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/_templates.py +0 -0
  69. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/integrations/__init__.py +0 -0
  70. {spanlight-0.1.2 → spanlight-0.2.1}/src/spanlight/py.typed +0 -0
  71. {spanlight-0.1.2 → spanlight-0.2.1}/tests/conftest.py +0 -0
  72. {spanlight-0.1.2 → spanlight-0.2.1}/tests/fixtures/template_cases.json +0 -0
  73. {spanlight-0.1.2 → spanlight-0.2.1}/tests/support.py +0 -0
  74. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_docs_snippets.py +0 -0
  75. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_experiments_report.py +0 -0
  76. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_exporter_retry_after.py +0 -0
  77. {spanlight-0.1.2 → spanlight-0.2.1}/tests/test_templates_surrogates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spanlight
3
- Version: 0.1.2
3
+ Version: 0.2.1
4
4
  Summary: Python SDK for Spanlight, open-source observability for LLM applications.
5
5
  Author: Dheeraj
6
6
  License-Expression: MIT
@@ -124,7 +124,8 @@ def answer(question: str) -> str:
124
124
 
125
125
  `wrap_openai` instruments `chat.completions.create` and `responses.create` on
126
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
127
+ variants; an Azure client is recorded with the provider `azure_openai`, which the
128
+ server does not price at OpenAI's rates (add a price override for it). The SDK's own `.stream()` helpers are traced as well, because they
128
129
  call `create` internally.
129
130
 
130
131
  Streaming works without any changes to your code. Usage is recorded when
@@ -145,6 +146,56 @@ The span ends when the stream is exhausted or closed, and records
145
146
  time-to-first-token. If you point the OpenAI SDK at a compatible API, set the
146
147
  provider name explicitly: `wrap_openai(client, provider="openrouter")`.
147
148
 
149
+ When a stream carries no usage, the span's output token count is left unknown
150
+ (never guessed) and the `usage.unreported_reason` attribute says why:
151
+ `include_usage_not_set` when a Chat Completions stream ended without a usage
152
+ chunk, `no_final_usage` when a Responses stream ended without its terminal
153
+ event, `client_disconnected` when your code closed it early, and
154
+ `stream_error` when it failed. Such a call's cost shows as unknown.
155
+
156
+ ### Service tier and web search
157
+
158
+ - The `service_tier` OpenAI answers with (on the response, or on the last
159
+ stream chunk or terminal event) is recorded as `standard`, `flex`, `priority`
160
+ or `batch`; `default`, `auto` and `scale` are `standard`. A value the SDK does
161
+ not know is kept in the `usage.service_tier_raw` attribute and the call is
162
+ priced at the standard rate.
163
+ - For the Responses API, every completed `web_search_call` output item counts
164
+ as one billed web search; searches still in progress or failed are not
165
+ counted.
166
+ - Chat Completions with `web_search_options` report sources as message
167
+ annotations, which are not a count of searches. No count is recorded and the
168
+ span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
169
+ shows as unknown rather than zero.
170
+ - Requests that set `inference_geo` (other than `global`) or `speed: "fast"`
171
+ are priced at the standard rate and tagged with
172
+ `usage.pricing_modifier_unknown`, because those modifiers are not modelled.
173
+
174
+ ### Batch output
175
+
176
+ OpenAI Batch results are a file you download, so record them explicitly once
177
+ the batch has completed:
178
+
179
+ ```python
180
+ batch = client.batches.retrieve("batch_67ccd2bed1ec8190b14f964abc054267")
181
+ content = client.files.content(batch.output_file_id)
182
+ recorded = spanlight.record_openai_batch_output(content.iter_lines(), batch_id=batch.id)
183
+ ```
184
+
185
+ Pass the error file (`batch.error_file_id`) the same way to record failed
186
+ requests. Each line becomes one `llm` span with the shape described under
187
+ [Anthropic batch results](#batch-results): `service_tier` is `batch` (the tier
188
+ the body reported is kept in `usage.service_tier_raw`), cached tokens come from
189
+ the usage details, and a line whose `status_code` is not 200 or that carries an
190
+ `error` becomes an error span without usage. A Responses body with
191
+ `status: "failed"` becomes an error span that keeps the usage OpenAI reports
192
+ (it is billed), and `status: "incomplete"` a normal span whose finish reason is
193
+ `incomplete_details.reason`. Chat Completions and Responses
194
+ bodies are both read. The function accepts any iterable of `str` or `bytes`
195
+ lines (or the whole file as one string), returns the number of spans recorded,
196
+ and never raises: unreadable lines are skipped. Use `provider=` for a
197
+ compatible API and `spanlight=` for a client other than the global one.
198
+
148
199
  ## Anthropic
149
200
 
150
201
  ```python
@@ -173,6 +224,33 @@ with client.messages.stream(
173
224
  `wrap_anthropic` instruments `messages.create` (including `stream=True`) and
174
225
  `messages.stream` on both `Anthropic` and `AsyncAnthropic`.
175
226
 
227
+ ### Batch results
228
+
229
+ Results read through `messages.batches.results` are recorded as you iterate
230
+ them, on both `Anthropic` and `AsyncAnthropic`:
231
+
232
+ ```python
233
+ for item in client.messages.batches.results("msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"):
234
+ handle(item) # each item is yielded unchanged
235
+ ```
236
+
237
+ Each result becomes one `llm` span named `batch <model>`, in a trace named
238
+ `batch <batch_id>` and tagged `spanlight:batch`:
239
+
240
+ - A `succeeded` result carries the model, token usage, finish reason and the
241
+ response content, with `service_tier` set to `batch` (the Batches API bills
242
+ every request at the batch rate). The tier the response reported is kept in
243
+ the `usage.service_tier_raw` attribute.
244
+ - `errored`, `canceled` and `expired` results are error spans without usage,
245
+ because they are not billed.
246
+ - The request is not part of a result, so the span has no input
247
+ (`batch.request_available` is `false`). Its start and end are both the
248
+ moment the result was read (`timing.synthetic` is `true`).
249
+ - The attributes `batch.id`, `batch.custom_id` and `batch.result_type` link
250
+ the span back to the batch.
251
+ - Trace and span ids are derived from the batch id and `custom_id`, so reading
252
+ the same batch again updates the same spans instead of adding new ones.
253
+
176
254
  ## Prompts
177
255
 
178
256
  Keep prompts in Spanlight and fetch them by label, so a new version ships
@@ -290,6 +368,16 @@ host must be `https://` unless it is localhost. Each
290
368
  run makes real provider calls. In a GitHub workflow use the
291
369
  `.github/actions/eval-gate` Action instead of calling the module yourself.
292
370
 
371
+ The run prints a Markdown report (also appended to the GitHub step summary): the
372
+ run's items, errors, cost and latency, then one row per score with its mean, the
373
+ 95% confidence interval on the pass rate of a boolean score (for example
374
+ `64–88%`; `—` for numeric and categorical scores, and from servers that predate
375
+ intervals), the number of items, the threshold and the result. When a threshold
376
+ lies inside a boolean score's interval, the report adds "Interval includes the
377
+ threshold; the result is not conclusive at this sample size." The gate itself
378
+ still compares the mean with the threshold, so the exit codes do not depend on
379
+ the interval.
380
+
293
381
  ## What gets recorded
294
382
 
295
383
  For every LLM call:
@@ -308,6 +396,22 @@ For every LLM call:
308
396
  | `output` | The assistant message, including tool calls |
309
397
  | `status` / `status_message` | `error` plus the provider's message when the call fails |
310
398
 
399
+ When a provider call fails with an HTTP error, the span also carries the
400
+ provider's error type and code (`provider.error.type`, `provider.error.code`),
401
+ the wait it asked for in seconds (`provider.retry_after_s`, from `retry-after-ms`
402
+ or a numeric `retry-after`), and `provider.ratelimit`: the response's rate-limit
403
+ headers, filtered through a fixed allowlist of 29 names. Only `retry-after`,
404
+ `retry-after-ms`, OpenAI's `x-ratelimit-*` headers and Anthropic's
405
+ `anthropic-ratelimit-*` and `anthropic-priority-*` headers are kept, each value
406
+ at most 64 printable ASCII characters. Every other response header, including
407
+ `authorization`, `x-api-key`, `cookie`, `set-cookie` and Anthropic's
408
+ organization and workspace ids, is dropped before the span leaves your process.
409
+ Connection errors and timeouts add none of these fields.
410
+ When the Spanlight gateway refused the call itself (its `X-Spanlight-Code`
411
+ header names one of its own codes, such as `RATE_LIMITED` for your gateway key's
412
+ limit), the span carries that code as `spanlight.gateway.error_code` instead, and
413
+ none of the `provider.*` fields above.
414
+
311
415
  The Spanlight server also redacts common secret patterns from
312
416
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
313
417
  inputs and outputs entirely. To keep payloads out of the SDK side for a
@@ -337,7 +441,7 @@ options. Use `tracer.observe`, `tracer.span` and
337
441
  | `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
338
442
  | `batch_size` | | `100` | Spans per request (max 1000) |
339
443
  | `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
340
- | `gzip` | | `False` | Gzip-compress request bodies |
444
+ | `gzip` | | `True` | Gzip-compress request bodies |
341
445
  | `timeout` | | `10.0` | HTTP timeout per request, in seconds |
342
446
  | `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
343
447
  | `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
@@ -370,10 +474,14 @@ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
370
474
  exception that escapes the block marks the span as failed and is re-raised
371
475
  unchanged.
372
476
 
373
- ### `update_trace(name=None, user_id=None, session_id=None, tags=None)`
477
+ ### `update_trace(name=None, user_id=None, session_id=None, tags=None, metadata=None)`
374
478
 
375
479
  Sets trace-level fields on the trace of the current span. You can call it
376
- anywhere inside the trace. Tags accumulate across calls.
480
+ anywhere inside the trace. Tags accumulate across calls. Metadata keys
481
+ accumulate too, and a later value replaces a key's earlier one:
482
+ `spanlight.update_trace(metadata={"customer_id": "acme", "feature": "search"})`.
483
+ Up to 32 keys matching `^[a-z0-9_.-]{1,64}$`, values of 1 to 256 characters
484
+ (numbers and booleans become text); an invalid entry is dropped with a debug log.
377
485
 
378
486
  ### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
379
487
 
@@ -407,6 +515,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
407
515
  queue fills up, the oldest spans are dropped and counted
408
516
  (`spanlight.get_client().exporter.dropped_count`).
409
517
 
518
+ Since 0.2.0, every batch of spans also carries a small `client` object with the
519
+ SDK name and version, a random `exporter_id` (new in each process, including a
520
+ forked child) and two running totals for that exporter: `dropped_total` (spans
521
+ and scores dropped because the queue was full) and `failed_total` (spans and
522
+ scores lost after retries or rejected by the server). The totals are cumulative,
523
+ so a batch delivered twice does not count a loss twice. It holds no span
524
+ content, and older Spanlight servers ignore it.
525
+
410
526
  To see what the SDK is doing:
411
527
 
412
528
  ```python
@@ -97,7 +97,8 @@ def answer(question: str) -> str:
97
97
 
98
98
  `wrap_openai` instruments `chat.completions.create` and `responses.create` on
99
99
  the client it is given. It works with `OpenAI`, `AsyncOpenAI` and the Azure
100
- variants. The SDK's own `.stream()` helpers are traced as well, because they
100
+ variants; an Azure client is recorded with the provider `azure_openai`, which the
101
+ server does not price at OpenAI's rates (add a price override for it). The SDK's own `.stream()` helpers are traced as well, because they
101
102
  call `create` internally.
102
103
 
103
104
  Streaming works without any changes to your code. Usage is recorded when
@@ -118,6 +119,56 @@ The span ends when the stream is exhausted or closed, and records
118
119
  time-to-first-token. If you point the OpenAI SDK at a compatible API, set the
119
120
  provider name explicitly: `wrap_openai(client, provider="openrouter")`.
120
121
 
122
+ When a stream carries no usage, the span's output token count is left unknown
123
+ (never guessed) and the `usage.unreported_reason` attribute says why:
124
+ `include_usage_not_set` when a Chat Completions stream ended without a usage
125
+ chunk, `no_final_usage` when a Responses stream ended without its terminal
126
+ event, `client_disconnected` when your code closed it early, and
127
+ `stream_error` when it failed. Such a call's cost shows as unknown.
128
+
129
+ ### Service tier and web search
130
+
131
+ - The `service_tier` OpenAI answers with (on the response, or on the last
132
+ stream chunk or terminal event) is recorded as `standard`, `flex`, `priority`
133
+ or `batch`; `default`, `auto` and `scale` are `standard`. A value the SDK does
134
+ not know is kept in the `usage.service_tier_raw` attribute and the call is
135
+ priced at the standard rate.
136
+ - For the Responses API, every completed `web_search_call` output item counts
137
+ as one billed web search; searches still in progress or failed are not
138
+ counted.
139
+ - Chat Completions with `web_search_options` report sources as message
140
+ annotations, which are not a count of searches. No count is recorded and the
141
+ span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
142
+ shows as unknown rather than zero.
143
+ - Requests that set `inference_geo` (other than `global`) or `speed: "fast"`
144
+ are priced at the standard rate and tagged with
145
+ `usage.pricing_modifier_unknown`, because those modifiers are not modelled.
146
+
147
+ ### Batch output
148
+
149
+ OpenAI Batch results are a file you download, so record them explicitly once
150
+ the batch has completed:
151
+
152
+ ```python
153
+ batch = client.batches.retrieve("batch_67ccd2bed1ec8190b14f964abc054267")
154
+ content = client.files.content(batch.output_file_id)
155
+ recorded = spanlight.record_openai_batch_output(content.iter_lines(), batch_id=batch.id)
156
+ ```
157
+
158
+ Pass the error file (`batch.error_file_id`) the same way to record failed
159
+ requests. Each line becomes one `llm` span with the shape described under
160
+ [Anthropic batch results](#batch-results): `service_tier` is `batch` (the tier
161
+ the body reported is kept in `usage.service_tier_raw`), cached tokens come from
162
+ the usage details, and a line whose `status_code` is not 200 or that carries an
163
+ `error` becomes an error span without usage. A Responses body with
164
+ `status: "failed"` becomes an error span that keeps the usage OpenAI reports
165
+ (it is billed), and `status: "incomplete"` a normal span whose finish reason is
166
+ `incomplete_details.reason`. Chat Completions and Responses
167
+ bodies are both read. The function accepts any iterable of `str` or `bytes`
168
+ lines (or the whole file as one string), returns the number of spans recorded,
169
+ and never raises: unreadable lines are skipped. Use `provider=` for a
170
+ compatible API and `spanlight=` for a client other than the global one.
171
+
121
172
  ## Anthropic
122
173
 
123
174
  ```python
@@ -146,6 +197,33 @@ with client.messages.stream(
146
197
  `wrap_anthropic` instruments `messages.create` (including `stream=True`) and
147
198
  `messages.stream` on both `Anthropic` and `AsyncAnthropic`.
148
199
 
200
+ ### Batch results
201
+
202
+ Results read through `messages.batches.results` are recorded as you iterate
203
+ them, on both `Anthropic` and `AsyncAnthropic`:
204
+
205
+ ```python
206
+ for item in client.messages.batches.results("msgbatch_01HkcTjaV5uDC8jWR4ZsDV8d"):
207
+ handle(item) # each item is yielded unchanged
208
+ ```
209
+
210
+ Each result becomes one `llm` span named `batch <model>`, in a trace named
211
+ `batch <batch_id>` and tagged `spanlight:batch`:
212
+
213
+ - A `succeeded` result carries the model, token usage, finish reason and the
214
+ response content, with `service_tier` set to `batch` (the Batches API bills
215
+ every request at the batch rate). The tier the response reported is kept in
216
+ the `usage.service_tier_raw` attribute.
217
+ - `errored`, `canceled` and `expired` results are error spans without usage,
218
+ because they are not billed.
219
+ - The request is not part of a result, so the span has no input
220
+ (`batch.request_available` is `false`). Its start and end are both the
221
+ moment the result was read (`timing.synthetic` is `true`).
222
+ - The attributes `batch.id`, `batch.custom_id` and `batch.result_type` link
223
+ the span back to the batch.
224
+ - Trace and span ids are derived from the batch id and `custom_id`, so reading
225
+ the same batch again updates the same spans instead of adding new ones.
226
+
149
227
  ## Prompts
150
228
 
151
229
  Keep prompts in Spanlight and fetch them by label, so a new version ships
@@ -263,6 +341,16 @@ host must be `https://` unless it is localhost. Each
263
341
  run makes real provider calls. In a GitHub workflow use the
264
342
  `.github/actions/eval-gate` Action instead of calling the module yourself.
265
343
 
344
+ The run prints a Markdown report (also appended to the GitHub step summary): the
345
+ run's items, errors, cost and latency, then one row per score with its mean, the
346
+ 95% confidence interval on the pass rate of a boolean score (for example
347
+ `64–88%`; `—` for numeric and categorical scores, and from servers that predate
348
+ intervals), the number of items, the threshold and the result. When a threshold
349
+ lies inside a boolean score's interval, the report adds "Interval includes the
350
+ threshold; the result is not conclusive at this sample size." The gate itself
351
+ still compares the mean with the threshold, so the exit codes do not depend on
352
+ the interval.
353
+
266
354
  ## What gets recorded
267
355
 
268
356
  For every LLM call:
@@ -281,6 +369,22 @@ For every LLM call:
281
369
  | `output` | The assistant message, including tool calls |
282
370
  | `status` / `status_message` | `error` plus the provider's message when the call fails |
283
371
 
372
+ When a provider call fails with an HTTP error, the span also carries the
373
+ provider's error type and code (`provider.error.type`, `provider.error.code`),
374
+ the wait it asked for in seconds (`provider.retry_after_s`, from `retry-after-ms`
375
+ or a numeric `retry-after`), and `provider.ratelimit`: the response's rate-limit
376
+ headers, filtered through a fixed allowlist of 29 names. Only `retry-after`,
377
+ `retry-after-ms`, OpenAI's `x-ratelimit-*` headers and Anthropic's
378
+ `anthropic-ratelimit-*` and `anthropic-priority-*` headers are kept, each value
379
+ at most 64 printable ASCII characters. Every other response header, including
380
+ `authorization`, `x-api-key`, `cookie`, `set-cookie` and Anthropic's
381
+ organization and workspace ids, is dropped before the span leaves your process.
382
+ Connection errors and timeouts add none of these fields.
383
+ When the Spanlight gateway refused the call itself (its `X-Spanlight-Code`
384
+ header names one of its own codes, such as `RATE_LIMITED` for your gateway key's
385
+ limit), the span carries that code as `spanlight.gateway.error_code` instead, and
386
+ none of the `provider.*` fields above.
387
+
284
388
  The Spanlight server also redacts common secret patterns from
285
389
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
286
390
  inputs and outputs entirely. To keep payloads out of the SDK side for a
@@ -310,7 +414,7 @@ options. Use `tracer.observe`, `tracer.span` and
310
414
  | `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
311
415
  | `batch_size` | | `100` | Spans per request (max 1000) |
312
416
  | `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
313
- | `gzip` | | `False` | Gzip-compress request bodies |
417
+ | `gzip` | | `True` | Gzip-compress request bodies |
314
418
  | `timeout` | | `10.0` | HTTP timeout per request, in seconds |
315
419
  | `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
316
420
  | `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
@@ -343,10 +447,14 @@ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
343
447
  exception that escapes the block marks the span as failed and is re-raised
344
448
  unchanged.
345
449
 
346
- ### `update_trace(name=None, user_id=None, session_id=None, tags=None)`
450
+ ### `update_trace(name=None, user_id=None, session_id=None, tags=None, metadata=None)`
347
451
 
348
452
  Sets trace-level fields on the trace of the current span. You can call it
349
- anywhere inside the trace. Tags accumulate across calls.
453
+ anywhere inside the trace. Tags accumulate across calls. Metadata keys
454
+ accumulate too, and a later value replaces a key's earlier one:
455
+ `spanlight.update_trace(metadata={"customer_id": "acme", "feature": "search"})`.
456
+ Up to 32 keys matching `^[a-z0-9_.-]{1,64}$`, values of 1 to 256 characters
457
+ (numbers and booleans become text); an invalid entry is dropped with a debug log.
350
458
 
351
459
  ### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
352
460
 
@@ -380,6 +488,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
380
488
  queue fills up, the oldest spans are dropped and counted
381
489
  (`spanlight.get_client().exporter.dropped_count`).
382
490
 
491
+ Since 0.2.0, every batch of spans also carries a small `client` object with the
492
+ SDK name and version, a random `exporter_id` (new in each process, including a
493
+ forked child) and two running totals for that exporter: `dropped_total` (spans
494
+ and scores dropped because the queue was full) and `failed_total` (spans and
495
+ scores lost after retries or rejected by the server). The totals are cumulative,
496
+ so a batch delivered twice does not count a loss twice. It holds no span
497
+ content, and older Spanlight servers ignore it.
498
+
383
499
  To see what the SDK is doing:
384
500
 
385
501
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.1.2"
3
+ version = "0.2.1"
4
4
  description = "Python SDK for Spanlight, open-source observability for LLM applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -25,6 +25,7 @@ from ._prompts import CompiledPrompt, Prompt, PromptUnavailableError, no_prompt
25
25
  from ._span import Span, SpanKind, SpanStatus
26
26
  from ._templates import InvalidTemplateError, MissingVariablesError, TemplateTooLargeError
27
27
  from ._version import __version__
28
+ from .integrations._openai_batch import record_openai_batch_output
28
29
  from .integrations.anthropic import wrap_anthropic
29
30
  from .integrations.openai import wrap_openai
30
31
 
@@ -50,6 +51,7 @@ __all__ = [
50
51
  "no_prompt",
51
52
  "observe",
52
53
  "prompts",
54
+ "record_openai_batch_output",
53
55
  "score",
54
56
  "shutdown",
55
57
  "span",
@@ -21,7 +21,8 @@ from ._context import (
21
21
  from ._exporter import BatchExporter
22
22
  from ._prompt_client import PromptClient
23
23
  from ._scores import ScoreValue, build_score
24
- from ._span import Span, SpanKind, TraceState, normalize_kind
24
+ from ._span import Span, SpanKind, normalize_kind
25
+ from ._trace_state import TraceState
25
26
 
26
27
  logger = logging.getLogger("spanlight")
27
28
 
@@ -60,7 +61,8 @@ class Spanlight:
60
61
  flush_interval: Maximum seconds a span waits before it is sent.
61
62
  batch_size: Maximum spans per request (capped at 1000).
62
63
  max_queue: Maximum spans buffered in memory; oldest dropped first.
63
- gzip: Compress request bodies.
64
+ gzip: Gzip-compress request bodies (default). Turn it off only for a proxy that
65
+ cannot forward a compressed body.
64
66
  timeout: HTTP timeout per request, in seconds.
65
67
  max_retries: Retries per batch for network errors, 429 and 5xx.
66
68
  shutdown_timeout: Seconds allowed for the final flush at exit.
@@ -79,7 +81,7 @@ class Spanlight:
79
81
  flush_interval: float = 2.0,
80
82
  batch_size: int = 100,
81
83
  max_queue: int = 10_000,
82
- gzip: bool = False,
84
+ gzip: bool = True,
83
85
  timeout: float = 10.0,
84
86
  max_retries: int = 5,
85
87
  shutdown_timeout: float = 5.0,
@@ -275,9 +277,12 @@ class Spanlight:
275
277
  user_id: str | None = None,
276
278
  session_id: str | None = None,
277
279
  tags: Iterable[str] | None = None,
280
+ metadata: Mapping[str, object] | None = None,
278
281
  ) -> None:
279
282
  """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)
283
+ update_trace(
284
+ name=name, user_id=user_id, session_id=session_id, tags=tags, metadata=metadata
285
+ )
281
286
 
282
287
  # ------------------------------------------------------------------ #
283
288
  # Scores
@@ -426,6 +431,7 @@ def update_trace(
426
431
  user_id: str | None = None,
427
432
  session_id: str | None = None,
428
433
  tags: Iterable[str] | None = None,
434
+ metadata: Mapping[str, object] | None = None,
429
435
  ) -> None:
430
436
  """Attach trace-level fields to the trace of the current span.
431
437
 
@@ -437,13 +443,17 @@ def update_trace(
437
443
  user_id: Your application's user identifier.
438
444
  session_id: Groups traces into a conversation/session.
439
445
  tags: Free-form labels for filtering.
446
+ metadata: Key-value pairs such as ``{"customer_id": "acme"}``; a later value
447
+ replaces a key's earlier one. Invalid entries are dropped with a debug log.
440
448
  """
441
449
  span = get_current_span()
442
450
  if span is None:
443
451
  logger.debug("update_trace() called outside of a span; ignoring")
444
452
  return
445
453
  try:
446
- span.trace.update(name=name, user_id=user_id, session_id=session_id, tags=tags)
454
+ span.trace.update(
455
+ name=name, user_id=user_id, session_id=session_id, tags=tags, metadata=metadata
456
+ )
447
457
  except Exception:
448
458
  logger.debug("update_trace() failed", exc_info=True)
449
459
 
@@ -16,6 +16,7 @@ SDK itself are swallowed so they cannot break the decorated function.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import asyncio
19
20
  import functools
20
21
  import inspect
21
22
  import logging
@@ -196,7 +197,10 @@ def _traced_generator(
196
197
  item = step()
197
198
  except StopIteration as stop:
198
199
  _finish(
199
- span, output=collected.result(stop.value), capture_output=options.capture_output
200
+ span,
201
+ output=collected.result(stop.value),
202
+ capture_output=options.capture_output,
203
+ output_cut=collected.truncated,
200
204
  )
201
205
  return stop.value
202
206
  except BaseException as exc:
@@ -212,7 +216,12 @@ def _traced_generator(
212
216
  try:
213
217
  generator.close()
214
218
  finally:
215
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
219
+ _finish(
220
+ span,
221
+ output=collected.result(None),
222
+ capture_output=options.capture_output,
223
+ output_cut=collected.truncated,
224
+ )
216
225
  raise
217
226
  except BaseException as exc:
218
227
  step = functools.partial(generator.throw, exc)
@@ -244,16 +253,33 @@ async def _traced_async_generator(
244
253
  """Async counterpart of :func:`_traced_generator`."""
245
254
  collected = _OutputCollector(enabled=options.capture_output)
246
255
  step: Callable[[], Any] = functools.partial(generator.asend, None)
256
+ # A cancellation thrown in at a `yield` means the consumer abandoned the generator: asyncio
257
+ # closes one left behind by `async for ... break` that way at loop shutdown. Like a `break`
258
+ # over a sync generator, that ends the span as ok, not failed.
259
+ abandoned: BaseException | None = None
247
260
  while True:
248
261
  previous = get_current_span()
249
262
  token = set_current_span(span)
250
263
  try:
251
264
  item = await step()
252
265
  except StopAsyncIteration:
253
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
266
+ _finish(
267
+ span,
268
+ output=collected.result(None),
269
+ capture_output=options.capture_output,
270
+ output_cut=collected.truncated,
271
+ )
254
272
  return
255
273
  except BaseException as exc:
256
- _finish(span, error=exc)
274
+ if exc is abandoned:
275
+ _finish(
276
+ span,
277
+ output=collected.result(None),
278
+ capture_output=options.capture_output,
279
+ output_cut=collected.truncated,
280
+ )
281
+ else:
282
+ _finish(span, error=exc)
257
283
  raise
258
284
  finally:
259
285
  restore_current_span(token, previous)
@@ -265,9 +291,15 @@ async def _traced_async_generator(
265
291
  try:
266
292
  await generator.aclose()
267
293
  finally:
268
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
294
+ _finish(
295
+ span,
296
+ output=collected.result(None),
297
+ capture_output=options.capture_output,
298
+ output_cut=collected.truncated,
299
+ )
269
300
  raise
270
301
  except BaseException as exc:
302
+ abandoned = exc if isinstance(exc, asyncio.CancelledError) else None
271
303
  step = functools.partial(generator.athrow, exc)
272
304
  else:
273
305
  step = functools.partial(generator.asend, sent)
@@ -305,6 +337,7 @@ def _finish(
305
337
  *,
306
338
  output: Any = None,
307
339
  capture_output: bool = False,
340
+ output_cut: bool = False,
308
341
  error: BaseException | None = None,
309
342
  ) -> None:
310
343
  """End ``span`` recording either its output or the error. Never raises."""
@@ -314,6 +347,8 @@ def _finish(
314
347
  span.record_error(error)
315
348
  elif capture_output:
316
349
  span.set_output(output)
350
+ # The collector kept only the first MAX_ITEMS yielded items.
351
+ span._output_cut = span._output_cut or output_cut
317
352
  span.end()
318
353
  except Exception:
319
354
  logger.debug("observe: failed to finish span", exc_info=True)
@@ -2,11 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
6
7
  from typing import Any
7
8
 
8
9
  _PLACES = Decimal("0.000001")
9
10
  _UNKNOWN = "—"
11
+ _INCONCLUSIVE = "Interval includes the threshold; the result is not conclusive at this sample size."
10
12
 
11
13
 
12
14
  def fmt(value: Decimal) -> str:
@@ -22,6 +24,30 @@ def _mean(stat: Any) -> Decimal | None:
22
24
  return Decimal(str(mean))
23
25
 
24
26
 
27
+ def _interval(stat: Any) -> tuple[Decimal, Decimal] | None:
28
+ """The 95 % interval on a boolean score's pass rate.
29
+
30
+ ``None`` for a numeric or categorical score, or from a server that predates intervals.
31
+ """
32
+ if not isinstance(stat, dict):
33
+ return None
34
+ try:
35
+ low, high = Decimal(str(stat["ci_low"])), Decimal(str(stat["ci_high"]))
36
+ except (KeyError, InvalidOperation): # a missing key, or null
37
+ return None
38
+ if not (low.is_finite() and high.is_finite()): # "NaN" or "Infinity" would raise in floor
39
+ return None
40
+ return low, high
41
+
42
+
43
+ def _percents(interval: tuple[Decimal, Decimal] | None) -> str:
44
+ """``64\u201388%``: the low bound floored, the high bound ceiled, so the range contains it."""
45
+ if interval is None:
46
+ return _UNKNOWN
47
+ low, high = interval
48
+ return f"{math.floor(low * 100)}\u2013{math.ceil(high * 100)}%"
49
+
50
+
25
51
  def check_thresholds(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
26
52
  """One line per score whose mean is below its threshold or that the run did not produce.
27
53
 
@@ -41,7 +67,10 @@ def check_thresholds(totals: dict[str, Any], fail_below: dict[str, Decimal]) ->
41
67
 
42
68
  def _score_rows(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
43
69
  scores = totals.get("scores") or {}
44
- rows = ["| Score | Mean | Items | Threshold | Result |", "| --- | --- | --- | --- | --- |"]
70
+ rows = [
71
+ "| Score | Mean | 95% CI | Items | Threshold | Result |",
72
+ "| --- | --- | --- | --- | --- | --- |",
73
+ ]
45
74
  for name in sorted({*scores, *fail_below}):
46
75
  stat = scores.get(name)
47
76
  mean = _mean(stat)
@@ -55,12 +84,27 @@ def _score_rows(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[
55
84
  result = "FAIL"
56
85
  rows.append(
57
86
  f"| {name} | {fmt(mean) if mean is not None else _UNKNOWN} "
87
+ f"| {_percents(_interval(stat))} "
58
88
  f"| {count if count is not None else _UNKNOWN} "
59
89
  f"| {'>= ' + fmt(threshold) if threshold is not None else _UNKNOWN} | {result} |"
60
90
  )
61
91
  return rows
62
92
 
63
93
 
94
+ def _inconclusive(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
95
+ """One line per gated boolean score whose threshold lies inside its interval.
96
+
97
+ The gate still compares the mean; this only says the verdict could flip with more items.
98
+ """
99
+ scores = totals.get("scores") or {}
100
+ lines: list[str] = []
101
+ for name, threshold in sorted(fail_below.items()):
102
+ interval = _interval(scores.get(name))
103
+ if interval is not None and interval[0] <= threshold <= interval[1]:
104
+ lines.append(f"{name}: {_INCONCLUSIVE}")
105
+ return lines
106
+
107
+
64
108
  def _number(value: Any) -> str:
65
109
  return _UNKNOWN if value is None else str(value)
66
110
 
@@ -107,6 +151,8 @@ def render_markdown(
107
151
  *_score_rows(totals, fail_below),
108
152
  "",
109
153
  ]
154
+ if inconclusive := _inconclusive(totals, fail_below):
155
+ lines += [*inconclusive, ""]
110
156
  if failures:
111
157
  lines += ["Below threshold:", "", *[f"- {line}" for line in failures], ""]
112
158
  return "\n".join(lines)