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.
- {spanlight-0.1.0 → spanlight-0.1.2}/PKG-INFO +20 -13
- {spanlight-0.1.0 → spanlight-0.1.2}/README.md +19 -12
- {spanlight-0.1.0 → spanlight-0.1.2}/pyproject.toml +1 -1
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments.py +4 -1
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_report.py +15 -3
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_exporter.py +27 -214
- spanlight-0.1.2/src/spanlight/_exporter_delivery.py +268 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_span.py +18 -2
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_templates.py +10 -2
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_version.py +1 -1
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/anthropic.py +24 -5
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/provider_mocks.py +40 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_anthropic.py +57 -4
- spanlight-0.1.2/tests/test_docs_snippets.py +155 -0
- spanlight-0.1.2/tests/test_experiments_report.py +106 -0
- spanlight-0.1.2/tests/test_exporter_retry_after.py +33 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_spans_and_context.py +30 -0
- spanlight-0.1.2/tests/test_templates_surrogates.py +53 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/uv.lock +1 -1
- {spanlight-0.1.0 → spanlight-0.1.2}/.gitignore +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/LICENSE +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/examples/support_bot.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/__init__.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_client.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_config.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_context.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_decorator.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_api.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_parse.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_runs.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_experiments_setup.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_globals.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_ids.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_prompt_client.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_prompts.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_request_hash.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_scores.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/_serialize.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/__init__.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/_common.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/integrations/openai.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/src/spanlight/py.typed +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/conftest.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/fixtures/template_cases.json +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/support.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_decorator.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_exporter.py +0 -0
- {spanlight-0.1.0 → spanlight-0.1.2}/tests/test_openai.py +0 -0
- {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.
|
|
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
|
|
298
|
-
|
|
|
299
|
-
| `provider`
|
|
300
|
-
| `model`
|
|
301
|
-
| `usage.input_tokens`
|
|
302
|
-
| `usage.output_tokens`
|
|
303
|
-
| `usage.cached_tokens`
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
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
|
|
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
|
|
271
|
-
|
|
|
272
|
-
| `provider`
|
|
273
|
-
| `model`
|
|
274
|
-
| `usage.input_tokens`
|
|
275
|
-
| `usage.output_tokens`
|
|
276
|
-
| `usage.cached_tokens`
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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 {
|
|
93
|
-
f"p95 {
|
|
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 .
|
|
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
|
-
|
|
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.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
136
|
+
if not self._delivery.accepts(SCORES):
|
|
166
137
|
logger.debug("No score endpoint configured; dropping a score")
|
|
167
138
|
return
|
|
168
|
-
self._enqueue(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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.
|
|
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>"
|