spanlight 0.1.1__tar.gz → 0.2.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.
Files changed (77) hide show
  1. {spanlight-0.1.1 → spanlight-0.2.0}/PKG-INFO +136 -17
  2. {spanlight-0.1.1 → spanlight-0.2.0}/README.md +135 -16
  3. {spanlight-0.1.1 → spanlight-0.2.0}/pyproject.toml +1 -1
  4. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/__init__.py +2 -0
  5. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_client.py +15 -5
  6. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_decorator.py +25 -4
  7. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments_report.py +47 -1
  8. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_exporter.py +27 -2
  9. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_exporter_delivery.py +27 -10
  10. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_globals.py +1 -1
  11. spanlight-0.2.0/src/spanlight/_metadata.py +110 -0
  12. spanlight-0.2.0/src/spanlight/_ratelimit_headers.py +101 -0
  13. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_serialize.py +26 -16
  14. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_span.py +18 -100
  15. spanlight-0.2.0/src/spanlight/_tiers.py +94 -0
  16. spanlight-0.2.0/src/spanlight/_trace_state.py +83 -0
  17. spanlight-0.2.0/src/spanlight/_usage.py +123 -0
  18. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_version.py +1 -1
  19. spanlight-0.2.0/src/spanlight/integrations/_anthropic_batch.py +135 -0
  20. spanlight-0.2.0/src/spanlight/integrations/_anthropic_message.py +66 -0
  21. spanlight-0.1.1/src/spanlight/integrations/anthropic.py → spanlight-0.2.0/src/spanlight/integrations/_anthropic_stream.py +53 -149
  22. spanlight-0.2.0/src/spanlight/integrations/_batch.py +167 -0
  23. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/integrations/_common.py +35 -210
  24. spanlight-0.2.0/src/spanlight/integrations/_modifiers.py +64 -0
  25. spanlight-0.2.0/src/spanlight/integrations/_openai_batch.py +181 -0
  26. spanlight-0.2.0/src/spanlight/integrations/_openai_response.py +108 -0
  27. spanlight-0.2.0/src/spanlight/integrations/_openai_stream.py +190 -0
  28. spanlight-0.2.0/src/spanlight/integrations/_provider_errors.py +202 -0
  29. spanlight-0.2.0/src/spanlight/integrations/_streams.py +188 -0
  30. spanlight-0.2.0/src/spanlight/integrations/_wrappers.py +91 -0
  31. spanlight-0.2.0/src/spanlight/integrations/anthropic.py +122 -0
  32. spanlight-0.2.0/src/spanlight/integrations/openai.py +189 -0
  33. {spanlight-0.1.1 → spanlight-0.2.0}/tests/provider_mocks.py +167 -11
  34. spanlight-0.2.0/tests/test_anthropic.py +472 -0
  35. spanlight-0.2.0/tests/test_anthropic_batch_results.py +235 -0
  36. spanlight-0.2.0/tests/test_anthropic_cut_stream.py +214 -0
  37. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_exporter.py +21 -7
  38. spanlight-0.2.0/tests/test_exporter_counters.py +163 -0
  39. spanlight-0.2.0/tests/test_gateway_refusal.py +193 -0
  40. spanlight-0.2.0/tests/test_metadata.py +86 -0
  41. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_openai.py +70 -5
  42. spanlight-0.2.0/tests/test_openai_batch_output.py +250 -0
  43. spanlight-0.2.0/tests/test_openai_tier_and_search.py +257 -0
  44. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_package.py +5 -0
  45. spanlight-0.2.0/tests/test_provider_errors.py +350 -0
  46. spanlight-0.2.0/tests/test_ratelimit_allowlist.py +103 -0
  47. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_spans_and_context.py +136 -0
  48. spanlight-0.2.0/tests/test_tiers.py +62 -0
  49. {spanlight-0.1.1 → spanlight-0.2.0}/uv.lock +1 -1
  50. spanlight-0.1.1/src/spanlight/integrations/openai.py +0 -356
  51. spanlight-0.1.1/tests/test_anthropic.py +0 -253
  52. {spanlight-0.1.1 → spanlight-0.2.0}/.gitignore +0 -0
  53. {spanlight-0.1.1 → spanlight-0.2.0}/LICENSE +0 -0
  54. {spanlight-0.1.1 → spanlight-0.2.0}/examples/support_bot.py +0 -0
  55. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_config.py +0 -0
  56. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_context.py +0 -0
  57. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments.py +0 -0
  58. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments_api.py +0 -0
  59. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments_parse.py +0 -0
  60. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments_runs.py +0 -0
  61. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_experiments_setup.py +0 -0
  62. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_ids.py +0 -0
  63. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_prompt_client.py +0 -0
  64. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_prompts.py +0 -0
  65. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_request_hash.py +0 -0
  66. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_scores.py +0 -0
  67. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/_templates.py +0 -0
  68. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/integrations/__init__.py +0 -0
  69. {spanlight-0.1.1 → spanlight-0.2.0}/src/spanlight/py.typed +0 -0
  70. {spanlight-0.1.1 → spanlight-0.2.0}/tests/conftest.py +0 -0
  71. {spanlight-0.1.1 → spanlight-0.2.0}/tests/fixtures/template_cases.json +0 -0
  72. {spanlight-0.1.1 → spanlight-0.2.0}/tests/support.py +0 -0
  73. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_decorator.py +0 -0
  74. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_docs_snippets.py +0 -0
  75. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_experiments_report.py +0 -0
  76. {spanlight-0.1.1 → spanlight-0.2.0}/tests/test_exporter_retry_after.py +0 -0
  77. {spanlight-0.1.1 → spanlight-0.2.0}/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.1
3
+ Version: 0.2.0
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,21 +368,49 @@ 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:
296
384
 
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 |
385
+ | Field | Source |
386
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
387
+ | `provider` | `openai` / `anthropic` |
388
+ | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
389
+ | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
390
+ | `usage.output_tokens` | Completion tokens |
391
+ | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
392
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
393
+ | `usage.cache_write_1h_tokens` | The part of `cache_write_tokens` written with Anthropic's 1-hour TTL (`cache_creation.ephemeral_1h_input_tokens`); the rest are 5-minute writes. Sent only when reported. |
394
+ | `time_to_first_token_ms` | Streaming calls only |
395
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
396
+ | `output` | The assistant message, including tool calls |
397
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
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.
308
414
 
309
415
  The Spanlight server also redacts common secret patterns from
310
416
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -335,7 +441,7 @@ options. Use `tracer.observe`, `tracer.span` and
335
441
  | `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
336
442
  | `batch_size` | | `100` | Spans per request (max 1000) |
337
443
  | `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
338
- | `gzip` | | `False` | Gzip-compress request bodies |
444
+ | `gzip` | | `True` | Gzip-compress request bodies |
339
445
  | `timeout` | | `10.0` | HTTP timeout per request, in seconds |
340
446
  | `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
341
447
  | `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
@@ -362,15 +468,20 @@ with spanlight.span("rerank", kind="retrieval") as span:
362
468
 
363
469
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
364
470
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
365
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
471
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
472
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
366
473
  `mark_first_token()` and `record_error(exc_or_message)`. An
367
474
  exception that escapes the block marks the span as failed and is re-raised
368
475
  unchanged.
369
476
 
370
- ### `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)`
371
478
 
372
479
  Sets trace-level fields on the trace of the current span. You can call it
373
- 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.
374
485
 
375
486
  ### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
376
487
 
@@ -404,6 +515,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
404
515
  queue fills up, the oldest spans are dropped and counted
405
516
  (`spanlight.get_client().exporter.dropped_count`).
406
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
+
407
526
  To see what the SDK is doing:
408
527
 
409
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,21 +341,49 @@ 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:
269
357
 
270
- | Field | Source |
271
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
272
- | `provider` | `openai` / `anthropic` |
273
- | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
274
- | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
275
- | `usage.output_tokens` | Completion tokens |
276
- | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
277
- | `time_to_first_token_ms` | Streaming calls only |
278
- | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
279
- | `output` | The assistant message, including tool calls |
280
- | `status` / `status_message` | `error` plus the provider's message when the call fails |
358
+ | Field | Source |
359
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
360
+ | `provider` | `openai` / `anthropic` |
361
+ | `model` | Model from the response (for example `gpt-4o-mini-2024-07-18`). Falls back to the requested model. |
362
+ | `usage.input_tokens` | Total prompt tokens, **including** cached tokens. For Anthropic this is input + cache reads + cache writes. |
363
+ | `usage.output_tokens` | Completion tokens |
364
+ | `usage.cached_tokens` | Prompt tokens served from the provider's prompt cache |
365
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
366
+ | `usage.cache_write_1h_tokens` | The part of `cache_write_tokens` written with Anthropic's 1-hour TTL (`cache_creation.ephemeral_1h_input_tokens`); the rest are 5-minute writes. Sent only when reported. |
367
+ | `time_to_first_token_ms` | Streaming calls only |
368
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
369
+ | `output` | The assistant message, including tool calls |
370
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
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.
281
387
 
282
388
  The Spanlight server also redacts common secret patterns from
283
389
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -308,7 +414,7 @@ options. Use `tracer.observe`, `tracer.span` and
308
414
  | `flush_interval` | | `2.0` | Seconds a span may wait before it is sent |
309
415
  | `batch_size` | | `100` | Spans per request (max 1000) |
310
416
  | `max_queue` | | `10000` | Buffered spans. When the buffer is full, the oldest spans are dropped. |
311
- | `gzip` | | `False` | Gzip-compress request bodies |
417
+ | `gzip` | | `True` | Gzip-compress request bodies |
312
418
  | `timeout` | | `10.0` | HTTP timeout per request, in seconds |
313
419
  | `max_retries` | | `5` | Retries for network errors, 429 and 5xx |
314
420
  | `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
@@ -335,15 +441,20 @@ with spanlight.span("rerank", kind="retrieval") as span:
335
441
 
336
442
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
337
443
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
338
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
444
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
445
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
339
446
  `mark_first_token()` and `record_error(exc_or_message)`. An
340
447
  exception that escapes the block marks the span as failed and is re-raised
341
448
  unchanged.
342
449
 
343
- ### `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)`
344
451
 
345
452
  Sets trace-level fields on the trace of the current span. You can call it
346
- 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.
347
458
 
348
459
  ### `flush(timeout=None) -> bool` and `shutdown(timeout=None)`
349
460
 
@@ -377,6 +488,14 @@ logged to the `spanlight` logger, as are any spans the server rejects. If the
377
488
  queue fills up, the oldest spans are dropped and counted
378
489
  (`spanlight.get_client().exporter.dropped_count`).
379
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
+
380
499
  To see what the SDK is doing:
381
500
 
382
501
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.1.1"
3
+ version = "0.2.0"
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
 
@@ -196,7 +196,10 @@ def _traced_generator(
196
196
  item = step()
197
197
  except StopIteration as stop:
198
198
  _finish(
199
- span, output=collected.result(stop.value), capture_output=options.capture_output
199
+ span,
200
+ output=collected.result(stop.value),
201
+ capture_output=options.capture_output,
202
+ output_cut=collected.truncated,
200
203
  )
201
204
  return stop.value
202
205
  except BaseException as exc:
@@ -212,7 +215,12 @@ def _traced_generator(
212
215
  try:
213
216
  generator.close()
214
217
  finally:
215
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
218
+ _finish(
219
+ span,
220
+ output=collected.result(None),
221
+ capture_output=options.capture_output,
222
+ output_cut=collected.truncated,
223
+ )
216
224
  raise
217
225
  except BaseException as exc:
218
226
  step = functools.partial(generator.throw, exc)
@@ -250,7 +258,12 @@ async def _traced_async_generator(
250
258
  try:
251
259
  item = await step()
252
260
  except StopAsyncIteration:
253
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
261
+ _finish(
262
+ span,
263
+ output=collected.result(None),
264
+ capture_output=options.capture_output,
265
+ output_cut=collected.truncated,
266
+ )
254
267
  return
255
268
  except BaseException as exc:
256
269
  _finish(span, error=exc)
@@ -265,7 +278,12 @@ async def _traced_async_generator(
265
278
  try:
266
279
  await generator.aclose()
267
280
  finally:
268
- _finish(span, output=collected.result(None), capture_output=options.capture_output)
281
+ _finish(
282
+ span,
283
+ output=collected.result(None),
284
+ capture_output=options.capture_output,
285
+ output_cut=collected.truncated,
286
+ )
269
287
  raise
270
288
  except BaseException as exc:
271
289
  step = functools.partial(generator.athrow, exc)
@@ -305,6 +323,7 @@ def _finish(
305
323
  *,
306
324
  output: Any = None,
307
325
  capture_output: bool = False,
326
+ output_cut: bool = False,
308
327
  error: BaseException | None = None,
309
328
  ) -> None:
310
329
  """End ``span`` recording either its output or the error. Never raises."""
@@ -314,6 +333,8 @@ def _finish(
314
333
  span.record_error(error)
315
334
  elif capture_output:
316
335
  span.set_output(output)
336
+ # The collector kept only the first MAX_ITEMS yielded items.
337
+ span._output_cut = span._output_cut or output_cut
317
338
  span.end()
318
339
  except Exception:
319
340
  logger.debug("observe: failed to finish span", exc_info=True)