telemetry-dev 0.1.0__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.1.0"
3
+ version = "0.2.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"
@@ -12,6 +12,7 @@ Quickstart::
12
12
  span.update(output=completion, usage={"input_tokens": 11, "output_tokens": 7})
13
13
  """
14
14
 
15
+ from ._capture import CaptureBudget
15
16
  from ._client import Client, flush, get_client, init, shutdown
16
17
  from ._config import SDK_VERSION as __version__
17
18
  from ._config import LogLevelOption
@@ -32,6 +33,7 @@ from .otel import TelemetrySpanProcessor
32
33
  __all__ = [
33
34
  "NOT_GIVEN",
34
35
  "AttributeValue",
36
+ "CaptureBudget",
35
37
  "Client",
36
38
  "LogLevel",
37
39
  "LogLevelOption",
@@ -0,0 +1,177 @@
1
+ """Bounded retention for provider streaming instrumentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Final, cast
7
+
8
+ _DEFAULT_MAX_BYTES: Final = 64 * 1024
9
+ _DEFAULT_MAX_ITEMS: Final = 1024
10
+ _MAX_DEPTH: Final = 32
11
+ _BASE_ITEM_BYTES: Final = 16
12
+
13
+
14
+ class CaptureBudget:
15
+ """Track a hard byte and item budget before retaining streamed provider data."""
16
+
17
+ def __init__(self, max_bytes: int = _DEFAULT_MAX_BYTES, max_items: int = _DEFAULT_MAX_ITEMS):
18
+ self.max_bytes: int = max(0, min(max_bytes, _DEFAULT_MAX_BYTES))
19
+ self.max_items: int = max(0, min(max_items, _DEFAULT_MAX_ITEMS))
20
+ self.bytes_used: int = 0
21
+ self.items_used: int = 0
22
+ self.truncated: bool = False
23
+
24
+ @classmethod
25
+ def from_client(cls) -> CaptureBudget:
26
+ """Use the active client's attribute limit without exceeding the SDK ceiling."""
27
+ from ._client import get_client
28
+
29
+ client = get_client()
30
+ return cls(
31
+ max_bytes=client.max_attribute_length if client is not None else _DEFAULT_MAX_BYTES
32
+ )
33
+
34
+ @property
35
+ def remaining_bytes(self) -> int:
36
+ """Return bytes still available for retained capture state."""
37
+ return self.max_bytes - self.bytes_used
38
+
39
+ def accept(self, value: object) -> bool:
40
+ """Reserve space for a value, returning false without retaining it when over budget."""
41
+ if self.truncated:
42
+ return False
43
+ measured = self._measure(
44
+ value,
45
+ remaining_bytes=self.remaining_bytes,
46
+ remaining_items=self.max_items - self.items_used,
47
+ depth=0,
48
+ seen=set(),
49
+ )
50
+ if measured is None:
51
+ self.truncated = True
52
+ return False
53
+ byte_count, item_count = measured
54
+ self.bytes_used += byte_count
55
+ self.items_used += item_count
56
+ return True
57
+
58
+ def capture_bytes(self, value: bytes | bytearray | memoryview) -> bytes:
59
+ """Retain at most the remaining byte prefix while marking a partial capture."""
60
+ if self.truncated:
61
+ return b""
62
+ view = memoryview(value)
63
+ try:
64
+ byte_view = view.cast("B")
65
+ except TypeError:
66
+ byte_view = memoryview(bytes(view))
67
+ byte_length = len(byte_view)
68
+ if byte_length == 0:
69
+ return b""
70
+ if self.items_used >= self.max_items or self.remaining_bytes <= 0:
71
+ self.truncated = True
72
+ return b""
73
+ retained_length = min(byte_length, self.remaining_bytes)
74
+ self.items_used += 1
75
+ self.bytes_used += retained_length
76
+ if retained_length < byte_length:
77
+ self.truncated = True
78
+ if isinstance(value, bytes) and retained_length == byte_length:
79
+ return value
80
+ return bytes(byte_view[:retained_length])
81
+
82
+ @classmethod
83
+ def _measure(
84
+ cls,
85
+ value: object,
86
+ *,
87
+ remaining_bytes: int,
88
+ remaining_items: int,
89
+ depth: int,
90
+ seen: set[int],
91
+ ) -> tuple[int, int] | None:
92
+ if remaining_items <= 0 or remaining_bytes < _BASE_ITEM_BYTES or depth > _MAX_DEPTH:
93
+ return None
94
+
95
+ byte_count = _BASE_ITEM_BYTES
96
+ item_count = 1
97
+ if value is None or isinstance(value, bool | int | float):
98
+ return byte_count, item_count
99
+ if isinstance(value, str):
100
+ for character in value:
101
+ codepoint = ord(character)
102
+ byte_count += (
103
+ 1
104
+ if codepoint <= 0x7F
105
+ else 2
106
+ if codepoint <= 0x7FF
107
+ else 3
108
+ if codepoint <= 0xFFFF
109
+ else 4
110
+ )
111
+ if byte_count > remaining_bytes:
112
+ return None
113
+ return byte_count, item_count
114
+ if isinstance(value, bytes | bytearray | memoryview):
115
+ length = value.nbytes if isinstance(value, memoryview) else len(value)
116
+ # Structured attributes serialize binary values as base64 text.
117
+ byte_count += 4 * ((length + 2) // 3)
118
+ return (byte_count, item_count) if byte_count <= remaining_bytes else None
119
+
120
+ value_id = id(value)
121
+ if value_id in seen:
122
+ return byte_count, item_count
123
+ seen.add(value_id)
124
+ mapping: dict[object, object] | None = None
125
+ children: Iterable[object] = ()
126
+ if isinstance(value, dict):
127
+ mapping = cast("dict[object, object]", value)
128
+ elif isinstance(value, list | tuple | set | frozenset):
129
+ children = cast("Iterable[object]", value)
130
+ else:
131
+ attributes = getattr(value, "__dict__", None)
132
+ if isinstance(attributes, dict):
133
+ mapping = cast("dict[object, object]", attributes)
134
+
135
+ if mapping is not None:
136
+ for key, child in mapping.items():
137
+ measured = cls._measure(
138
+ key,
139
+ remaining_bytes=remaining_bytes - byte_count,
140
+ remaining_items=remaining_items - item_count,
141
+ depth=depth + 1,
142
+ seen=seen,
143
+ )
144
+ if measured is None:
145
+ return None
146
+ child_bytes, child_items = measured
147
+ byte_count += child_bytes
148
+ item_count += child_items
149
+
150
+ measured = cls._measure(
151
+ child,
152
+ remaining_bytes=remaining_bytes - byte_count,
153
+ remaining_items=remaining_items - item_count,
154
+ depth=depth + 1,
155
+ seen=seen,
156
+ )
157
+ if measured is None:
158
+ return None
159
+ child_bytes, child_items = measured
160
+ byte_count += child_bytes
161
+ item_count += child_items
162
+ return byte_count, item_count
163
+
164
+ for child in children:
165
+ measured = cls._measure(
166
+ child,
167
+ remaining_bytes=remaining_bytes - byte_count,
168
+ remaining_items=remaining_items - item_count,
169
+ depth=depth + 1,
170
+ seen=seen,
171
+ )
172
+ if measured is None:
173
+ return None
174
+ child_bytes, child_items = measured
175
+ byte_count += child_bytes
176
+ item_count += child_items
177
+ return byte_count, item_count
File without changes