spanlight 0.1.0__tar.gz → 0.1.2__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 (49) hide show
  1. {spanlight-0.1.0 → spanlight-0.1.2}/PKG-INFO +20 -13
  2. {spanlight-0.1.0 → spanlight-0.1.2}/README.md +19 -12
  3. {spanlight-0.1.0 → spanlight-0.1.2}/pyproject.toml +1 -1
  4. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments.py +4 -1
  5. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_report.py +15 -3
  6. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_exporter.py +27 -214
  7. spanlight-0.1.2/src/spanlight/_exporter_delivery.py +268 -0
  8. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_span.py +18 -2
  9. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_templates.py +10 -2
  10. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_version.py +1 -1
  11. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/anthropic.py +24 -5
  12. {spanlight-0.1.0 → spanlight-0.1.2}/tests/provider_mocks.py +40 -0
  13. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_anthropic.py +57 -4
  14. spanlight-0.1.2/tests/test_docs_snippets.py +155 -0
  15. spanlight-0.1.2/tests/test_experiments_report.py +106 -0
  16. spanlight-0.1.2/tests/test_exporter_retry_after.py +33 -0
  17. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_spans_and_context.py +30 -0
  18. spanlight-0.1.2/tests/test_templates_surrogates.py +53 -0
  19. {spanlight-0.1.0 → spanlight-0.1.2}/uv.lock +1 -1
  20. {spanlight-0.1.0 → spanlight-0.1.2}/.gitignore +0 -0
  21. {spanlight-0.1.0 → spanlight-0.1.2}/LICENSE +0 -0
  22. {spanlight-0.1.0 → spanlight-0.1.2}/examples/support_bot.py +0 -0
  23. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/__init__.py +0 -0
  24. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_client.py +0 -0
  25. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_config.py +0 -0
  26. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_context.py +0 -0
  27. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_decorator.py +0 -0
  28. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_api.py +0 -0
  29. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_parse.py +0 -0
  30. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_runs.py +0 -0
  31. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_setup.py +0 -0
  32. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_globals.py +0 -0
  33. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_ids.py +0 -0
  34. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_prompt_client.py +0 -0
  35. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_prompts.py +0 -0
  36. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_request_hash.py +0 -0
  37. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_scores.py +0 -0
  38. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_serialize.py +0 -0
  39. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/__init__.py +0 -0
  40. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/_common.py +0 -0
  41. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/openai.py +0 -0
  42. {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/py.typed +0 -0
  43. {spanlight-0.1.0 → spanlight-0.1.2}/tests/conftest.py +0 -0
  44. {spanlight-0.1.0 → spanlight-0.1.2}/tests/fixtures/template_cases.json +0 -0
  45. {spanlight-0.1.0 → spanlight-0.1.2}/tests/support.py +0 -0
  46. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_decorator.py +0 -0
  47. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_exporter.py +0 -0
  48. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_openai.py +0 -0
  49. {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: spanlight
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python SDK for Spanlight, open-source observability for LLM applications.
5
5
  Author: Dheeraj
6
6
  License-Expression: MIT
@@ -294,17 +294,19 @@ run makes real provider calls. In a GitHub workflow use the
294
294
 
295
295
  For every LLM call:
296
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 |
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
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
305
+ | `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. |
306
+ | `time_to_first_token_ms` | Streaming calls only |
307
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
308
+ | `output` | The assistant message, including tool calls |
309
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
308
310
 
309
311
  The Spanlight server also redacts common secret patterns from
310
312
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -313,6 +315,10 @@ specific function, use `@observe(capture_input=False, capture_output=False)`.
313
315
 
314
316
  ## API reference
315
317
 
318
+ The full API, with every signature, default and error, is in the
319
+ [Python SDK reference](https://github.com/dheerajkarwasra/spanlight/blob/main/docs-site/src/content/docs/reference/python-sdk.mdx).
320
+ The sections below are a summary.
321
+
316
322
  ### `init(**options) -> Spanlight`
317
323
 
318
324
  Configures the global client used by `observe`, `span` and the wrappers.
@@ -358,7 +364,8 @@ with spanlight.span("rerank", kind="retrieval") as span:
358
364
 
359
365
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
360
366
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
361
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
367
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
368
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
362
369
  `mark_first_token()` and `record_error(exc_or_message)`. An
363
370
  exception that escapes the block marks the span as failed and is re-raised
364
371
  unchanged.
@@ -267,17 +267,19 @@ run makes real provider calls. In a GitHub workflow use the
267
267
 
268
268
  For every LLM call:
269
269
 
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 |
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
+ | `usage.cache_write_tokens` | Prompt tokens written to the prompt cache (Anthropic `cache_creation_input_tokens`). Sent only when the provider reports it. |
278
+ | `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. |
279
+ | `time_to_first_token_ms` | Streaming calls only |
280
+ | `input` | Request parameters such as `messages`, `system` and `temperature`. API keys, headers, `extra_*` and `timeout` are never recorded. |
281
+ | `output` | The assistant message, including tool calls |
282
+ | `status` / `status_message` | `error` plus the provider's message when the call fails |
281
283
 
282
284
  The Spanlight server also redacts common secret patterns from
283
285
  payloads on ingestion. Set `capture_payloads = false` on a project to drop
@@ -286,6 +288,10 @@ specific function, use `@observe(capture_input=False, capture_output=False)`.
286
288
 
287
289
  ## API reference
288
290
 
291
+ The full API, with every signature, default and error, is in the
292
+ [Python SDK reference](https://github.com/dheerajkarwasra/spanlight/blob/main/docs-site/src/content/docs/reference/python-sdk.mdx).
293
+ The sections below are a summary.
294
+
289
295
  ### `init(**options) -> Spanlight`
290
296
 
291
297
  Configures the global client used by `observe`, `span` and the wrappers.
@@ -331,7 +337,8 @@ with spanlight.span("rerank", kind="retrieval") as span:
331
337
 
332
338
  `Span` methods: `set_input`, `set_output`, `set_attribute`, `set_attributes`,
333
339
  `set_model(model, provider=...)`, `set_usage(input_tokens=, output_tokens=,
334
- cached_tokens=)`, `set_finish_reason(reason)`, `set_request_hash(hash)`,
340
+ cached_tokens=, cache_write_tokens=, cache_write_1h_tokens=)`,
341
+ `set_finish_reason(reason)`, `set_request_hash(hash)`,
335
342
  `mark_first_token()` and `record_error(exc_or_message)`. An
336
343
  exception that escapes the block marks the span as failed and is re-raised
337
344
  unchanged.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Python SDK for Spanlight, open-source observability for LLM applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -120,9 +120,12 @@ def run_experiment(
120
120
  experiment, version = _prepare(
121
121
  api, dataset, prompt, evaluators, credential_id, model, max_cost_usd
122
122
  )
123
- url = f"{host.rstrip('/')}/{org_of(api)}/{project_id}/experiments/{experiment['id']}"
123
+ experiment_url = (
124
+ f"{host.rstrip('/')}/{org_of(api)}/{project_id}/experiments/{experiment['id']}"
125
+ )
124
126
  wait_idle(api, experiment["id"], deadline, poll_s, sleep, clock)
125
127
  run = start_run(api, experiment["id"], version.version_id, env)
128
+ url = f"{experiment_url}/runs/{run['id']}"
126
129
  try:
127
130
  final = wait(api, experiment["id"], run["id"], deadline, poll_s, sleep, clock)
128
131
  except ExperimentError as error:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from decimal import Decimal
5
+ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
6
6
  from typing import Any
7
7
 
8
8
  _PLACES = Decimal("0.000001")
@@ -65,6 +65,18 @@ def _number(value: Any) -> str:
65
65
  return _UNKNOWN if value is None else str(value)
66
66
 
67
67
 
68
+ def _milliseconds(value: Any) -> str:
69
+ """A latency in whole milliseconds; the server sends a raw float such as ``1234.5678``."""
70
+ if value is None:
71
+ return _UNKNOWN
72
+ if isinstance(value, bool) or not isinstance(value, int | float):
73
+ return str(value)
74
+ try:
75
+ return str(int(Decimal(str(value)).quantize(Decimal(1), rounding=ROUND_HALF_UP)))
76
+ except (InvalidOperation, ValueError, OverflowError):
77
+ return _UNKNOWN
78
+
79
+
68
80
  def render_markdown(
69
81
  *,
70
82
  title: str,
@@ -89,8 +101,8 @@ def render_markdown(
89
101
  lines += [
90
102
  f"Items {_number(totals.get('items'))}, errors {_number(totals.get('errors'))}, "
91
103
  f"cost {_number(totals.get('cost_usd'))} USD, "
92
- f"p50 {_number(totals.get('latency_p50_ms'))} ms, "
93
- f"p95 {_number(totals.get('latency_p95_ms'))} ms.",
104
+ f"p50 {_milliseconds(totals.get('latency_p50_ms'))} ms, "
105
+ f"p95 {_milliseconds(totals.get('latency_p95_ms'))} ms.",
94
106
  "",
95
107
  *_score_rows(totals, fail_below),
96
108
  "",
@@ -11,6 +11,7 @@ Design goals, in priority order:
11
11
  3. **Deliver what we can.** Batches are retried with jittered exponential
12
12
  backoff on network errors, ``429`` and ``5xx``, honouring ``Retry-After``.
13
13
  Other ``4xx`` responses are permanent failures and are logged, not retried.
14
+ The sending itself lives in :mod:`spanlight._exporter_delivery`.
14
15
 
15
16
  Spans and scores share one queue (and so one ``max_queue`` bound). Each cycle
16
17
  takes up to ``batch_size`` items, sends the spans first and the scores after,
@@ -19,9 +20,6 @@ so a score recorded after its span never reaches the server before it.
19
20
 
20
21
  from __future__ import annotations
21
22
 
22
- import email.utils
23
- import gzip
24
- import json
25
23
  import logging
26
24
  import os
27
25
  import random
@@ -29,32 +27,15 @@ import threading
29
27
  import time
30
28
  from collections import deque
31
29
  from collections.abc import Callable
32
- from dataclasses import dataclass
33
- from typing import Any
34
30
 
35
31
  import httpx
36
32
 
37
- from ._serialize import JsonValue
38
- from ._version import __version__
39
-
40
- logger = logging.getLogger("spanlight")
41
-
42
- SpanPayload = dict[str, JsonValue]
43
-
44
- _MAX_LOGGED_REJECTIONS = 10
33
+ from ._exporter_delivery import SCORES, SPANS, Delivery, SpanPayload, parse_retry_after
45
34
 
35
+ __all__ = ["BatchExporter", "SpanPayload", "parse_retry_after"]
46
36
 
47
- @dataclass(frozen=True)
48
- class _Target:
49
- """One endpoint the exporter delivers to."""
50
-
51
- url: str
52
- body_key: str # the list's key in the request body, and the plural noun in logs
53
- id_field: str # the payload field logged when the server rejects an item
54
-
37
+ logger = logging.getLogger("spanlight")
55
38
 
56
- _SPANS = "spans"
57
- _SCORES = "scores"
58
39
  _Item = tuple[str, SpanPayload]
59
40
 
60
41
 
@@ -100,42 +81,32 @@ class BatchExporter:
100
81
  base_backoff: float = 0.5,
101
82
  max_backoff: float = 30.0,
102
83
  ) -> None:
103
- self._targets: dict[str, _Target] = {_SPANS: _Target(url, _SPANS, "span_id")}
104
- if scores_url is not None:
105
- self._targets[_SCORES] = _Target(scores_url, _SCORES, "id")
106
- self._scores_unsupported_logged = False
107
- self._headers = {
108
- "Authorization": f"Bearer {api_key}",
109
- "Content-Type": "application/json",
110
- "User-Agent": f"spanlight-python/{__version__}",
111
- }
112
- if gzip_body:
113
- self._headers["Content-Encoding"] = "gzip"
84
+ self._delivery = Delivery(
85
+ url=url,
86
+ api_key=api_key,
87
+ scores_url=scores_url,
88
+ gzip_body=gzip_body,
89
+ timeout=timeout,
90
+ max_retries=max_retries,
91
+ transport=transport,
92
+ sleep=sleep,
93
+ jitter=jitter,
94
+ base_backoff=base_backoff,
95
+ max_backoff=max_backoff,
96
+ )
114
97
  self._batch_size = batch_size
115
98
  self._flush_interval = flush_interval
116
99
  self._max_queue = max_queue
117
- self._gzip = gzip_body
118
- self._timeout = timeout
119
- self._max_retries = max_retries
120
- self._transport = transport
121
- self._sleep = sleep if sleep is not None else self._interruptible_sleep
122
- self._jitter = jitter
123
- self._base_backoff = base_backoff
124
- self._max_backoff = max_backoff
125
100
 
126
101
  self._condition = threading.Condition()
127
102
  self._queue: deque[_Item] = deque()
128
103
  self._in_flight = 0
129
104
  self._flush_requested = False
130
105
  self._closed = False
131
- self._abort_retries = threading.Event()
132
106
  self._worker: threading.Thread | None = None
133
- self._client: httpx.Client | None = None
134
107
  self._pid = os.getpid()
135
108
 
136
109
  self._dropped = 0
137
- self._failed = 0
138
- self._sent = 0
139
110
 
140
111
  # ------------------------------------------------------------------ #
141
112
  # Public API (called from user threads)
@@ -149,23 +120,23 @@ class BatchExporter:
149
120
  @property
150
121
  def failed_count(self) -> int:
151
122
  """Spans and scores lost because their batch could not be delivered."""
152
- return self._failed
123
+ return self._delivery.failed
153
124
 
154
125
  @property
155
126
  def sent_count(self) -> int:
156
127
  """Spans and scores delivered and accepted by the server."""
157
- return self._sent
128
+ return self._delivery.sent
158
129
 
159
130
  def export(self, span: SpanPayload) -> None:
160
131
  """Enqueue one serialized span. Never blocks on I/O and never raises."""
161
- self._enqueue(_SPANS, span)
132
+ self._enqueue(SPANS, span)
162
133
 
163
134
  def export_score(self, score: SpanPayload) -> None:
164
135
  """Enqueue one serialized score. Never blocks on I/O and never raises."""
165
- if _SCORES not in self._targets:
136
+ if not self._delivery.accepts(SCORES):
166
137
  logger.debug("No score endpoint configured; dropping a score")
167
138
  return
168
- self._enqueue(_SCORES, score)
139
+ self._enqueue(SCORES, score)
169
140
 
170
141
  def _enqueue(self, kind: str, payload: SpanPayload) -> None:
171
142
  try:
@@ -227,12 +198,11 @@ class BatchExporter:
227
198
  with self._condition:
228
199
  self._closed = True
229
200
  self._condition.notify_all()
230
- self._abort_retries.set()
201
+ self._delivery.abort_retries()
231
202
  worker = self._worker
232
203
  if worker is not None and worker is not threading.current_thread():
233
204
  worker.join(max(deadline - time.monotonic(), 0.0))
234
- if self._client is not None:
235
- self._client.close()
205
+ self._delivery.close()
236
206
  except Exception:
237
207
  logger.debug("Shutdown failed", exc_info=True)
238
208
 
@@ -249,7 +219,7 @@ class BatchExporter:
249
219
  self._queue = deque()
250
220
  self._in_flight = 0
251
221
  self._worker = None
252
- self._client = None
222
+ self._delivery.forget_client()
253
223
 
254
224
  def _ensure_worker(self) -> None:
255
225
  with self._condition:
@@ -270,10 +240,10 @@ class BatchExporter:
270
240
  if batch is None:
271
241
  return
272
242
  try:
273
- for kind in (_SPANS, _SCORES):
243
+ for kind in (SPANS, SCORES):
274
244
  payloads = [payload for item_kind, payload in batch if item_kind == kind]
275
245
  if payloads:
276
- self._send_guarded(self._targets[kind], payloads)
246
+ self._delivery.send(kind, payloads)
277
247
  finally:
278
248
  with self._condition:
279
249
  self._in_flight -= len(batch)
@@ -303,160 +273,3 @@ class BatchExporter:
303
273
  if self._closed or self._flush_requested:
304
274
  return False
305
275
  return len(self._queue) < self._batch_size
306
-
307
- def _http_client(self) -> httpx.Client:
308
- if self._client is None:
309
- self._client = httpx.Client(transport=self._transport, timeout=self._timeout)
310
- return self._client
311
-
312
- def _send_guarded(self, target: _Target, batch: list[SpanPayload]) -> None:
313
- """Send one target's part of a cycle; a crash here never stops the next part."""
314
- try:
315
- self._send_batch(target, batch)
316
- except Exception:
317
- self._failed += len(batch)
318
- logger.warning(
319
- "Unexpected error exporting %d %s", len(batch), target.body_key, exc_info=True
320
- )
321
-
322
- def _send_batch(self, target: _Target, batch: list[SpanPayload]) -> None:
323
- if not batch:
324
- return
325
- body = json.dumps({target.body_key: batch}, separators=(",", ":"), default=str).encode()
326
- if self._gzip:
327
- body = gzip.compress(body)
328
-
329
- for attempt in range(self._max_retries + 1):
330
- retry_after: float | None = None
331
- try:
332
- response = self._http_client().post(target.url, content=body, headers=self._headers)
333
- except httpx.HTTPError as exc:
334
- failure = f"{type(exc).__name__}: {exc}"
335
- else:
336
- status = response.status_code
337
- if 200 <= status < 300:
338
- self._handle_success(target, response, batch)
339
- return
340
- if status == 413 and len(batch) > 1:
341
- self._split_and_send(target, batch)
342
- return
343
- if not _is_retryable(status):
344
- self._failed += len(batch)
345
- self._log_rejected_batch(target, response, len(batch))
346
- return
347
- failure = f"HTTP {status}"
348
- retry_after = parse_retry_after(response.headers.get("Retry-After"))
349
-
350
- if attempt >= self._max_retries or self._abort_retries.is_set():
351
- self._failed += len(batch)
352
- logger.warning(
353
- "Dropping %d %s after %d attempts (%s)",
354
- len(batch),
355
- target.body_key,
356
- attempt + 1,
357
- failure,
358
- )
359
- return
360
- delay = self._retry_delay(attempt, retry_after)
361
- logger.debug("Export failed (%s); retrying in %.2fs", failure, delay)
362
- self._sleep(delay)
363
-
364
- def _split_and_send(self, target: _Target, batch: list[SpanPayload]) -> None:
365
- """Retry an oversized (HTTP 413) batch as two smaller ones."""
366
- middle = len(batch) // 2
367
- self._send_batch(target, batch[:middle])
368
- self._send_batch(target, batch[middle:])
369
-
370
- def _log_rejected_batch(self, target: _Target, response: httpx.Response, size: int) -> None:
371
- if target.body_key == _SCORES and response.status_code == 404:
372
- # A server older than score ingestion: say so once instead of on every batch.
373
- if not self._scores_unsupported_logged:
374
- self._scores_unsupported_logged = True
375
- logger.warning(
376
- "This Spanlight server does not accept scores (HTTP 404); dropping them"
377
- )
378
- return
379
- logger.error(
380
- "Spanlight rejected a batch of %d %s: HTTP %d %s",
381
- size,
382
- target.body_key,
383
- response.status_code,
384
- _short_body(response),
385
- )
386
-
387
- def _handle_success(
388
- self, target: _Target, response: httpx.Response, batch: list[SpanPayload]
389
- ) -> None:
390
- batch_size = len(batch)
391
- try:
392
- result: Any = response.json()
393
- except ValueError:
394
- self._sent += batch_size
395
- return
396
- rejected = result.get("rejected") if isinstance(result, dict) else None
397
- if not isinstance(rejected, list) or not rejected:
398
- self._sent += batch_size
399
- return
400
-
401
- self._sent += batch_size - len(rejected)
402
- self._failed += len(rejected)
403
- logger.warning(
404
- "Spanlight rejected %d of %d %s",
405
- len(rejected),
406
- batch_size,
407
- target.body_key,
408
- )
409
- for item in rejected[:_MAX_LOGGED_REJECTIONS]:
410
- if isinstance(item, dict):
411
- index = item.get("index")
412
- sent = batch[index] if isinstance(index, int) and 0 <= index < batch_size else {}
413
- logger.warning(
414
- " %s %s (index %s): %s",
415
- target.body_key[:-1],
416
- item.get(target.id_field) or sent.get(target.id_field),
417
- index,
418
- item.get("reason"),
419
- )
420
-
421
- def _retry_delay(self, attempt: int, retry_after: float | None) -> float:
422
- """Return seconds to wait before retry number ``attempt + 1``.
423
-
424
- ``Retry-After`` wins when present. Otherwise use "equal jitter"
425
- exponential backoff: half of the exponential step is fixed and the
426
- other half random, which spreads retries out without ever retrying
427
- immediately.
428
- """
429
- if retry_after is not None:
430
- return min(max(retry_after, 0.0), self._max_backoff)
431
- step = min(self._max_backoff, self._base_backoff * (2.0**attempt))
432
- return step / 2 + self._jitter() * step / 2
433
-
434
- def _interruptible_sleep(self, seconds: float) -> None:
435
- self._abort_retries.wait(seconds)
436
-
437
-
438
- def _is_retryable(status: int) -> bool:
439
- return status == 429 or status >= 500
440
-
441
-
442
- def parse_retry_after(value: str | None) -> float | None:
443
- """Parse a ``Retry-After`` header (delta-seconds or HTTP-date) into seconds."""
444
- if value is None:
445
- return None
446
- value = value.strip()
447
- try:
448
- return max(float(value), 0.0)
449
- except ValueError:
450
- pass
451
- try:
452
- moment = email.utils.parsedate_to_datetime(value)
453
- except (TypeError, ValueError):
454
- return None
455
- return max(moment.timestamp() - time.time(), 0.0)
456
-
457
-
458
- def _short_body(response: httpx.Response) -> str:
459
- try:
460
- return response.text[:500]
461
- except Exception:
462
- return "<unreadable body>"