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.
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/PKG-INFO +12 -7
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/README.md +11 -6
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/pyproject.toml +1 -1
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/pyproject.toml.orig +1 -1
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_prompts.py +68 -7
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/LICENSE +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_capture.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_client.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_config.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_context.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_export.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_logs.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_metrics.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_semconv.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_serialize.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/_spans.py +0 -0
- {telemetry_dev-0.2.12 → telemetry_dev-0.2.13}/src/telemetry_dev/otel.py +0 -0
- {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.
|
|
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.
|
|
251
|
-
|
|
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.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
226
|
-
|
|
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.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
@@ -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
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|