telemetry-dev 0.2.12__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 (21) hide show
  1. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/PKG-INFO +21 -12
  2. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/README.md +20 -11
  3. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_config.py +1 -1
  6. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_prompts.py +76 -9
  7. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/LICENSE +0 -0
  8. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/__init__.py +0 -0
  9. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_capture.py +0 -0
  10. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_client.py +0 -0
  11. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_context.py +0 -0
  12. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_export.py +0 -0
  13. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_logs.py +0 -0
  14. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_metrics.py +0 -0
  15. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/_spans.py +0 -0
  20. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.2.12 → telemetry_dev-0.3.0}/src/telemetry_dev/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.2.12
3
+ Version: 0.3.0
4
4
  Summary: telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics
5
5
  Keywords: telemetry,opentelemetry,llm,genai,tracing,observability
6
6
  Author: telemetry.dev
@@ -81,7 +81,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
81
81
  | Variable | Default | Purpose |
82
82
  | --- | --- | --- |
83
83
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
84
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
84
+ | `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
85
85
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
86
86
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
87
87
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -89,6 +89,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
89
89
 
90
90
  Explicit `init()` arguments take precedence over environment variables.
91
91
 
92
+ Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
93
+ same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
94
+ `base_url` to it.
95
+
92
96
  Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
93
97
  otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
94
98
  initialization. Explicit and propagated session IDs take precedence, and valid parent trace
@@ -247,11 +251,13 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
247
251
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
248
252
  example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
249
253
  redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
250
- seconds (or the TTL, if it is shorter) get the same result without a fetch. This is the one SDK
251
- function that can raise: your app cannot run without its prompt.
254
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
255
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
256
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
257
+ without its prompt.
252
258
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
253
259
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
254
- telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
260
+ telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
255
261
  prompts.
256
262
  - When a label sends part of the traffic to a second version, each session keeps one version: the
257
263
  choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
@@ -264,10 +270,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
264
270
  it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
265
271
  object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
266
272
  `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
267
- of a different thread. Model calls are the spans of type `generation` (operations `chat`,
268
- `text_completion`, and `generate_content`), including the spans of the provider integrations:
269
- OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from other
270
- OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
273
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
274
+ ran in it, or before it started in the code that started it; with none, it records the last
275
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
276
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
277
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
278
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
279
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
271
280
  model call outside `with_prompt` are not recorded.
272
281
 
273
282
  ## Auto-metrics
@@ -326,7 +335,7 @@ from telemetry_dev import TelemetrySpanProcessor
326
335
  | Option | Default | Purpose |
327
336
  | --- | --- | --- |
328
337
  | `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
329
- | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://ingest.telemetry.dev` | Ingest base URL; trailing slashes are stripped. |
338
+ | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
330
339
  | `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
331
340
  | `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
332
341
  | `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
@@ -394,7 +403,7 @@ from opentelemetry.sdk.trace import TracerProvider
394
403
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
395
404
 
396
405
  exporter = OTLPSpanExporter(
397
- endpoint="https://ingest.telemetry.dev/v1/traces",
406
+ endpoint="https://api.telemetry.dev/v1/traces",
398
407
  headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
399
408
  )
400
409
  provider = TracerProvider()
@@ -402,7 +411,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
402
411
  trace.set_tracer_provider(provider)
403
412
  ```
404
413
 
405
- The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
414
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
406
415
 
407
416
  When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
408
417
 
@@ -56,7 +56,7 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
56
56
  | Variable | Default | Purpose |
57
57
  | --- | --- | --- |
58
58
  | `TELEMETRY_DEV_API_KEY` | — | Ingest key (`td_live_...`). Absent = SDK is a no-op. |
59
- | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
59
+ | `TELEMETRY_DEV_BASE_URL` | `https://api.telemetry.dev` | Base URL for telemetry export and `get_prompt` (trailing slashes stripped). |
60
60
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
61
61
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
62
62
  | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
@@ -64,6 +64,10 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
64
64
 
65
65
  Explicit `init()` arguments take precedence over environment variables.
66
66
 
67
+ Earlier versions sent data to `https://ingest.telemetry.dev` by default. That hostname reaches the
68
+ same service and stays supported: if a firewall allows only it, set `TELEMETRY_DEV_BASE_URL` or
69
+ `base_url` to it.
70
+
67
71
  Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
68
72
  otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
69
73
  initialization. Explicit and propagated session IDs take precedence, and valid parent trace
@@ -222,11 +226,13 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
222
226
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
223
227
  example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
224
228
  redirect, thus your key does not go to a different host. With no copy, the calls of the next 10
225
- seconds (or the TTL, if it is shorter) get the same result without a fetch. This is the one SDK
226
- function that can raise: your app cannot run without its prompt.
229
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
230
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
231
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
232
+ without its prompt.
227
233
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
228
234
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
229
- telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
235
+ telemetry goes to, `https://api.telemetry.dev` by default. The key needs the read scope for
230
236
  prompts.
231
237
  - When a label sends part of the traffic to a second version, each session keeps one version: the
232
238
  choice uses the session id of `propagate_attributes` (or of `session_mode="process"`). Without a
@@ -239,10 +245,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
239
245
  it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
240
246
  object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
241
247
  `get_prompt` returns a new object from the cache. If you do not, a thread can record the variables
242
- of a different thread. Model calls are the spans of type `generation` (operations `chat`,
243
- `text_completion`, and `generate_content`), including the spans of the provider integrations:
244
- OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from other
245
- OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
248
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
249
+ ran in it, or before it started in the code that started it; with none, it records the last
250
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
251
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
252
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
253
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
254
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
246
255
  model call outside `with_prompt` are not recorded.
247
256
 
248
257
  ## Auto-metrics
@@ -301,7 +310,7 @@ from telemetry_dev import TelemetrySpanProcessor
301
310
  | Option | Default | Purpose |
302
311
  | --- | --- | --- |
303
312
  | `api_key` | `TELEMETRY_DEV_API_KEY` | Ingest key. Absent with no `span_exporter` = inert no-op. |
304
- | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://ingest.telemetry.dev` | Ingest base URL; trailing slashes are stripped. |
313
+ | `base_url` | `TELEMETRY_DEV_BASE_URL` or `https://api.telemetry.dev` | Base URL; trailing slashes are stripped. |
305
314
  | `export_mode` | `"batched"` | `"batched"` or `"immediate"` span export. |
306
315
  | `max_export_batch_size` | `64` | BatchSpanProcessor export batch size. |
307
316
  | `schedule_delay_millis` | `1000` | BatchSpanProcessor schedule delay. |
@@ -369,7 +378,7 @@ from opentelemetry.sdk.trace import TracerProvider
369
378
  from opentelemetry.sdk.trace.export import BatchSpanProcessor
370
379
 
371
380
  exporter = OTLPSpanExporter(
372
- endpoint="https://ingest.telemetry.dev/v1/traces",
381
+ endpoint="https://api.telemetry.dev/v1/traces",
373
382
  headers={"Authorization": f"Bearer {os.environ['TELEMETRY_DEV_API_KEY']}"},
374
383
  )
375
384
  provider = TracerProvider()
@@ -377,7 +386,7 @@ provider.add_span_processor(BatchSpanProcessor(exporter))
377
386
  trace.set_tracer_provider(provider)
378
387
  ```
379
388
 
380
- The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://ingest.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
389
+ The Claude API can also send server-side spans to telemetry.dev through a Console destination. Set the endpoint to `https://api.telemetry.dev`. Set the `Authorization` header to `Bearer <telemetry.dev API key>`. For more information, refer to [Claude Platform](https://telemetry.dev/docs/integrations/claude-platform).
381
390
 
382
391
  When the SDK and the Claude API send the spans of a request to the same telemetry.dev project, telemetry.dev counts the tokens of the request one time, from the API span. The SDK span stays in the trace with no tokens. telemetry.dev matches the two spans by the request ID or the response ID, so the API span does not have to be a child of the SDK span. If the Console destination uses an API key that is scoped to one environment, its API spans take over only the SDK spans of that environment.
383
392
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.12"
3
+ version = "0.3.0"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.12"
3
+ version = "0.3.0"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,7 +8,7 @@ from collections.abc import Callable
8
8
  from dataclasses import dataclass
9
9
  from typing import Literal
10
10
 
11
- DEFAULT_BASE_URL = "https://ingest.telemetry.dev"
11
+ DEFAULT_BASE_URL = "https://api.telemetry.dev"
12
12
 
13
13
 
14
14
  def _resolve_version() -> str:
@@ -13,8 +13,11 @@ import urllib.request
13
13
  from collections import OrderedDict
14
14
  from collections.abc import Callable, Generator, Mapping
15
15
  from contextlib import contextmanager
16
+ from contextvars import ContextVar
16
17
  from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
17
19
  from email.message import Message
20
+ from email.utils import parsedate_to_datetime
18
21
  from time import monotonic
19
22
  from typing import IO, Any, Literal, TypeAlias, TypedDict, cast, overload
20
23
  from weakref import WeakKeyDictionary
@@ -23,7 +26,7 @@ from opentelemetry import context as otel_context
23
26
  from opentelemetry.context import Context
24
27
 
25
28
  from ._client import Client, get_client
26
- from ._config import report_error, resolve_config
29
+ from ._config import SDK_VERSION, report_error, resolve_config
27
30
  from ._context import propagated_attributes
28
31
  from ._semconv import (
29
32
  ATTR_PROMPT_LABEL,
@@ -37,7 +40,11 @@ from ._serialize import AttributeValue, json_dumps
37
40
  _FETCH_TIMEOUT_S = 10.0
38
41
  # How long a failed fetch with no copy answers calls, so that they do not each fetch again.
39
42
  _ERROR_TTL_S = 10.0
43
+ # The longest Retry-After that get_prompt honors, so that a bad header cannot stop fetches.
44
+ _MAX_RETRY_AFTER_S = 3600.0
40
45
  _MAX_CACHE_ENTRIES = 1000
46
+ # IMF-fixdate, the HTTP date that servers send: Sun, 06 Nov 1994 08:49:37 GMT.
47
+ _HTTP_DATE = re.compile(r"[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT", re.ASCII)
41
48
  _VARIABLE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
42
49
  _MODEL_CALL_OPERATIONS = frozenset({"chat", "text_completion", "generate_content"})
43
50
  _PROMPT_KEY = otel_context.create_key("telemetry_dev.prompt")
@@ -61,6 +68,8 @@ class PromptError(Exception):
61
68
  self.code = code
62
69
  self.message = message
63
70
  self.status = status
71
+ # The Retry-After of a 429 or 503 response, in seconds.
72
+ self._retry_after: float | None = None
64
73
 
65
74
 
66
75
  @dataclass(frozen=True, eq=False)
@@ -243,12 +252,21 @@ def with_prompt(prompt: Prompt) -> Generator[None]:
243
252
  ``td.prompt.name``, ``td.prompt.version``, ``td.prompt.label``, and, when input capture is on,
244
253
  ``td.prompt.variables``. The variables are those of the last ``compile()`` of this prompt
245
254
  object before the context starts, or of the last ``compile()`` inside it, so a compile in
246
- another thread or task does not change them once the context runs."""
255
+ another thread or task does not change them once the context runs. When concurrent tasks in
256
+ the context each compile, a span records the last compile of its own task; a compile in a
257
+ worker thread (``asyncio.to_thread``) counts only for a task that has not compiled."""
247
258
  scope = _PromptScope(prompt, _compiled_variables.get(prompt))
248
259
  token = otel_context.attach(otel_context.set_value(_PROMPT_KEY, scope))
260
+ task_token = _task_variables.set(_task_variables.get())
249
261
  try:
250
262
  yield
251
263
  finally:
264
+ try:
265
+ _task_variables.reset(task_token)
266
+ except ValueError:
267
+ # The block ended in another Context, as a generator closed elsewhere does. Keep
268
+ # going like otel_context.detach does, so that the detach below still runs.
269
+ pass
252
270
  otel_context.detach(token)
253
271
 
254
272
 
@@ -267,8 +285,10 @@ def prompt_attributes(
267
285
  attrs[ATTR_PROMPT_VERSION] = prompt.version
268
286
  if prompt.label is not None:
269
287
  attrs[ATTR_PROMPT_LABEL] = prompt.label
270
- if scope.variables is not None and capture_input:
271
- serialized = client.serialize(scope.variables, ATTR_PROMPT_VARIABLES)
288
+ task = _task_variables.get()
289
+ variables = task[1] if task is not None and task[0] is scope else scope.variables
290
+ if variables is not None and capture_input:
291
+ serialized = client.serialize(variables, ATTR_PROMPT_VARIABLES)
272
292
  if serialized is not None:
273
293
  attrs[ATTR_PROMPT_VARIABLES] = serialized
274
294
  return attrs
@@ -313,7 +333,13 @@ def _get_prompt(
313
333
  cache_ttl_seconds,
314
334
  lambda: _fetch(
315
335
  f"{url}/v1/prompts?{urllib.parse.urlencode(query)}",
316
- {"Authorization": f"Bearer {key}", "X-Telemetry-Dev-SDK": "telemetry-dev"},
336
+ {
337
+ "Authorization": f"Bearer {key}",
338
+ "X-Telemetry-Dev-SDK": "telemetry-dev",
339
+ # urllib's default User-Agent, Python-urllib/3.x, is refused by bot filters
340
+ # such as Cloudflare's Browser Integrity Check.
341
+ "User-Agent": f"telemetry-dev-python/{SDK_VERSION}",
342
+ },
317
343
  ),
318
344
  on_error,
319
345
  )
@@ -347,14 +373,22 @@ _compiled_variables: WeakKeyDictionary[ChatPrompt | TextPrompt, dict[str, Any]]
347
373
  )
348
374
 
349
375
 
350
- @dataclass
376
+ @dataclass(eq=False)
351
377
  class _PromptScope:
352
- """The prompt of a ``with_prompt`` context and the variables that its spans record."""
378
+ """The prompt of a ``with_prompt`` context and the variables of the last ``compile()`` in it.
379
+ Concurrent tasks in the context share the scope; see ``_task_variables``."""
353
380
 
354
381
  prompt: Prompt
355
382
  variables: dict[str, Any] | None
356
383
 
357
384
 
385
+ # The variables of the last compile() of a scope in the current task. A new asyncio task or a
386
+ # copied context gets a copy, so its compile does not change the value of other tasks.
387
+ _task_variables: ContextVar[tuple[_PromptScope, dict[str, Any]] | None] = ContextVar(
388
+ "telemetry_dev_prompt_variables", default=None
389
+ )
390
+
391
+
358
392
  def _record(
359
393
  prompt: Prompt, variables: Mapping[str, Any] | None, kwargs: Mapping[str, Any]
360
394
  ) -> dict[str, Any]:
@@ -363,6 +397,7 @@ def _record(
363
397
  scope = otel_context.get_value(_PROMPT_KEY)
364
398
  if isinstance(scope, _PromptScope) and scope.prompt is prompt:
365
399
  scope.variables = values
400
+ _task_variables.set((scope, values))
366
401
  return values
367
402
 
368
403
 
@@ -413,6 +448,8 @@ class _Entry:
413
448
  error: Exception | None = None
414
449
  # When the last fetch ended.
415
450
  checked_at: float = 0.0
451
+ # No fetch before this time: the Retry-After of the last 429 or 503 response.
452
+ retry_at: float = 0.0
416
453
  # Set when the running fetch ends.
417
454
  pending: threading.Event | None = None
418
455
  # When calls stop waiting for the running fetch.
@@ -442,7 +479,7 @@ def _load(
442
479
  entry = _cache[key] = _Entry()
443
480
  now = monotonic()
444
481
  ttl = ttl_seconds if entry.response is not None else min(ttl_seconds, _ERROR_TTL_S)
445
- if now >= entry.checked_at + ttl and entry.pending is None:
482
+ if now >= max(entry.checked_at + ttl, entry.retry_at) and entry.pending is None:
446
483
  entry.pending = threading.Event()
447
484
  entry.deadline = now + _FETCH_TIMEOUT_S
448
485
  threading.Thread(
@@ -478,6 +515,12 @@ def _refresh(
478
515
  error = exc
479
516
  with _cache_lock:
480
517
  stale = entry.response
518
+ delay = (
519
+ error._retry_after # pyright: ignore[reportPrivateUsage]
520
+ if isinstance(error, PromptError)
521
+ else None
522
+ )
523
+ entry.retry_at = 0.0 if delay is None else monotonic() + delay
481
524
  if response is not None:
482
525
  entry.response = response
483
526
  elif stale is None:
@@ -515,7 +558,11 @@ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
515
558
  body: bytes = response.read()
516
559
  except urllib.error.HTTPError as exc:
517
560
  with exc:
518
- raise _api_error(exc.code, exc.read()) from None
561
+ error = _api_error(exc.code, exc.read())
562
+ if exc.code in (429, 503):
563
+ retry_after = _retry_after(exc.headers.get("Retry-After"))
564
+ error._retry_after = retry_after # pyright: ignore[reportPrivateUsage]
565
+ raise error from None
519
566
  except TimeoutError as exc:
520
567
  raise PromptError(
521
568
  "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
@@ -550,6 +597,25 @@ def _api_error(status: int, body: bytes) -> PromptError:
550
597
  )
551
598
 
552
599
 
600
+ def _retry_after(value: str | None) -> float | None:
601
+ """Seconds to wait from a Retry-After header of seconds or an IMF-fixdate HTTP date
602
+ (``Sun, 06 Nov 1994 08:49:37 GMT``), capped."""
603
+ value = (value or "").strip()
604
+ if not value:
605
+ return None
606
+ if value.isascii() and value.isdigit():
607
+ seconds = float(value)
608
+ elif _HTTP_DATE.fullmatch(value):
609
+ try:
610
+ date = parsedate_to_datetime(value)
611
+ except (TypeError, ValueError):
612
+ return None
613
+ seconds = (date - datetime.now(timezone.utc)).total_seconds()
614
+ else:
615
+ return None
616
+ return min(seconds, _MAX_RETRY_AFTER_S) if seconds > 0 else None
617
+
618
+
553
619
  def _parse_response(data: object) -> dict[str, Any] | None:
554
620
  if not isinstance(data, dict):
555
621
  return None
@@ -559,6 +625,7 @@ def _parse_response(data: object) -> dict[str, Any] | None:
559
625
  if (
560
626
  not isinstance(body.get("name"), str)
561
627
  or prompt_type not in ("chat", "text")
628
+ or "label" not in body
562
629
  or not (label is None or isinstance(label, str))
563
630
  or not _is_version(body.get("version"))
564
631
  or not _valid_content(body, prompt_type)
File without changes