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.
Files changed (82) hide show
  1. {spanlight-0.2.0 → spanlight-0.3.0}/PKG-INFO +68 -4
  2. {spanlight-0.2.0 → spanlight-0.3.0}/README.md +67 -3
  3. {spanlight-0.2.0 → spanlight-0.3.0}/pyproject.toml +1 -1
  4. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/__init__.py +3 -0
  5. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_client.py +10 -1
  6. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_config.py +2 -2
  7. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_decorator.py +15 -1
  8. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_api.py +8 -11
  9. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_exporter.py +4 -1
  10. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_exporter_delivery.py +81 -26
  11. spanlight-0.3.0/src/spanlight/_hosts.py +61 -0
  12. spanlight-0.3.0/src/spanlight/_propagation.py +122 -0
  13. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_serialize.py +31 -17
  14. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_span.py +32 -13
  15. spanlight-0.3.0/src/spanlight/_trace_state.py +131 -0
  16. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_usage.py +36 -13
  17. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_version.py +1 -1
  18. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_batch.py +6 -1
  19. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_message.py +13 -0
  20. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_anthropic_stream.py +4 -3
  21. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_batch.py +4 -0
  22. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_common.py +26 -14
  23. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_modifiers.py +18 -2
  24. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/anthropic.py +26 -8
  25. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/openai.py +36 -9
  26. {spanlight-0.2.0 → spanlight-0.3.0}/tests/conftest.py +2 -2
  27. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic.py +21 -0
  28. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic_batch_results.py +99 -0
  29. spanlight-0.3.0/tests/test_config.py +105 -0
  30. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_decorator.py +20 -1
  31. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_docs_snippets.py +4 -2
  32. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter.py +16 -3
  33. spanlight-0.3.0/tests/test_exporter_delivery_http.py +247 -0
  34. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter_retry_after.py +13 -0
  35. spanlight-0.3.0/tests/test_prompt_compilation.py +93 -0
  36. spanlight-0.3.0/tests/test_propagation.py +38 -0
  37. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_spans_and_context.py +68 -0
  38. {spanlight-0.2.0 → spanlight-0.3.0}/uv.lock +4 -4
  39. spanlight-0.2.0/src/spanlight/_trace_state.py +0 -83
  40. {spanlight-0.2.0 → spanlight-0.3.0}/.gitignore +0 -0
  41. {spanlight-0.2.0 → spanlight-0.3.0}/LICENSE +0 -0
  42. {spanlight-0.2.0 → spanlight-0.3.0}/examples/support_bot.py +0 -0
  43. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_context.py +0 -0
  44. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments.py +0 -0
  45. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_parse.py +0 -0
  46. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_report.py +0 -0
  47. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_runs.py +0 -0
  48. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_experiments_setup.py +0 -0
  49. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_globals.py +0 -0
  50. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_ids.py +0 -0
  51. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_metadata.py +0 -0
  52. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_prompt_client.py +0 -0
  53. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_prompts.py +0 -0
  54. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_ratelimit_headers.py +0 -0
  55. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_request_hash.py +0 -0
  56. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_scores.py +0 -0
  57. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_templates.py +0 -0
  58. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/_tiers.py +0 -0
  59. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/__init__.py +0 -0
  60. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_batch.py +0 -0
  61. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_response.py +0 -0
  62. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_openai_stream.py +0 -0
  63. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_provider_errors.py +0 -0
  64. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_streams.py +0 -0
  65. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/integrations/_wrappers.py +0 -0
  66. {spanlight-0.2.0 → spanlight-0.3.0}/src/spanlight/py.typed +0 -0
  67. {spanlight-0.2.0 → spanlight-0.3.0}/tests/fixtures/template_cases.json +0 -0
  68. {spanlight-0.2.0 → spanlight-0.3.0}/tests/provider_mocks.py +0 -0
  69. {spanlight-0.2.0 → spanlight-0.3.0}/tests/support.py +0 -0
  70. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_anthropic_cut_stream.py +0 -0
  71. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_experiments_report.py +0 -0
  72. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_exporter_counters.py +0 -0
  73. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_gateway_refusal.py +0 -0
  74. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_metadata.py +0 -0
  75. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai.py +0 -0
  76. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai_batch_output.py +0 -0
  77. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_openai_tier_and_search.py +0 -0
  78. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_package.py +0 -0
  79. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_provider_errors.py +0 -0
  80. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_ratelimit_allowlist.py +0 -0
  81. {spanlight-0.2.0 → spanlight-0.3.0}/tests/test_templates_surrogates.py +0 -0
  82. {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.2.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
- - 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.0"
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
@@ -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
- _finish(span, error=exc)
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, 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__(
@@ -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
- envelope: dict[str, Any] = {target.body_key: batch}
141
+ fields = [f'"{target.body_key}":[{",".join(encoded)}]']
141
142
  if client is not None and target.body_key == SPANS:
142
- envelope["client"] = dict(client)
143
- body = json.dumps(envelope, separators=(",", ":"), default=str).encode()
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 = f"{type(exc).__name__}: {exc}"
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
- return
158
- 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:
159
164
  self._split_and_send(target, batch, client)
160
165
  return
161
- if not _is_retryable(status):
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
- failure = f"HTTP {status}"
166
- 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"))
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
- ) -> None:
236
+ ) -> bool:
210
237
  batch_size = len(batch)
211
238
  try:
212
239
  result: Any = response.json()
213
240
  except ValueError:
214
- self.sent += batch_size
215
- return
216
- rejected = result.get("rejected") if isinstance(result, dict) else None
217
- if not isinstance(rejected, list) or not rejected:
218
- self.sent += batch_size
219
- return
220
-
221
- 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
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`` wins when present. Otherwise use "equal jitter"
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
- if retry_after is not None:
250
- return min(max(retry_after, 0.0), self._max_backoff)
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
- 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
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
- 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
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)