telemetry-dev 0.3.0__tar.gz → 0.3.1__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.3.0 → telemetry_dev-0.3.1}/PKG-INFO +4 -3
  2. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/README.md +3 -2
  3. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/pyproject.toml +1 -1
  4. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_prompts.py +143 -27
  6. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/LICENSE +0 -0
  7. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/__init__.py +0 -0
  8. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_capture.py +0 -0
  9. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_client.py +0 -0
  10. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_config.py +0 -0
  11. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_context.py +0 -0
  12. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_export.py +0 -0
  13. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_logs.py +0 -0
  14. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_metrics.py +0 -0
  15. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/_spans.py +0 -0
  20. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/src/telemetry_dev/otel.py +0 -0
  21. {telemetry_dev-0.3.0 → telemetry_dev-0.3.1}/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.3.0
3
+ Version: 0.3.1
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
@@ -276,8 +276,9 @@ ran in it, or before it started in the code that started it; with none, it recor
276
276
  for code that has not compiled in its own task. Model calls are the spans of type `generation`
277
277
  (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
278
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
280
- model call outside `with_prompt` are not recorded.
279
+ other OpenTelemetry tracers do not get the attributes. A model call outside `with_prompt` does not
280
+ get the attributes, even when it sends messages from `compile()`; with input capture on, the call
281
+ still records those messages as its input.
281
282
 
282
283
  ## Auto-metrics
283
284
 
@@ -251,8 +251,9 @@ ran in it, or before it started in the code that started it; with none, it recor
251
251
  for code that has not compiled in its own task. Model calls are the spans of type `generation`
252
252
  (operations `chat`, `text_completion`, and `generate_content`), including the spans of the provider
253
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
255
- model call outside `with_prompt` are not recorded.
254
+ other OpenTelemetry tracers do not get the attributes. A model call outside `with_prompt` does not
255
+ get the attributes, even when it sends messages from `compile()`; with input capture on, the call
256
+ still records those messages as its input.
256
257
 
257
258
  ## Auto-metrics
258
259
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.3.0"
3
+ version = "0.3.1"
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.3.0"
3
+ version = "0.3.1"
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,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import copy
5
6
  import hashlib
6
7
  import json
7
8
  import random
8
9
  import re
10
+ import socket
9
11
  import threading
10
12
  import urllib.error
11
13
  import urllib.parse
@@ -274,7 +276,8 @@ def prompt_attributes(
274
276
  client: Client, context: Context | None, operation: object, capture_input: bool
275
277
  ) -> dict[str, AttributeValue]:
276
278
  """The td.prompt.* attributes for a span with this operation that starts in ``context``."""
277
- if operation not in _MODEL_CALL_OPERATIONS:
279
+ # An attribute can be a list, which a frozenset cannot hold.
280
+ if not isinstance(operation, str) or operation not in _MODEL_CALL_OPERATIONS:
278
281
  return {}
279
282
  scope = otel_context.get_value(_PROMPT_KEY, context) or otel_context.get_value(_PROMPT_KEY)
280
283
  if not isinstance(scope, _PromptScope):
@@ -467,10 +470,9 @@ def _load(
467
470
  on_error: Callable[[BaseException], None] | None,
468
471
  ) -> dict[str, Any]:
469
472
  """Return the kept copy of a prompt, and fetch a new copy in a background thread when it is
470
- older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline: the
471
- timeout of urllib applies to each socket operation, not to the whole fetch. A failed fetch with
472
- no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is shorter) without a
473
- fetch."""
473
+ older than the TTL. Only a call with no copy waits for a fetch, until the fetch's deadline. A
474
+ failed fetch with no copy fails the calls of the next ``_ERROR_TTL_S`` (or the TTL, if it is
475
+ shorter) without a fetch."""
474
476
  with _cache_lock:
475
477
  entry = _cache.get(key)
476
478
  if entry is None:
@@ -489,7 +491,7 @@ def _load(
489
491
  return entry.response
490
492
  pending, deadline = entry.pending, entry.deadline
491
493
  if pending is not None and not pending.wait(deadline - monotonic()):
492
- raise PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
494
+ raise _timeout()
493
495
  with _cache_lock:
494
496
  response, error = entry.response, entry.error
495
497
  if response is not None:
@@ -548,31 +550,141 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
548
550
  return None
549
551
 
550
552
 
551
- _opener = urllib.request.build_opener(_NoRedirect)
553
+ class _Deadline:
554
+ """Ends one prompt fetch after ``_FETCH_TIMEOUT_S``. The timeout of urllib applies to each
555
+ socket operation, so a server that sends a byte now and then could keep a fetch reading forever,
556
+ and each address of a host gets the full timeout to connect. A connection attempt gets only the
557
+ time that is left, and at the deadline this shuts the connection down, which ends a blocked
558
+ read. It cannot stop an address lookup; the system resolver bounds that."""
559
+
560
+ def __init__(self) -> None:
561
+ self.expired = False
562
+ self._end = monotonic() + _FETCH_TIMEOUT_S
563
+ self._lock = threading.Lock()
564
+ self._sockets: list[socket.socket] = []
565
+ self._timer = threading.Timer(_FETCH_TIMEOUT_S, self._expire)
566
+ self._timer.daemon = True
567
+ self._timer.start()
568
+
569
+ def connect(
570
+ self,
571
+ address: tuple[str, int],
572
+ timeout: float | None,
573
+ source_address: tuple[str, int] | None = None,
574
+ ) -> socket.socket:
575
+ """``socket.create_connection``, with each attempt bounded by the deadline."""
576
+ host, port = address
577
+ error: OSError = OSError(f"no address for {host}")
578
+ for family, kind, proto, _, target in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
579
+ left = self._end - monotonic()
580
+ if left <= 0:
581
+ break
582
+ sock = socket.socket(family, kind, proto)
583
+ try:
584
+ sock.settimeout(left if timeout is None else min(left, timeout))
585
+ if source_address is not None:
586
+ sock.bind(source_address)
587
+ sock.connect(target)
588
+ sock.settimeout(timeout)
589
+ except OSError as exc:
590
+ sock.close()
591
+ error = exc
592
+ continue
593
+ with self._lock:
594
+ if not self.expired:
595
+ try:
596
+ # A TLS wrap takes the file descriptor of sock, so keep a copy to shut down.
597
+ self._sockets.append(sock.dup())
598
+ except OSError:
599
+ sock.close()
600
+ raise
601
+ return sock
602
+ sock.close()
603
+ break
604
+ if self.expired or self._end <= monotonic():
605
+ raise TimeoutError("the prompt fetch passed its deadline")
606
+ raise error
607
+
608
+ def _expire(self) -> None:
609
+ with self._lock:
610
+ self.expired = True
611
+ for sock in self._sockets:
612
+ with contextlib.suppress(OSError):
613
+ sock.shutdown(socket.SHUT_RDWR)
614
+
615
+ def close(self) -> None:
616
+ self._timer.cancel()
617
+ with self._lock:
618
+ for sock in self._sockets:
619
+ sock.close()
620
+ self._sockets.clear()
621
+
622
+
623
+ class _Request(urllib.request.Request):
624
+ def __init__(self, url: str, headers: dict[str, str], deadline: _Deadline) -> None:
625
+ super().__init__(url, headers=headers, method="GET")
626
+ self.deadline = deadline
627
+
628
+
629
+ def _on_deadline(http_class: Any, request: urllib.request.Request) -> Any:
630
+ """``http_class``, with the sockets of a ``_Request`` under its deadline."""
631
+
632
+ def connection(host: str, **kwargs: Any) -> Any:
633
+ conn = http_class(host, **kwargs)
634
+ if isinstance(request, _Request):
635
+ conn._create_connection = request.deadline.connect
636
+ return conn
637
+
638
+ return connection
639
+
640
+
641
+ class _HTTPHandler(urllib.request.HTTPHandler):
642
+ def do_open(self, http_class: Any, req: urllib.request.Request, **http_conn_args: Any) -> Any:
643
+ return super().do_open(_on_deadline(http_class, req), req, **http_conn_args)
644
+
645
+
646
+ class _HTTPSHandler(urllib.request.HTTPSHandler):
647
+ def do_open(self, http_class: Any, req: urllib.request.Request, **http_conn_args: Any) -> Any:
648
+ return super().do_open(_on_deadline(http_class, req), req, **http_conn_args)
649
+
650
+
651
+ _opener = urllib.request.build_opener(_NoRedirect, _HTTPHandler, _HTTPSHandler)
552
652
 
553
653
 
554
654
  def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
555
- request = urllib.request.Request(url, headers=headers, method="GET")
655
+ deadline = _Deadline()
656
+ retry_after: str | None = None
556
657
  try:
557
- with _opener.open(request, timeout=_FETCH_TIMEOUT_S) as response:
558
- body: bytes = response.read()
559
- except urllib.error.HTTPError as exc:
560
- with exc:
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
566
- except TimeoutError as exc:
567
- raise PromptError(
568
- "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
569
- ) from exc
570
- except (urllib.error.URLError, OSError) as exc:
571
- if isinstance(getattr(exc, "reason", None), TimeoutError):
572
- raise PromptError(
573
- "timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s"
574
- ) from exc
575
- raise PromptError("network_error", "Could not reach telemetry.dev") from exc
658
+ try:
659
+ with _opener.open(
660
+ _Request(url, headers, deadline), timeout=_FETCH_TIMEOUT_S
661
+ ) as response:
662
+ status: int = response.status
663
+ body: bytes = response.read()
664
+ except urllib.error.HTTPError as exc:
665
+ with exc:
666
+ status, body = exc.code, exc.read()
667
+ retry_after = exc.headers.get("Retry-After")
668
+ except Exception as exc:
669
+ if (
670
+ deadline.expired
671
+ or isinstance(exc, TimeoutError)
672
+ or isinstance(getattr(exc, "reason", None), TimeoutError)
673
+ ):
674
+ raise _timeout() from exc
675
+ if isinstance(exc, (urllib.error.URLError, OSError)):
676
+ raise PromptError("network_error", "Could not reach telemetry.dev") from exc
677
+ raise
678
+ finally:
679
+ deadline.close()
680
+ # A read that the deadline cut short can end without an error, with part of the body.
681
+ if deadline.expired:
682
+ raise _timeout()
683
+ if not 200 <= status < 300:
684
+ error = _api_error(status, body)
685
+ if status in (429, 503):
686
+ error._retry_after = _retry_after(retry_after) # pyright: ignore[reportPrivateUsage]
687
+ raise error
576
688
  try:
577
689
  parsed = _parse_response(json.loads(body))
578
690
  except ValueError:
@@ -582,6 +694,10 @@ def _fetch(url: str, headers: dict[str, str]) -> dict[str, Any]:
582
694
  return parsed
583
695
 
584
696
 
697
+ def _timeout() -> PromptError:
698
+ return PromptError("timeout", f"Fetching the prompt took more than {_FETCH_TIMEOUT_S:g} s")
699
+
700
+
585
701
  def _api_error(status: int, body: bytes) -> PromptError:
586
702
  try:
587
703
  error = json.loads(body).get("error")
File without changes