spanlight 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {spanlight-0.2.0 → spanlight-0.3.0}/PKG-INFO +68 -4
- {spanlight-0.2.0 → spanlight-0.3.0}/README.md +67 -3
- {spanlight-0.2.0 → spanlight-0.3.0}/pyproject.toml +1 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/__init__.py +3 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_client.py +10 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_config.py +2 -2
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_decorator.py +15 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_api.py +8 -11
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_exporter.py +4 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_exporter_delivery.py +81 -26
- spanlight-0.3.0/src/spanlight/_hosts.py +61 -0
- spanlight-0.3.0/src/spanlight/_propagation.py +122 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_serialize.py +31 -17
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_span.py +32 -13
- spanlight-0.3.0/src/spanlight/_trace_state.py +131 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_usage.py +36 -13
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_version.py +1 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_batch.py +6 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_message.py +13 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_stream.py +4 -3
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_batch.py +4 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_common.py +26 -14
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_modifiers.py +18 -2
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/anthropic.py +26 -8
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/openai.py +36 -9
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/conftest.py +2 -2
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic.py +21 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic_batch_results.py +99 -0
- spanlight-0.3.0/tests/test_config.py +105 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_decorator.py +20 -1
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_docs_snippets.py +4 -2
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter.py +16 -3
- spanlight-0.3.0/tests/test_exporter_delivery_http.py +247 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter_retry_after.py +13 -0
- spanlight-0.3.0/tests/test_prompt_compilation.py +93 -0
- spanlight-0.3.0/tests/test_propagation.py +38 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_spans_and_context.py +68 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/uv.lock +4 -4
- spanlight-0.2.0/src/spanlight/_trace_state.py +0 -83
- {spanlight-0.2.0 → spanlight-0.3.0}/.gitignore +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/LICENSE +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/examples/support_bot.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_context.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_parse.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_report.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_runs.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_setup.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_globals.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_ids.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_metadata.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_prompt_client.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_prompts.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_ratelimit_headers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_request_hash.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_scores.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_templates.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_tiers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/__init__.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_batch.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_response.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_stream.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_provider_errors.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_streams.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_wrappers.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/py.typed +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/fixtures/template_cases.json +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/provider_mocks.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/support.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic_cut_stream.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_experiments_report.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter_counters.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_gateway_refusal.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_metadata.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai_batch_output.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai_tier_and_search.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_package.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_provider_errors.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_ratelimit_allowlist.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_templates_surrogates.py +0 -0
- {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_tiers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: spanlight
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for Spanlight, open-source observability for LLM applications.
|
|
5
5
|
Author: Dheeraj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -167,9 +167,11 @@ event, `client_disconnected` when your code closed it early, and
|
|
|
167
167
|
annotations, which are not a count of searches. No count is recorded and the
|
|
168
168
|
span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
|
|
169
169
|
shows as unknown rather than zero.
|
|
170
|
-
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
- Anthropic data residency and fast mode are priced from the answer's
|
|
171
|
+
`usage.inference_geo` and `usage.speed` (1.1 times and 2 times the standard
|
|
172
|
+
rate). When the answer lacks them, the requested option is tagged
|
|
173
|
+
`usage.pricing_modifier_unknown` and priced at the standard rate, as is an
|
|
174
|
+
OpenAI call through a regional host (`regional_endpoint`).
|
|
173
175
|
|
|
174
176
|
### Batch output
|
|
175
177
|
|
|
@@ -447,6 +449,13 @@ options. Use `tracer.observe`, `tracer.span` and
|
|
|
447
449
|
| `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
|
|
448
450
|
| `prompt_cache_ttl` | | `60.0` | Seconds a fetched prompt is served before it is revalidated |
|
|
449
451
|
|
|
452
|
+
`host` must use `https://`. Plain `http://` works only for local and private-network hosts:
|
|
453
|
+
`localhost` and names ending in `.localhost`, `.local` or `.internal`, single-label names such
|
|
454
|
+
as a Compose service (`http://api:8000`), and loopback, private and link-local IP addresses.
|
|
455
|
+
With any other plain-http host the SDK logs a warning and stays disabled rather than send the
|
|
456
|
+
key in clear text. A service name with an underscore (`my_api`) is not a valid host name and is
|
|
457
|
+
refused too: rename it with a hyphen or use `https://`.
|
|
458
|
+
|
|
450
459
|
### `@observe(name=None, kind="chain", capture_input=True, capture_output=True)`
|
|
451
460
|
|
|
452
461
|
Traces each call of a function. It supports plain functions, `async def`,
|
|
@@ -505,8 +514,63 @@ Trace ids are 32 lowercase hex characters and span ids are 16, both
|
|
|
505
514
|
W3C Trace Context compatible. `spanlight.get_current_trace_id()` returns the
|
|
506
515
|
current trace id so you can put it in your logs.
|
|
507
516
|
|
|
517
|
+
### `inject(carrier=None) -> dict` and `continue_trace(carrier)`
|
|
518
|
+
|
|
519
|
+
These carry a trace from one process to another as a W3C `traceparent`, so an MCP server, an
|
|
520
|
+
agent you call over HTTP or a queue worker records its spans as children of the caller's span.
|
|
521
|
+
|
|
522
|
+
`inject` returns a copy of `carrier` with `traceparent` set to the current span. A
|
|
523
|
+
`traceparent` already in the carrier is kept, and outside a span the copy is returned
|
|
524
|
+
unchanged. `continue_trace` is a `with` and `async with` block: spans opened inside it become
|
|
525
|
+
children of the span the `traceparent` names. A missing or invalid `traceparent` is ignored and
|
|
526
|
+
the next span starts a new trace. The W3C sampled flag is not honoured (every span is recorded),
|
|
527
|
+
and `tracestate` and `baggage` are not used.
|
|
528
|
+
|
|
529
|
+
MCP carries trace context in `params._meta` under the key `traceparent`
|
|
530
|
+
([SEP-414](https://modelcontextprotocol.io/seps/414-request-meta)):
|
|
531
|
+
|
|
532
|
+
```python
|
|
533
|
+
# Client: before sending a request
|
|
534
|
+
params["_meta"] = spanlight.inject(params.get("_meta"))
|
|
535
|
+
|
|
536
|
+
# Server: while handling it
|
|
537
|
+
with spanlight.continue_trace(request.params.meta):
|
|
538
|
+
result = run_tool(request.params.arguments)
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
HTTP headers work the same way:
|
|
542
|
+
|
|
543
|
+
```python
|
|
544
|
+
# Client
|
|
545
|
+
with spanlight.span("ask-billing-agent"):
|
|
546
|
+
response = httpx.post(url, json=body, headers=spanlight.inject())
|
|
547
|
+
|
|
548
|
+
# Server (any headers object with .get, such as Starlette's or Flask's)
|
|
549
|
+
with spanlight.continue_trace(request.headers), spanlight.span("answer"):
|
|
550
|
+
...
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Spans the server records carry no trace name, environment or release, so they never overwrite the
|
|
554
|
+
caller's. The remote parent is a placeholder that is never sent: inside the block
|
|
555
|
+
`get_current_span()` returns it, and setters called on it are lost, so open a span of your own
|
|
556
|
+
first.
|
|
557
|
+
|
|
558
|
+
Only continue traces from callers you trust. The caller chooses the trace id, so it can attach
|
|
559
|
+
your spans to another trace of the same project. Your spans still land in the project of your
|
|
560
|
+
own API key.
|
|
561
|
+
|
|
508
562
|
## Delivery guarantees
|
|
509
563
|
|
|
564
|
+
Delivery is confirmed only by a JSON acknowledgment whose nonnegative integer `accepted`
|
|
565
|
+
count plus its `rejected` entries equals the batch size. Rejection indexes must be unique
|
|
566
|
+
integers within the batch. Empty, malformed or inconsistent success responses are ambiguous:
|
|
567
|
+
the SDK retries the same IDs within its configured retry budget, then counts unconfirmed
|
|
568
|
+
entries as failed. A write may already have committed; server idempotence prevents duplicate
|
|
569
|
+
stored records when those IDs are retried. A valid `Retry-After` is a minimum delay, even above
|
|
570
|
+
the client exponential backoff cap. Integer delays beyond exact numeric representation remain
|
|
571
|
+
pending until shutdown rather than retrying early. Shutdown interrupts pending retry waits.
|
|
572
|
+
|
|
573
|
+
|
|
510
574
|
Spans are sent by a background daemon thread. A batch goes out when it reaches
|
|
511
575
|
`batch_size` spans or after `flush_interval` seconds. Failed requests are
|
|
512
576
|
retried with jittered exponential backoff on network errors, `429` and `5xx`,
|
|
@@ -140,9 +140,11 @@ event, `client_disconnected` when your code closed it early, and
|
|
|
140
140
|
annotations, which are not a count of searches. No count is recorded and the
|
|
141
141
|
span gets `usage.fee_unknown = "openai_chat_web_search"`, so the search fee
|
|
142
142
|
shows as unknown rather than zero.
|
|
143
|
-
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
- Anthropic data residency and fast mode are priced from the answer's
|
|
144
|
+
`usage.inference_geo` and `usage.speed` (1.1 times and 2 times the standard
|
|
145
|
+
rate). When the answer lacks them, the requested option is tagged
|
|
146
|
+
`usage.pricing_modifier_unknown` and priced at the standard rate, as is an
|
|
147
|
+
OpenAI call through a regional host (`regional_endpoint`).
|
|
146
148
|
|
|
147
149
|
### Batch output
|
|
148
150
|
|
|
@@ -420,6 +422,13 @@ options. Use `tracer.observe`, `tracer.span` and
|
|
|
420
422
|
| `shutdown_timeout` | | `5.0` | Time budget for the flush at interpreter exit |
|
|
421
423
|
| `prompt_cache_ttl` | | `60.0` | Seconds a fetched prompt is served before it is revalidated |
|
|
422
424
|
|
|
425
|
+
`host` must use `https://`. Plain `http://` works only for local and private-network hosts:
|
|
426
|
+
`localhost` and names ending in `.localhost`, `.local` or `.internal`, single-label names such
|
|
427
|
+
as a Compose service (`http://api:8000`), and loopback, private and link-local IP addresses.
|
|
428
|
+
With any other plain-http host the SDK logs a warning and stays disabled rather than send the
|
|
429
|
+
key in clear text. A service name with an underscore (`my_api`) is not a valid host name and is
|
|
430
|
+
refused too: rename it with a hyphen or use `https://`.
|
|
431
|
+
|
|
423
432
|
### `@observe(name=None, kind="chain", capture_input=True, capture_output=True)`
|
|
424
433
|
|
|
425
434
|
Traces each call of a function. It supports plain functions, `async def`,
|
|
@@ -478,8 +487,63 @@ Trace ids are 32 lowercase hex characters and span ids are 16, both
|
|
|
478
487
|
W3C Trace Context compatible. `spanlight.get_current_trace_id()` returns the
|
|
479
488
|
current trace id so you can put it in your logs.
|
|
480
489
|
|
|
490
|
+
### `inject(carrier=None) -> dict` and `continue_trace(carrier)`
|
|
491
|
+
|
|
492
|
+
These carry a trace from one process to another as a W3C `traceparent`, so an MCP server, an
|
|
493
|
+
agent you call over HTTP or a queue worker records its spans as children of the caller's span.
|
|
494
|
+
|
|
495
|
+
`inject` returns a copy of `carrier` with `traceparent` set to the current span. A
|
|
496
|
+
`traceparent` already in the carrier is kept, and outside a span the copy is returned
|
|
497
|
+
unchanged. `continue_trace` is a `with` and `async with` block: spans opened inside it become
|
|
498
|
+
children of the span the `traceparent` names. A missing or invalid `traceparent` is ignored and
|
|
499
|
+
the next span starts a new trace. The W3C sampled flag is not honoured (every span is recorded),
|
|
500
|
+
and `tracestate` and `baggage` are not used.
|
|
501
|
+
|
|
502
|
+
MCP carries trace context in `params._meta` under the key `traceparent`
|
|
503
|
+
([SEP-414](https://modelcontextprotocol.io/seps/414-request-meta)):
|
|
504
|
+
|
|
505
|
+
```python
|
|
506
|
+
# Client: before sending a request
|
|
507
|
+
params["_meta"] = spanlight.inject(params.get("_meta"))
|
|
508
|
+
|
|
509
|
+
# Server: while handling it
|
|
510
|
+
with spanlight.continue_trace(request.params.meta):
|
|
511
|
+
result = run_tool(request.params.arguments)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
HTTP headers work the same way:
|
|
515
|
+
|
|
516
|
+
```python
|
|
517
|
+
# Client
|
|
518
|
+
with spanlight.span("ask-billing-agent"):
|
|
519
|
+
response = httpx.post(url, json=body, headers=spanlight.inject())
|
|
520
|
+
|
|
521
|
+
# Server (any headers object with .get, such as Starlette's or Flask's)
|
|
522
|
+
with spanlight.continue_trace(request.headers), spanlight.span("answer"):
|
|
523
|
+
...
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Spans the server records carry no trace name, environment or release, so they never overwrite the
|
|
527
|
+
caller's. The remote parent is a placeholder that is never sent: inside the block
|
|
528
|
+
`get_current_span()` returns it, and setters called on it are lost, so open a span of your own
|
|
529
|
+
first.
|
|
530
|
+
|
|
531
|
+
Only continue traces from callers you trust. The caller chooses the trace id, so it can attach
|
|
532
|
+
your spans to another trace of the same project. Your spans still land in the project of your
|
|
533
|
+
own API key.
|
|
534
|
+
|
|
481
535
|
## Delivery guarantees
|
|
482
536
|
|
|
537
|
+
Delivery is confirmed only by a JSON acknowledgment whose nonnegative integer `accepted`
|
|
538
|
+
count plus its `rejected` entries equals the batch size. Rejection indexes must be unique
|
|
539
|
+
integers within the batch. Empty, malformed or inconsistent success responses are ambiguous:
|
|
540
|
+
the SDK retries the same IDs within its configured retry budget, then counts unconfirmed
|
|
541
|
+
entries as failed. A write may already have committed; server idempotence prevents duplicate
|
|
542
|
+
stored records when those IDs are retried. A valid `Retry-After` is a minimum delay, even above
|
|
543
|
+
the client exponential backoff cap. Integer delays beyond exact numeric representation remain
|
|
544
|
+
pending until shutdown rather than retrying early. Shutdown interrupts pending retry waits.
|
|
545
|
+
|
|
546
|
+
|
|
483
547
|
Spans are sent by a background daemon thread. A batch goes out when it reaches
|
|
484
548
|
`batch_size` spans or after `flush_interval` seconds. Failed requests are
|
|
485
549
|
retried with jittered exponential backoff on network errors, `429` and `5xx`,
|
|
@@ -22,6 +22,7 @@ from ._context import get_current_span, get_current_trace_id
|
|
|
22
22
|
from ._globals import flush, get_client, init, observe, prompts, score, shutdown, span
|
|
23
23
|
from ._prompt_client import PromptClient
|
|
24
24
|
from ._prompts import CompiledPrompt, Prompt, PromptUnavailableError, no_prompt
|
|
25
|
+
from ._propagation import continue_trace, inject
|
|
25
26
|
from ._span import Span, SpanKind, SpanStatus
|
|
26
27
|
from ._templates import InvalidTemplateError, MissingVariablesError, TemplateTooLargeError
|
|
27
28
|
from ._version import __version__
|
|
@@ -43,11 +44,13 @@ __all__ = [
|
|
|
43
44
|
"Spanlight",
|
|
44
45
|
"TemplateTooLargeError",
|
|
45
46
|
"__version__",
|
|
47
|
+
"continue_trace",
|
|
46
48
|
"flush",
|
|
47
49
|
"get_client",
|
|
48
50
|
"get_current_span",
|
|
49
51
|
"get_current_trace_id",
|
|
50
52
|
"init",
|
|
53
|
+
"inject",
|
|
51
54
|
"no_prompt",
|
|
52
55
|
"observe",
|
|
53
56
|
"prompts",
|
|
@@ -19,6 +19,7 @@ from ._context import (
|
|
|
19
19
|
set_current_span,
|
|
20
20
|
)
|
|
21
21
|
from ._exporter import BatchExporter
|
|
22
|
+
from ._hosts import is_safe_host
|
|
22
23
|
from ._prompt_client import PromptClient
|
|
23
24
|
from ._scores import ScoreValue, build_score
|
|
24
25
|
from ._span import Span, SpanKind, normalize_kind
|
|
@@ -104,9 +105,11 @@ class Spanlight:
|
|
|
104
105
|
prompt_cache_ttl=prompt_cache_ttl,
|
|
105
106
|
)
|
|
106
107
|
self._exporter: BatchExporter | None = None
|
|
108
|
+
# The key is a bearer credential: never send it in clear text to another machine.
|
|
109
|
+
safe_host = is_safe_host(self.config.host)
|
|
107
110
|
self.prompts: PromptClient = PromptClient(
|
|
108
111
|
base_url=self.config.prompts_url,
|
|
109
|
-
api_key=self.config.api_key,
|
|
112
|
+
api_key=self.config.api_key if safe_host else None,
|
|
110
113
|
timeout=self.config.timeout,
|
|
111
114
|
cache_ttl=self.config.prompt_cache_ttl,
|
|
112
115
|
transport=transport,
|
|
@@ -118,6 +121,12 @@ class Spanlight:
|
|
|
118
121
|
"Spanlight: no API key configured (set SPANLIGHT_API_KEY or pass "
|
|
119
122
|
"api_key=...); tracing is disabled."
|
|
120
123
|
)
|
|
124
|
+
elif self.config.enabled and not safe_host:
|
|
125
|
+
logger.warning(
|
|
126
|
+
"Spanlight: the host must be an https:// URL (plain http:// is allowed only for "
|
|
127
|
+
"local and private-network hosts); the API key is not sent, so tracing and "
|
|
128
|
+
"prompt fetching are disabled."
|
|
129
|
+
)
|
|
121
130
|
elif self.config.enabled and self.config.api_key:
|
|
122
131
|
self._exporter = BatchExporter(
|
|
123
132
|
url=self.config.ingest_url,
|
|
@@ -8,7 +8,7 @@ a ``SPANLIGHT_*`` environment variable and then to a default.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
12
|
|
|
13
13
|
DEFAULT_HOST = "http://localhost:8000"
|
|
14
14
|
INGEST_PATH = "/v1/traces"
|
|
@@ -39,7 +39,7 @@ class SpanlightConfig:
|
|
|
39
39
|
prompt_cache_ttl: Seconds a fetched prompt is served before it is revalidated.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
|
-
api_key: str | None
|
|
42
|
+
api_key: str | None = field(repr=False)
|
|
43
43
|
host: str
|
|
44
44
|
environment: str | None
|
|
45
45
|
release: str | None
|
|
@@ -16,6 +16,7 @@ SDK itself are swallowed so they cannot break the decorated function.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import asyncio
|
|
19
20
|
import functools
|
|
20
21
|
import inspect
|
|
21
22
|
import logging
|
|
@@ -252,6 +253,10 @@ async def _traced_async_generator(
|
|
|
252
253
|
"""Async counterpart of :func:`_traced_generator`."""
|
|
253
254
|
collected = _OutputCollector(enabled=options.capture_output)
|
|
254
255
|
step: Callable[[], Any] = functools.partial(generator.asend, None)
|
|
256
|
+
# A cancellation thrown in at a `yield` means the consumer abandoned the generator: asyncio
|
|
257
|
+
# closes one left behind by `async for ... break` that way at loop shutdown. Like a `break`
|
|
258
|
+
# over a sync generator, that ends the span as ok, not failed.
|
|
259
|
+
abandoned: BaseException | None = None
|
|
255
260
|
while True:
|
|
256
261
|
previous = get_current_span()
|
|
257
262
|
token = set_current_span(span)
|
|
@@ -266,7 +271,15 @@ async def _traced_async_generator(
|
|
|
266
271
|
)
|
|
267
272
|
return
|
|
268
273
|
except BaseException as exc:
|
|
269
|
-
|
|
274
|
+
if exc is abandoned:
|
|
275
|
+
_finish(
|
|
276
|
+
span,
|
|
277
|
+
output=collected.result(None),
|
|
278
|
+
capture_output=options.capture_output,
|
|
279
|
+
output_cut=collected.truncated,
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
_finish(span, error=exc)
|
|
270
283
|
raise
|
|
271
284
|
finally:
|
|
272
285
|
restore_current_span(token, previous)
|
|
@@ -286,6 +299,7 @@ async def _traced_async_generator(
|
|
|
286
299
|
)
|
|
287
300
|
raise
|
|
288
301
|
except BaseException as exc:
|
|
302
|
+
abandoned = exc if isinstance(exc, asyncio.CancelledError) else None
|
|
289
303
|
step = functools.partial(generator.athrow, exc)
|
|
290
304
|
else:
|
|
291
305
|
step = functools.partial(generator.asend, sent)
|
|
@@ -10,18 +10,18 @@ from __future__ import annotations
|
|
|
10
10
|
from collections.abc import Iterator
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
from typing import Any
|
|
13
|
-
from urllib.parse import quote
|
|
13
|
+
from urllib.parse import quote
|
|
14
14
|
|
|
15
15
|
import httpx
|
|
16
16
|
|
|
17
17
|
from ._experiments_parse import PromptRef
|
|
18
|
+
from ._hosts import is_safe_host
|
|
18
19
|
from ._version import __version__
|
|
19
20
|
|
|
20
21
|
_PAGE_SIZE = 200
|
|
21
22
|
# A guard against a server that keeps returning cursors: 50 pages of 200 is 10 000 rows.
|
|
22
23
|
_MAX_PAGES = 50
|
|
23
24
|
_ESCAPE = "~"
|
|
24
|
-
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
class ExperimentError(Exception):
|
|
@@ -39,15 +39,12 @@ class ResolvedPrompt:
|
|
|
39
39
|
|
|
40
40
|
def _require_safe_host(host: str) -> None:
|
|
41
41
|
"""Refuse to send the access token over plain http, except to the local machine."""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"the host must be an https:// URL (http:// is allowed only for localhost); "
|
|
49
|
-
"the access token is never sent in clear text"
|
|
50
|
-
)
|
|
42
|
+
if not is_safe_host(host):
|
|
43
|
+
raise ExperimentError(
|
|
44
|
+
"the host must be an https:// URL (http:// is allowed only for local and "
|
|
45
|
+
"private-network hosts); "
|
|
46
|
+
"the access token is never sent in clear text"
|
|
47
|
+
)
|
|
51
48
|
|
|
52
49
|
|
|
53
50
|
def wire_name(name: str) -> str:
|
|
@@ -63,7 +63,8 @@ class BatchExporter:
|
|
|
63
63
|
jitter: Function returning a float in ``[0, 1)`` used to randomize
|
|
64
64
|
backoff delays.
|
|
65
65
|
base_backoff: Backoff before the first retry, in seconds.
|
|
66
|
-
max_backoff: Upper bound for
|
|
66
|
+
max_backoff: Upper bound for client exponential backoff, in seconds.
|
|
67
|
+
A valid server ``Retry-After`` minimum may exceed this bound.
|
|
67
68
|
"""
|
|
68
69
|
|
|
69
70
|
def __init__(
|
|
@@ -189,6 +190,8 @@ class BatchExporter:
|
|
|
189
190
|
``True`` if the queue drained in time, ``False`` otherwise.
|
|
190
191
|
"""
|
|
191
192
|
try:
|
|
193
|
+
# A forked child must not wait on the parent's in-flight batch.
|
|
194
|
+
self._check_fork()
|
|
192
195
|
self._ensure_worker()
|
|
193
196
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
194
197
|
with self._condition:
|
|
@@ -135,35 +135,41 @@ class Delivery:
|
|
|
135
135
|
def _send_batch(
|
|
136
136
|
self, target: _Target, batch: list[SpanPayload], client: Mapping[str, Any] | None
|
|
137
137
|
) -> None:
|
|
138
|
+
batch, encoded = self._encode_each(target, batch)
|
|
138
139
|
if not batch:
|
|
139
140
|
return
|
|
140
|
-
|
|
141
|
+
fields = [f'"{target.body_key}":[{",".join(encoded)}]']
|
|
141
142
|
if client is not None and target.body_key == SPANS:
|
|
142
|
-
|
|
143
|
-
body =
|
|
143
|
+
fields.append('"client":' + json.dumps(dict(client), separators=(",", ":")))
|
|
144
|
+
body = ("{" + ",".join(fields) + "}").encode()
|
|
144
145
|
if self._gzip:
|
|
145
146
|
body = gzip.compress(body)
|
|
146
147
|
|
|
147
148
|
for attempt in range(self._max_retries + 1):
|
|
149
|
+
if self._abort_retries.is_set():
|
|
150
|
+
self.failed += len(batch)
|
|
151
|
+
return
|
|
148
152
|
retry_after: float | None = None
|
|
149
153
|
try:
|
|
150
154
|
response = self._http_client().post(target.url, content=body, headers=self._headers)
|
|
151
155
|
except httpx.HTTPError as exc:
|
|
152
|
-
failure =
|
|
156
|
+
failure = type(exc).__name__
|
|
153
157
|
else:
|
|
154
158
|
status = response.status_code
|
|
155
159
|
if 200 <= status < 300:
|
|
156
|
-
self._handle_success(target, response, batch)
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
if self._handle_success(target, response, batch):
|
|
161
|
+
return
|
|
162
|
+
failure = "ambiguous acknowledgment"
|
|
163
|
+
elif status == 413 and len(batch) > 1:
|
|
159
164
|
self._split_and_send(target, batch, client)
|
|
160
165
|
return
|
|
161
|
-
|
|
166
|
+
elif not _is_retryable(status):
|
|
162
167
|
self.failed += len(batch)
|
|
163
168
|
self._log_rejected_batch(target, response, len(batch))
|
|
164
169
|
return
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
else:
|
|
171
|
+
failure = f"HTTP {status}"
|
|
172
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
167
173
|
|
|
168
174
|
if attempt >= self._max_retries or self._abort_retries.is_set():
|
|
169
175
|
self.failed += len(batch)
|
|
@@ -179,6 +185,27 @@ class Delivery:
|
|
|
179
185
|
logger.debug("Export failed (%s); retrying in %.2fs", failure, delay)
|
|
180
186
|
self._sleep(delay)
|
|
181
187
|
|
|
188
|
+
def _encode_each(
|
|
189
|
+
self, target: _Target, batch: list[SpanPayload]
|
|
190
|
+
) -> tuple[list[SpanPayload], list[str]]:
|
|
191
|
+
"""JSON-encode each item on its own, so one that cannot be encoded loses only itself."""
|
|
192
|
+
kept: list[SpanPayload] = []
|
|
193
|
+
encoded: list[str] = []
|
|
194
|
+
for item in batch:
|
|
195
|
+
try:
|
|
196
|
+
encoded.append(json.dumps(item, separators=(",", ":"), default=str))
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
self.failed += 1
|
|
199
|
+
logger.warning(
|
|
200
|
+
"Dropping %s %s: it cannot be encoded as JSON (%s)",
|
|
201
|
+
target.body_key[:-1],
|
|
202
|
+
item.get(target.id_field),
|
|
203
|
+
type(exc).__name__,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
kept.append(item)
|
|
207
|
+
return kept, encoded
|
|
208
|
+
|
|
182
209
|
def _split_and_send(
|
|
183
210
|
self, target: _Target, batch: list[SpanPayload], client: Mapping[str, Any] | None
|
|
184
211
|
) -> None:
|
|
@@ -206,20 +233,35 @@ class Delivery:
|
|
|
206
233
|
|
|
207
234
|
def _handle_success(
|
|
208
235
|
self, target: _Target, response: httpx.Response, batch: list[SpanPayload]
|
|
209
|
-
) ->
|
|
236
|
+
) -> bool:
|
|
210
237
|
batch_size = len(batch)
|
|
211
238
|
try:
|
|
212
239
|
result: Any = response.json()
|
|
213
240
|
except ValueError:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
241
|
+
return False
|
|
242
|
+
if not isinstance(result, dict):
|
|
243
|
+
return False
|
|
244
|
+
accepted, rejected = result.get("accepted"), result.get("rejected")
|
|
245
|
+
if (
|
|
246
|
+
type(accepted) is not int
|
|
247
|
+
or accepted < 0
|
|
248
|
+
or not isinstance(rejected, list)
|
|
249
|
+
or accepted + len(rejected) != batch_size
|
|
250
|
+
):
|
|
251
|
+
return False
|
|
252
|
+
indexes: set[int] = set()
|
|
253
|
+
for item in rejected:
|
|
254
|
+
if not isinstance(item, dict):
|
|
255
|
+
return False
|
|
256
|
+
index = item.get("index")
|
|
257
|
+
if type(index) is not int or not 0 <= index < batch_size or index in indexes:
|
|
258
|
+
return False
|
|
259
|
+
indexes.add(index)
|
|
260
|
+
|
|
261
|
+
self.sent += accepted
|
|
222
262
|
self.failed += len(rejected)
|
|
263
|
+
if not rejected:
|
|
264
|
+
return True
|
|
223
265
|
logger.warning(
|
|
224
266
|
"Spanlight rejected %d of %d %s",
|
|
225
267
|
len(rejected),
|
|
@@ -238,21 +280,26 @@ class Delivery:
|
|
|
238
280
|
item.get("reason"),
|
|
239
281
|
)
|
|
240
282
|
|
|
283
|
+
return True
|
|
284
|
+
|
|
241
285
|
def retry_delay(self, attempt: int, retry_after: float | None) -> float:
|
|
242
286
|
"""Return seconds to wait before retry number ``attempt + 1``.
|
|
243
287
|
|
|
244
|
-
``Retry-After``
|
|
288
|
+
``Retry-After`` sets a minimum when present. Use "equal jitter"
|
|
245
289
|
exponential backoff: half of the exponential step is fixed and the
|
|
246
290
|
other half random, which spreads retries out without ever retrying
|
|
247
291
|
immediately.
|
|
248
292
|
"""
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
step = min(self._max_backoff, self._base_backoff * (2.0**attempt))
|
|
252
|
-
return step / 2 + self._jitter() * step / 2
|
|
293
|
+
step = min(self._max_backoff, self._base_backoff * (2.0 ** min(attempt, 1023)))
|
|
294
|
+
return max(retry_after or 0.0, step / 2 + self._jitter() * step / 2)
|
|
253
295
|
|
|
254
296
|
def _interruptible_sleep(self, seconds: float) -> None:
|
|
255
|
-
|
|
297
|
+
# Event.wait overflows on very large valid server delays; wait in bounded chunks.
|
|
298
|
+
while seconds > 0 and not self._abort_retries.is_set():
|
|
299
|
+
started = time.monotonic()
|
|
300
|
+
if self._abort_retries.wait(min(seconds, threading.TIMEOUT_MAX)):
|
|
301
|
+
return
|
|
302
|
+
seconds -= time.monotonic() - started
|
|
256
303
|
|
|
257
304
|
|
|
258
305
|
def _is_retryable(status: int) -> bool:
|
|
@@ -264,6 +311,10 @@ def parse_retry_after(value: str | None) -> float | None:
|
|
|
264
311
|
if value is None:
|
|
265
312
|
return None
|
|
266
313
|
value = value.strip()
|
|
314
|
+
if value.isascii() and value.isdecimal():
|
|
315
|
+
seconds = float(value)
|
|
316
|
+
# Beyond exact integer representation, keep the minimum indefinite until shutdown.
|
|
317
|
+
return seconds if seconds <= 2**53 else math.inf
|
|
267
318
|
try:
|
|
268
319
|
seconds = float(value)
|
|
269
320
|
except ValueError:
|
|
@@ -275,7 +326,11 @@ def parse_retry_after(value: str | None) -> float | None:
|
|
|
275
326
|
moment = email.utils.parsedate_to_datetime(value)
|
|
276
327
|
except (TypeError, ValueError):
|
|
277
328
|
return None
|
|
278
|
-
|
|
329
|
+
try:
|
|
330
|
+
seconds = moment.timestamp() - time.time()
|
|
331
|
+
except (OverflowError, OSError, ValueError):
|
|
332
|
+
return None
|
|
333
|
+
return max(seconds, 0.0) if math.isfinite(seconds) else None
|
|
279
334
|
|
|
280
335
|
|
|
281
336
|
def _short_body(response: httpx.Response) -> str:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Which hosts the SDK may send a credential to.
|
|
2
|
+
|
|
3
|
+
Any https:// host is fine. Plain http:// is allowed only for a host that cannot be on the public
|
|
4
|
+
internet: loopback, private and link-local addresses, and local-only names. The TypeScript SDK
|
|
5
|
+
applies the same rule (``sdks/typescript/src/client.ts``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
# Explicit lists rather than ``is_private``, which is broader and would make the SDKs disagree.
|
|
16
|
+
_LOCAL_NETWORKS = tuple(
|
|
17
|
+
ipaddress.ip_network(network)
|
|
18
|
+
for network in (
|
|
19
|
+
"127.0.0.0/8",
|
|
20
|
+
"10.0.0.0/8",
|
|
21
|
+
"172.16.0.0/12",
|
|
22
|
+
"192.168.0.0/16",
|
|
23
|
+
"169.254.0.0/16",
|
|
24
|
+
"::1/128",
|
|
25
|
+
"fc00::/7",
|
|
26
|
+
"fe80::/10",
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
_LOCAL_SUFFIXES = (".localhost", ".local", ".internal")
|
|
30
|
+
_SINGLE_LABEL = re.compile(r"[a-z0-9-]+")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_safe_host(host: str) -> bool:
|
|
34
|
+
"""True for an https:// URL, or plain http:// to a local or private-network host."""
|
|
35
|
+
try:
|
|
36
|
+
parts = urlsplit(host)
|
|
37
|
+
hostname = parts.hostname
|
|
38
|
+
except ValueError: # e.g. an unclosed "[::1"; fail closed instead of raising into the app
|
|
39
|
+
return False
|
|
40
|
+
if not hostname:
|
|
41
|
+
return False
|
|
42
|
+
if parts.scheme == "https":
|
|
43
|
+
return True
|
|
44
|
+
return parts.scheme == "http" and _is_local(hostname)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_local(hostname: str) -> bool:
|
|
48
|
+
name = hostname.removesuffix(".")
|
|
49
|
+
if name == "localhost" or name.endswith(_LOCAL_SUFFIXES):
|
|
50
|
+
return True
|
|
51
|
+
try:
|
|
52
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address = ipaddress.ip_address(name)
|
|
53
|
+
except ValueError:
|
|
54
|
+
if "." in name or not _SINGLE_LABEL.fullmatch(name):
|
|
55
|
+
return False
|
|
56
|
+
try:
|
|
57
|
+
# The resolver reads a dotless number ("134744072", "0x8080808") as an IPv4 address.
|
|
58
|
+
address = ipaddress.IPv4Address(socket.inet_aton(name))
|
|
59
|
+
except OSError:
|
|
60
|
+
return True # a single-label name such as a Compose service ("api")
|
|
61
|
+
return any(address in network for network in _LOCAL_NETWORKS)
|