spanlight 0.2.1__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.
Files changed (81) hide show
  1. {spanlight-0.2.1 → spanlight-0.3.0}/PKG-INFO +68 -4
  2. {spanlight-0.2.1 → spanlight-0.3.0}/README.md +67 -3
  3. {spanlight-0.2.1 → spanlight-0.3.0}/pyproject.toml +1 -1
  4. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/__init__.py +3 -0
  5. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_client.py +10 -1
  6. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_config.py +2 -2
  7. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments_api.py +8 -11
  8. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_exporter.py +2 -1
  9. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_exporter_delivery.py +56 -23
  10. spanlight-0.3.0/src/spanlight/_hosts.py +61 -0
  11. spanlight-0.3.0/src/spanlight/_propagation.py +122 -0
  12. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_version.py +1 -1
  13. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_batch.py +6 -1
  14. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_message.py +13 -0
  15. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_stream.py +4 -3
  16. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_batch.py +4 -0
  17. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_modifiers.py +18 -2
  18. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/openai.py +24 -7
  19. {spanlight-0.2.1 → spanlight-0.3.0}/tests/conftest.py +2 -2
  20. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_anthropic_batch_results.py +99 -0
  21. spanlight-0.3.0/tests/test_config.py +105 -0
  22. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_decorator.py +1 -1
  23. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_docs_snippets.py +4 -2
  24. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_exporter.py +3 -3
  25. spanlight-0.3.0/tests/test_exporter_delivery_http.py +247 -0
  26. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_exporter_retry_after.py +13 -0
  27. spanlight-0.3.0/tests/test_prompt_compilation.py +93 -0
  28. spanlight-0.3.0/tests/test_propagation.py +38 -0
  29. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_spans_and_context.py +1 -1
  30. {spanlight-0.2.1 → spanlight-0.3.0}/uv.lock +4 -4
  31. {spanlight-0.2.1 → spanlight-0.3.0}/.gitignore +0 -0
  32. {spanlight-0.2.1 → spanlight-0.3.0}/LICENSE +0 -0
  33. {spanlight-0.2.1 → spanlight-0.3.0}/examples/support_bot.py +0 -0
  34. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_context.py +0 -0
  35. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_decorator.py +0 -0
  36. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments.py +0 -0
  37. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments_parse.py +0 -0
  38. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments_report.py +0 -0
  39. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments_runs.py +0 -0
  40. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_experiments_setup.py +0 -0
  41. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_globals.py +0 -0
  42. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_ids.py +0 -0
  43. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_metadata.py +0 -0
  44. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_prompt_client.py +0 -0
  45. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_prompts.py +0 -0
  46. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_ratelimit_headers.py +0 -0
  47. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_request_hash.py +0 -0
  48. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_scores.py +0 -0
  49. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_serialize.py +0 -0
  50. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_span.py +0 -0
  51. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_templates.py +0 -0
  52. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_tiers.py +0 -0
  53. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_trace_state.py +0 -0
  54. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/_usage.py +0 -0
  55. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/__init__.py +0 -0
  56. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_common.py +0 -0
  57. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_openai_batch.py +0 -0
  58. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_openai_response.py +0 -0
  59. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_openai_stream.py +0 -0
  60. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_provider_errors.py +0 -0
  61. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_streams.py +0 -0
  62. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/_wrappers.py +0 -0
  63. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/integrations/anthropic.py +0 -0
  64. {spanlight-0.2.1 → spanlight-0.3.0}/src/spanlight/py.typed +0 -0
  65. {spanlight-0.2.1 → spanlight-0.3.0}/tests/fixtures/template_cases.json +0 -0
  66. {spanlight-0.2.1 → spanlight-0.3.0}/tests/provider_mocks.py +0 -0
  67. {spanlight-0.2.1 → spanlight-0.3.0}/tests/support.py +0 -0
  68. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_anthropic.py +0 -0
  69. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_anthropic_cut_stream.py +0 -0
  70. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_experiments_report.py +0 -0
  71. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_exporter_counters.py +0 -0
  72. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_gateway_refusal.py +0 -0
  73. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_metadata.py +0 -0
  74. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_openai.py +0 -0
  75. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_openai_batch_output.py +0 -0
  76. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_openai_tier_and_search.py +0 -0
  77. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_package.py +0 -0
  78. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_provider_errors.py +0 -0
  79. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_ratelimit_allowlist.py +0 -0
  80. {spanlight-0.2.1 → spanlight-0.3.0}/tests/test_templates_surrogates.py +0 -0
  81. {spanlight-0.2.1 → 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.2.1
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
- - 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.
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
- - 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.
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`,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spanlight"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Python SDK for Spanlight, open-source observability for LLM applications."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
@@ -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, urlsplit
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
- parts = urlsplit(host)
43
- if parts.scheme == "https" and parts.hostname:
44
- return
45
- if parts.scheme == "http" and parts.hostname in _LOCAL_HOSTS:
46
- return
47
- raise ExperimentError(
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 any single wait, in seconds.
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__(
@@ -146,25 +146,30 @@ class Delivery:
146
146
  body = gzip.compress(body)
147
147
 
148
148
  for attempt in range(self._max_retries + 1):
149
+ if self._abort_retries.is_set():
150
+ self.failed += len(batch)
151
+ return
149
152
  retry_after: float | None = None
150
153
  try:
151
154
  response = self._http_client().post(target.url, content=body, headers=self._headers)
152
155
  except httpx.HTTPError as exc:
153
- failure = f"{type(exc).__name__}: {exc}"
156
+ failure = type(exc).__name__
154
157
  else:
155
158
  status = response.status_code
156
159
  if 200 <= status < 300:
157
- self._handle_success(target, response, batch)
158
- return
159
- if status == 413 and len(batch) > 1:
160
+ if self._handle_success(target, response, batch):
161
+ return
162
+ failure = "ambiguous acknowledgment"
163
+ elif status == 413 and len(batch) > 1:
160
164
  self._split_and_send(target, batch, client)
161
165
  return
162
- if not _is_retryable(status):
166
+ elif not _is_retryable(status):
163
167
  self.failed += len(batch)
164
168
  self._log_rejected_batch(target, response, len(batch))
165
169
  return
166
- failure = f"HTTP {status}"
167
- retry_after = parse_retry_after(response.headers.get("Retry-After"))
170
+ else:
171
+ failure = f"HTTP {status}"
172
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
168
173
 
169
174
  if attempt >= self._max_retries or self._abort_retries.is_set():
170
175
  self.failed += len(batch)
@@ -228,20 +233,35 @@ class Delivery:
228
233
 
229
234
  def _handle_success(
230
235
  self, target: _Target, response: httpx.Response, batch: list[SpanPayload]
231
- ) -> None:
236
+ ) -> bool:
232
237
  batch_size = len(batch)
233
238
  try:
234
239
  result: Any = response.json()
235
240
  except ValueError:
236
- self.sent += batch_size
237
- return
238
- rejected = result.get("rejected") if isinstance(result, dict) else None
239
- if not isinstance(rejected, list) or not rejected:
240
- self.sent += batch_size
241
- return
242
-
243
- self.sent += batch_size - len(rejected)
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
244
262
  self.failed += len(rejected)
263
+ if not rejected:
264
+ return True
245
265
  logger.warning(
246
266
  "Spanlight rejected %d of %d %s",
247
267
  len(rejected),
@@ -260,21 +280,26 @@ class Delivery:
260
280
  item.get("reason"),
261
281
  )
262
282
 
283
+ return True
284
+
263
285
  def retry_delay(self, attempt: int, retry_after: float | None) -> float:
264
286
  """Return seconds to wait before retry number ``attempt + 1``.
265
287
 
266
- ``Retry-After`` wins when present. Otherwise use "equal jitter"
288
+ ``Retry-After`` sets a minimum when present. Use "equal jitter"
267
289
  exponential backoff: half of the exponential step is fixed and the
268
290
  other half random, which spreads retries out without ever retrying
269
291
  immediately.
270
292
  """
271
- if retry_after is not None:
272
- return min(max(retry_after, 0.0), self._max_backoff)
273
- step = min(self._max_backoff, self._base_backoff * (2.0**attempt))
274
- 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)
275
295
 
276
296
  def _interruptible_sleep(self, seconds: float) -> None:
277
- self._abort_retries.wait(seconds)
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
278
303
 
279
304
 
280
305
  def _is_retryable(status: int) -> bool:
@@ -286,6 +311,10 @@ def parse_retry_after(value: str | None) -> float | None:
286
311
  if value is None:
287
312
  return None
288
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
289
318
  try:
290
319
  seconds = float(value)
291
320
  except ValueError:
@@ -297,7 +326,11 @@ def parse_retry_after(value: str | None) -> float | None:
297
326
  moment = email.utils.parsedate_to_datetime(value)
298
327
  except (TypeError, ValueError):
299
328
  return None
300
- return max(moment.timestamp() - time.time(), 0.0)
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
301
334
 
302
335
 
303
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)
@@ -0,0 +1,122 @@
1
+ """W3C Trace Context propagation across processes: MCP ``_meta``, HTTP headers, queues.
2
+
3
+ :func:`inject` writes the current span as a ``traceparent`` into a carrier dict, and
4
+ :func:`continue_trace` makes the span a ``traceparent`` names the parent of the spans opened
5
+ inside its block. ``tracestate`` and ``baggage`` are left as they are. The W3C sampled flag
6
+ is not honoured: the SDK records every span.
7
+
8
+ Only continue traces from callers you trust: a caller chooses the trace id, so it can attach
9
+ your spans to another trace of the same project.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ from collections.abc import Mapping
17
+ from contextvars import Token
18
+ from types import TracebackType
19
+ from typing import Any
20
+
21
+ from ._context import get_current_span, restore_current_span, set_current_span
22
+ from ._span import Span
23
+ from ._trace_state import TraceState
24
+
25
+ logger = logging.getLogger("spanlight")
26
+
27
+ _TRACEPARENT = re.compile(r"([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}")
28
+
29
+
30
+ def inject(carrier: Mapping[str, Any] | None = None) -> dict[str, Any]:
31
+ """Return a copy of ``carrier`` with the current span as its ``traceparent``.
32
+
33
+ A ``traceparent`` already in the carrier is kept. Outside a recording span the copy is
34
+ returned unchanged.
35
+
36
+ Example:
37
+ >>> params["_meta"] = spanlight.inject(params.get("_meta")) # doctest: +SKIP
38
+ """
39
+ result = dict(carrier or {})
40
+ span = get_current_span()
41
+ if span is not None and span.recording and "traceparent" not in result:
42
+ result["traceparent"] = f"00-{span.trace_id}-{span.span_id}-01"
43
+ return result
44
+
45
+
46
+ def _parse(value: object) -> tuple[str, str] | None:
47
+ """``(trace_id, parent_span_id)`` of a valid W3C ``traceparent``, else ``None``."""
48
+ if not isinstance(value, str):
49
+ return None
50
+ match = _TRACEPARENT.match(value)
51
+ if match is None:
52
+ return None
53
+ version, trace_id, span_id = match.groups()
54
+ # Version 00 is exactly 55 characters; a later version may add fields after a dash.
55
+ exact = len(value) == 55
56
+ if version == "ff" or not (exact or (version != "00" and value[55] == "-")):
57
+ return None
58
+ if not trace_id.strip("0") or not span_id.strip("0"):
59
+ return None
60
+ return trace_id, span_id
61
+
62
+
63
+ class continue_trace: # lowercase: used like a function, as `contextlib` helpers are
64
+ """Continue the trace named by ``carrier["traceparent"]`` inside a ``with`` block.
65
+
66
+ Spans opened inside the block become children of the remote span. A missing or invalid
67
+ ``traceparent`` is ignored, so the next span starts a new trace. Works with ``with`` and
68
+ ``async with``. The carrier is anything with ``.get`` (a dict, request headers) or an object
69
+ with a ``traceparent`` attribute (the MCP Python SDK's ``request.params.meta``).
70
+
71
+ The remote parent is a placeholder that is never sent; :func:`get_current_span` returns it
72
+ inside the block, and setters called on it are lost.
73
+ """
74
+
75
+ def __init__(self, carrier: Mapping[str, Any] | object | None) -> None:
76
+ try:
77
+ # `.get` covers dicts and framework header objects that are not a `Mapping`.
78
+ get = getattr(carrier, "get", None)
79
+ value = get("traceparent") if callable(get) else getattr(carrier, "traceparent", None)
80
+ self._ids = _parse(value)
81
+ except Exception:
82
+ logger.debug("continue_trace() could not read the carrier", exc_info=True)
83
+ self._ids = None
84
+ self._token: Token[Span | None] | None = None
85
+ self._previous: Span | None = None
86
+
87
+ def __enter__(self) -> None:
88
+ if self._ids is None:
89
+ return
90
+ trace_id, span_id = self._ids
91
+ # No name, environment or release: the caller's trace fields must win on the server.
92
+ placeholder = Span(
93
+ name="remote parent",
94
+ kind="other",
95
+ trace=TraceState(trace_id=trace_id),
96
+ parent_span_id=None,
97
+ on_end=None,
98
+ span_id=span_id,
99
+ )
100
+ self._previous = get_current_span()
101
+ self._token = set_current_span(placeholder)
102
+
103
+ def __exit__(
104
+ self,
105
+ exc_type: type[BaseException] | None,
106
+ exc: BaseException | None,
107
+ traceback: TracebackType | None,
108
+ ) -> None:
109
+ if self._token is not None:
110
+ restore_current_span(self._token, self._previous)
111
+ self._token = None
112
+
113
+ async def __aenter__(self) -> None:
114
+ self.__enter__()
115
+
116
+ async def __aexit__(
117
+ self,
118
+ exc_type: type[BaseException] | None,
119
+ exc: BaseException | None,
120
+ traceback: TracebackType | None,
121
+ ) -> None:
122
+ self.__exit__(exc_type, exc, traceback)
@@ -1,3 +1,3 @@
1
1
  """Package version, kept in one place."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"