telemetry-dev 0.2.12__tar.gz → 0.2.13__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.2.13}/PKG-INFO +12 -7
  2. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/README.md +11 -6
  3. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_prompts.py +68 -7
  6. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/LICENSE +0 -0
  7. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/__init__.py +0 -0
  8. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_capture.py +0 -0
  9. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_client.py +0 -0
  10. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_config.py +0 -0
  11. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_context.py +0 -0
  12. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_export.py +0 -0
  13. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_logs.py +0 -0
  14. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_metrics.py +0 -0
  15. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_spans.py +0 -0
  20. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/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.2.13
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
@@ -247,8 +247,10 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
247
247
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
248
248
  example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
249
249
  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.
250
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
251
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
252
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
253
+ without its prompt.
252
254
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
253
255
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
254
256
  telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
@@ -264,10 +266,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
264
266
  it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
265
267
  object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
266
268
  `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
269
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
270
+ ran in it, or before it started in the code that started it; with none, it records the last
271
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
272
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
273
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
274
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
275
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
271
276
  model call outside `with_prompt` are not recorded.
272
277
 
273
278
  ## Auto-metrics
@@ -222,8 +222,10 @@ pinned = telemetry_dev.get_prompt("support/reply", version=13)
222
222
  `timeout`. A response with a different status and no API error gives `http_<status>`, for
223
223
  example `http_302` for a redirect. `get_prompt` does not send the request to the location of a
224
224
  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.
225
+ seconds (or the TTL, if it is shorter) get the same result without a fetch. After a 429 or 503
226
+ response with a `Retry-After` header, `get_prompt` does not fetch that prompt again before that
227
+ time (one hour at most). This is the one SDK function that can raise: your app cannot run
228
+ without its prompt.
227
229
  - `get_prompt` uses the API key and base URL of an enabled `init()`, or `TELEMETRY_DEV_API_KEY`
228
230
  and `TELEMETRY_DEV_BASE_URL`, or its own `api_key` and `base_url`. It reads the same host that
229
231
  telemetry goes to, `https://ingest.telemetry.dev` by default. The key needs the read scope for
@@ -239,10 +241,13 @@ When input capture is on, `td.prompt.variables` holds the JSON of the variables
239
241
  it, after `mask` and truncation. If threads or tasks that run at the same time share one prompt
240
242
  object, call `compile()` in `with_prompt`, or call `get_prompt` in each thread or task.
241
243
  `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
244
+ of a different thread. Inside one `with_prompt`, an asyncio task records the last `compile()` that
245
+ ran in it, or before it started in the code that started it; with none, it records the last
246
+ `compile()` in the block. A `compile()` in a worker thread, as with `asyncio.to_thread`, counts only
247
+ for code that has not compiled in its own task. Model calls are the spans of type `generation`
248
+ (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
249
+ integrations: OpenAI, Anthropic, Amazon Bedrock, Google GenAI, LiteLLM, and OpenRouter. Spans from
250
+ other OpenTelemetry tracers do not get the attributes. Messages from `compile()` that you pass to a
246
251
  model call outside `with_prompt` are not recorded.
247
252
 
248
253
  ## Auto-metrics
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.12"
3
+ version = "0.2.13"
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.2.13"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
@@ -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
@@ -347,14 +367,22 @@ _compiled_variables: WeakKeyDictionary[ChatPrompt | TextPrompt, dict[str, Any]]
347
367
  )
348
368
 
349
369
 
350
- @dataclass
370
+ @dataclass(eq=False)
351
371
  class _PromptScope:
352
- """The prompt of a ``with_prompt`` context and the variables that its spans record."""
372
+ """The prompt of a ``with_prompt`` context and the variables of the last ``compile()`` in it.
373
+ Concurrent tasks in the context share the scope; see ``_task_variables``."""
353
374
 
354
375
  prompt: Prompt
355
376
  variables: dict[str, Any] | None
356
377
 
357
378
 
379
+ # The variables of the last compile() of a scope in the current task. A new asyncio task or a
380
+ # copied context gets a copy, so its compile does not change the value of other tasks.
381
+ _task_variables: ContextVar[tuple[_PromptScope, dict[str, Any]] | None] = ContextVar(
382
+ "telemetry_dev_prompt_variables", default=None
383
+ )
384
+
385
+
358
386
  def _record(
359
387
  prompt: Prompt, variables: Mapping[str, Any] | None, kwargs: Mapping[str, Any]
360
388
  ) -> dict[str, Any]:
@@ -363,6 +391,7 @@ def _record(
363
391
  scope = otel_context.get_value(_PROMPT_KEY)
364
392
  if isinstance(scope, _PromptScope) and scope.prompt is prompt:
365
393
  scope.variables = values
394
+ _task_variables.set((scope, values))
366
395
  return values
367
396
 
368
397
 
@@ -413,6 +442,8 @@ class _Entry:
413
442
  error: Exception | None = None
414
443
  # When the last fetch ended.
415
444
  checked_at: float = 0.0
445
+ # No fetch before this time: the Retry-After of the last 429 or 503 response.
446
+ retry_at: float = 0.0
416
447
  # Set when the running fetch ends.
417
448
  pending: threading.Event | None = None
418
449
  # When calls stop waiting for the running fetch.
@@ -442,7 +473,7 @@ def _load(
442
473
  entry = _cache[key] = _Entry()
443
474
  now = monotonic()
444
475
  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:
476
+ if now >= max(entry.checked_at + ttl, entry.retry_at) and entry.pending is None:
446
477
  entry.pending = threading.Event()
447
478
  entry.deadline = now + _FETCH_TIMEOUT_S
448
479
  threading.Thread(
@@ -478,6 +509,12 @@ def _refresh(
478
509
  error = exc
479
510
  with _cache_lock:
480
511
  stale = entry.response
512
+ delay = (
513
+ error._retry_after # pyright: ignore[reportPrivateUsage]
514
+ if isinstance(error, PromptError)
515
+ else None
516
+ )
517
+ entry.retry_at = 0.0 if delay is None else monotonic() + delay
481
518
  if response is not None:
482
519
  entry.response = response
483
520
  elif stale is None:
@@ -515,7 +552,11 @@ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
515
552
  body: bytes = response.read()
516
553
  except urllib.error.HTTPError as exc:
517
554
  with exc:
518
- raise _api_error(exc.code, exc.read()) from None
555
+ error = _api_error(exc.code, exc.read())
556
+ if exc.code in (429, 503):
557
+ retry_after = _retry_after(exc.headers.get("Retry-After"))
558
+ error._retry_after = retry_after # pyright: ignore[reportPrivateUsage]
559
+ raise error from None
519
560
  except TimeoutError as exc:
520
561
  raise PromptError(
521
562
  "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
@@ -550,6 +591,25 @@ def _api_error(status: int, body: bytes) -> PromptError:
550
591
  )
551
592
 
552
593
 
594
+ def _retry_after(value: str | None) -> float | None:
595
+ """Seconds to wait from a Retry-After header of seconds or an IMF-fixdate HTTP date
596
+ (``Sun, 06 Nov 1994 08:49:37 GMT``), capped."""
597
+ value = (value or "").strip()
598
+ if not value:
599
+ return None
600
+ if value.isascii() and value.isdigit():
601
+ seconds = float(value)
602
+ elif _HTTP_DATE.fullmatch(value):
603
+ try:
604
+ date = parsedate_to_datetime(value)
605
+ except (TypeError, ValueError):
606
+ return None
607
+ seconds = (date - datetime.now(timezone.utc)).total_seconds()
608
+ else:
609
+ return None
610
+ return min(seconds, _MAX_RETRY_AFTER_S) if seconds > 0 else None
611
+
612
+
553
613
  def _parse_response(data: object) -> dict[str, Any] | None:
554
614
  if not isinstance(data, dict):
555
615
  return None
@@ -559,6 +619,7 @@ def _parse_response(data: object) -> dict[str, Any] | None:
559
619
  if (
560
620
  not isinstance(body.get("name"), str)
561
621
  or prompt_type not in ("chat", "text")
622
+ or "label" not in body
562
623
  or not (label is None or isinstance(label, str))
563
624
  or not _is_version(body.get("version"))
564
625
  or not _valid_content(body, prompt_type)
File without changes