telemetry-dev 0.2.3__tar.gz → 0.2.4__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 (20) hide show
  1. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/PKG-INFO +20 -3
  2. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/README.md +19 -2
  3. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/pyproject.toml +1 -1
  4. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/pyproject.toml.orig +1 -1
  5. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_client.py +7 -2
  6. telemetry_dev-0.2.4/src/telemetry_dev/_metrics.py +280 -0
  7. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_spans.py +82 -2
  8. telemetry_dev-0.2.3/src/telemetry_dev/_metrics.py +0 -120
  9. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/LICENSE +0 -0
  10. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/__init__.py +0 -0
  11. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_capture.py +0 -0
  12. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_config.py +0 -0
  13. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_context.py +0 -0
  14. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_logs.py +0 -0
  15. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/otel.py +0 -0
  20. {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/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
3
+ Version: 0.2.4
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
@@ -99,7 +99,7 @@ later `init()` generates a new ID. It is not persisted across processes.
99
99
  | `init(**options) -> Client` | Initialize the SDK (see options below). Calling again replaces the previous client. |
100
100
  | `@observe` / `@observe(name=, type=, capture_input=, capture_output=, attributes=)` | Wrap a sync/async function (or generator) in a span. Arguments become `input` (param-name dict, `self`/`cls` dropped), the return value becomes `output`, exceptions are captured and re-raised. |
101
101
  | `start_span(name, *, type="span", ...) -> SpanHandle` | Start a span. `with` activates it in the current context; without `with` it is a detached handle you must `.end()`. |
102
- | `SpanHandle.update(**fields)` / `.end(**fields, end_time=)` / `.traceparent()` | Update attributes, end (accepts the full update field set), or read the W3C traceparent. |
102
+ | `SpanHandle.update(**fields)` / `.end(**fields, end_time=)` / `.record_output_chunk(timestamp_ms=None)` / `.traceparent()` | Update attributes, end, record arrival of a non-empty output chunk, or read the W3C traceparent. |
103
103
  | `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
104
104
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
105
105
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
@@ -108,6 +108,18 @@ later `init()` generates a new ID. It is not persisted across processes.
108
108
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
109
109
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
110
110
 
111
+ `record_output_chunk()` measures intervals at the point the consumer pulls each chunk. If a
112
+ consumer waits between pulls, that delay is included and cannot be separated from provider latency.
113
+ The exact `gen_ai.client.operation.time_per_output_chunk` histogram is supported by the built-in
114
+ OTLP metric exporter. OpenTelemetry Python does not expose an aggregate-input API for arbitrary
115
+ metric readers, so supplying `metric_reader=` disables this histogram. Duration, token usage, and
116
+ time-to-first-chunk metrics remain available; the first accepted streamed span with two or more
117
+ recorded chunks reports this limitation once through `on_error` and the SDK logger.
118
+
119
+ Provider integrations feature-detect `record_output_chunk`. Older core SDKs without that method
120
+ continue delivering streams and tracing normally, but do not emit chunk-interval metrics. Upgrade
121
+ the core SDK with provider integrations to enable the new metric.
122
+
111
123
  ### Span types
112
124
 
113
125
  `type=` maps to `gen_ai.operation.name`:
@@ -160,12 +172,17 @@ The built-in Python and TypeScript ratio samplers can select different sessions
160
172
 
161
173
  ## Auto-metrics
162
174
 
163
- Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
175
+ Ended spans automatically record histograms (DELTA temporality, exported every 60s):
164
176
 
165
177
  - `gen_ai.client.operation.duration` (unit `s`) for `chat`, `invoke_agent`, `embeddings`,
166
178
  `execute_tool`
167
179
  - `gen_ai.client.token.usage` (unit `{token}`, attribute `gen_ai.token.type=input|output`) for
168
180
  `chat`, `invoke_agent`, `embeddings`
181
+ - `gen_ai.client.operation.time_to_first_chunk` (unit `s`) for `chat` when a finite, non-negative
182
+ first-chunk timing is available.
183
+ - `gen_ai.client.operation.time_per_output_chunk` (unit `s`) for `chat` with two or more recorded
184
+ output chunks, using the built-in OTLP exporter. Filtering applies before either streaming metric
185
+ is emitted, including streams interrupted after receiving output.
169
186
 
170
187
  Plain spans (`function`) record no metrics. Quiet intervals produce zero metric requests.
171
188
 
@@ -75,7 +75,7 @@ later `init()` generates a new ID. It is not persisted across processes.
75
75
  | `init(**options) -> Client` | Initialize the SDK (see options below). Calling again replaces the previous client. |
76
76
  | `@observe` / `@observe(name=, type=, capture_input=, capture_output=, attributes=)` | Wrap a sync/async function (or generator) in a span. Arguments become `input` (param-name dict, `self`/`cls` dropped), the return value becomes `output`, exceptions are captured and re-raised. |
77
77
  | `start_span(name, *, type="span", ...) -> SpanHandle` | Start a span. `with` activates it in the current context; without `with` it is a detached handle you must `.end()`. |
78
- | `SpanHandle.update(**fields)` / `.end(**fields, end_time=)` / `.traceparent()` | Update attributes, end (accepts the full update field set), or read the W3C traceparent. |
78
+ | `SpanHandle.update(**fields)` / `.end(**fields, end_time=)` / `.record_output_chunk(timestamp_ms=None)` / `.traceparent()` | Update attributes, end, record arrival of a non-empty output chunk, or read the W3C traceparent. |
79
79
  | `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
80
80
  | `propagate_attributes(*, user_id=, session_id=, metadata=)` | Context manager stamping `user.id` / `gen_ai.conversation.id` / `td.metadata.*` on every span and log record started inside (threads/asyncio included via contextvars). |
81
81
  | `log(message, *, level="info", event_name=None, attributes=None)` | Emit an OTLP log record to `/v1/logs`, correlated with the current trace. Levels: `debug`/`info`/`warn`/`error` (`"warning"` is accepted as an alias of `warn`). |
@@ -84,6 +84,18 @@ later `init()` generates a new ID. It is not persisted across processes.
84
84
  | `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
85
85
  | `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
86
86
 
87
+ `record_output_chunk()` measures intervals at the point the consumer pulls each chunk. If a
88
+ consumer waits between pulls, that delay is included and cannot be separated from provider latency.
89
+ The exact `gen_ai.client.operation.time_per_output_chunk` histogram is supported by the built-in
90
+ OTLP metric exporter. OpenTelemetry Python does not expose an aggregate-input API for arbitrary
91
+ metric readers, so supplying `metric_reader=` disables this histogram. Duration, token usage, and
92
+ time-to-first-chunk metrics remain available; the first accepted streamed span with two or more
93
+ recorded chunks reports this limitation once through `on_error` and the SDK logger.
94
+
95
+ Provider integrations feature-detect `record_output_chunk`. Older core SDKs without that method
96
+ continue delivering streams and tracing normally, but do not emit chunk-interval metrics. Upgrade
97
+ the core SDK with provider integrations to enable the new metric.
98
+
87
99
  ### Span types
88
100
 
89
101
  `type=` maps to `gen_ai.operation.name`:
@@ -136,12 +148,17 @@ The built-in Python and TypeScript ratio samplers can select different sessions
136
148
 
137
149
  ## Auto-metrics
138
150
 
139
- Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
151
+ Ended spans automatically record histograms (DELTA temporality, exported every 60s):
140
152
 
141
153
  - `gen_ai.client.operation.duration` (unit `s`) for `chat`, `invoke_agent`, `embeddings`,
142
154
  `execute_tool`
143
155
  - `gen_ai.client.token.usage` (unit `{token}`, attribute `gen_ai.token.type=input|output`) for
144
156
  `chat`, `invoke_agent`, `embeddings`
157
+ - `gen_ai.client.operation.time_to_first_chunk` (unit `s`) for `chat` when a finite, non-negative
158
+ first-chunk timing is available.
159
+ - `gen_ai.client.operation.time_per_output_chunk` (unit `s`) for `chat` with two or more recorded
160
+ output chunks, using the built-in OTLP exporter. Filtering applies before either streaming metric
161
+ is emitted, including streams interrupted after receiving output.
145
162
 
146
163
  Plain spans (`function`) record no metrics. Quiet intervals produce zero metric requests.
147
164
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.3"
3
+ version = "0.2.4"
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.3"
3
+ version = "0.2.4"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -41,7 +41,7 @@ from ._config import (
41
41
  resolve_config,
42
42
  )
43
43
  from ._context import SessionSampler
44
- from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder
44
+ from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder, OutputChunkAggregation
45
45
  from ._processor import ExportMode, StampingSpanProcessor
46
46
  from ._semconv import SCOPE_NAME
47
47
  from ._serialize import Mask, serialize_content
@@ -160,6 +160,7 @@ class Client:
160
160
  self._tracer_provider: TracerProvider | None = None
161
161
  self._logger_provider: LoggerProvider | None = None
162
162
  self._meter_provider: MeterProvider | None = None
163
+ self._output_chunks: OutputChunkAggregation | None = None
163
164
 
164
165
  if not enabled:
165
166
  return
@@ -191,6 +192,7 @@ class Client:
191
192
  # Without an api key (enabled via a test seam), never construct real network
192
193
  # exporters — they would POST to the ingest with a bogus Authorization header.
193
194
  if metric_reader is None and config.api_key is not None:
195
+ self._output_chunks = OutputChunkAggregation()
194
196
  metric_exporter = GuardedOTLPMetricExporter(
195
197
  endpoint=f"{config.base_url}/v1/metrics",
196
198
  headers=headers,
@@ -199,6 +201,7 @@ class Client:
199
201
  preferred_temporality={Histogram: AggregationTemporality.DELTA},
200
202
  on_error=on_error,
201
203
  )
204
+ metric_exporter.output_chunks = self._output_chunks
202
205
  metric_reader = PeriodicExportingMetricReader(
203
206
  metric_exporter, export_interval_millis=60_000
204
207
  )
@@ -208,7 +211,9 @@ class Client:
208
211
  metric_readers=[metric_reader], resource=resource, shutdown_on_exit=False
209
212
  )
210
213
  meter = self._meter_provider.get_meter(SCOPE_NAME, SDK_VERSION)
211
- metrics_recorder = MetricsRecorder(meter, on_error=on_error)
214
+ metrics_recorder = MetricsRecorder(
215
+ meter, on_error=on_error, output_chunks=self._output_chunks
216
+ )
212
217
 
213
218
  if span_exporter is None:
214
219
  span_exporter = _ReportingSpanExporter(
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
11
+ from opentelemetry.metrics import Meter
12
+ from opentelemetry.sdk.metrics.export import (
13
+ AggregationTemporality,
14
+ Histogram,
15
+ HistogramDataPoint,
16
+ Metric,
17
+ MetricExportResult,
18
+ MetricsData,
19
+ ResourceMetrics,
20
+ ScopeMetrics,
21
+ )
22
+ from opentelemetry.sdk.trace import ReadableSpan
23
+ from opentelemetry.trace import INVALID_SPAN_CONTEXT, NonRecordingSpan, set_span_in_context
24
+
25
+ from ._config import report_error
26
+ from ._semconv import (
27
+ ATTR_ERROR_TYPE,
28
+ ATTR_TIME_TO_FIRST_CHUNK,
29
+ DURATION_BUCKETS,
30
+ DURATION_METRIC_OPERATIONS,
31
+ METRIC_ATTR_KEYS,
32
+ SCOPE_NAME,
33
+ TOKEN_BUCKETS,
34
+ TOKEN_METRIC_OPERATIONS,
35
+ USAGE_ATTRS,
36
+ )
37
+
38
+ _INPUT_TOKENS_ATTR = USAGE_ATTRS["input_tokens"]
39
+ _OUTPUT_TOKENS_ATTR = USAGE_ATTRS["output_tokens"]
40
+ _MAX_CHUNK_ATTRIBUTE_SETS = 2000
41
+ _CHUNK_METRIC_NAME = "gen_ai.client.operation.time_per_output_chunk"
42
+
43
+
44
+ @dataclass
45
+ class _ChunkAggregate:
46
+ count: int
47
+ sum: float
48
+ min: float
49
+ max: float
50
+ buckets: list[int]
51
+
52
+
53
+ class OutputChunkAggregation:
54
+ def __init__(self, max_attribute_sets: int = _MAX_CHUNK_ATTRIBUTE_SETS) -> None:
55
+ self._max_attribute_sets = max_attribute_sets
56
+ self._values: dict[tuple[tuple[str, Any], ...], _ChunkAggregate] = {}
57
+ self._lock = threading.Lock()
58
+ self._collection_start = time.time_ns()
59
+
60
+ def add(
61
+ self,
62
+ attributes: dict[str, Any],
63
+ aggregate: tuple[int, float, float, float, tuple[int, ...]],
64
+ ) -> None:
65
+ key = tuple(sorted(attributes.items()))
66
+ with self._lock:
67
+ if key not in self._values and len(self._values) >= self._max_attribute_sets - 1:
68
+ key = (("otel.metric.overflow", True),)
69
+ count, total, minimum, maximum, buckets = aggregate
70
+ existing = self._values.get(key)
71
+ if existing is None:
72
+ self._values[key] = _ChunkAggregate(count, total, minimum, maximum, list(buckets))
73
+ return
74
+ existing.count += count
75
+ existing.sum += total
76
+ existing.min = min(existing.min, minimum)
77
+ existing.max = max(existing.max, maximum)
78
+ for index, value in enumerate(buckets):
79
+ existing.buckets[index] += value
80
+
81
+ def drain_points(self) -> tuple[HistogramDataPoint, ...]:
82
+ with self._lock:
83
+ values, self._values = self._values, {}
84
+ now = time.time_ns()
85
+ start, self._collection_start = self._collection_start, now
86
+ return tuple(
87
+ HistogramDataPoint(
88
+ attributes=dict(key),
89
+ start_time_unix_nano=start,
90
+ time_unix_nano=now,
91
+ count=value.count,
92
+ sum=value.sum,
93
+ bucket_counts=tuple(value.buckets),
94
+ explicit_bounds=tuple(DURATION_BUCKETS),
95
+ min=value.min,
96
+ max=value.max,
97
+ exemplars=(),
98
+ )
99
+ for key, value in values.items()
100
+ )
101
+
102
+
103
+ def _with_output_chunks(
104
+ metrics_data: MetricsData, aggregation: OutputChunkAggregation
105
+ ) -> MetricsData:
106
+ resource_metrics = list(metrics_data.resource_metrics)
107
+ target = next(
108
+ (
109
+ (resource_index, scope_index)
110
+ for resource_index, resource in enumerate(resource_metrics)
111
+ for scope_index, scope in enumerate(resource.scope_metrics)
112
+ if scope.scope.name == SCOPE_NAME
113
+ ),
114
+ None,
115
+ )
116
+ if target is None:
117
+ return metrics_data
118
+ points = aggregation.drain_points()
119
+ if not points:
120
+ return metrics_data
121
+ metric = Metric(
122
+ name=_CHUNK_METRIC_NAME,
123
+ description="Time between consecutive non-empty GenAI output chunks",
124
+ unit="s",
125
+ data=Histogram(points, AggregationTemporality.DELTA),
126
+ )
127
+ resource_index, scope_index = target
128
+ resource = resource_metrics[resource_index]
129
+ scopes = list(resource.scope_metrics)
130
+ scope = scopes[scope_index]
131
+ scopes[scope_index] = ScopeMetrics(scope.scope, [*scope.metrics, metric], scope.schema_url)
132
+ resource_metrics[resource_index] = ResourceMetrics(
133
+ resource.resource, scopes, resource.schema_url
134
+ )
135
+ return MetricsData(resource_metrics)
136
+
137
+
138
+ def _has_data_points(metrics_data: MetricsData) -> bool:
139
+ for resource_metrics in metrics_data.resource_metrics:
140
+ for scope_metrics in resource_metrics.scope_metrics:
141
+ for metric in scope_metrics.metrics:
142
+ if len(metric.data.data_points) > 0:
143
+ return True
144
+ return False
145
+
146
+
147
+ class GuardedOTLPMetricExporter(OTLPMetricExporter):
148
+ """OTLP metric exporter that skips POSTs for collections with zero data points
149
+ (mirrors the TS emitter's empty-datapoint guard) and surfaces failures to on_error."""
150
+
151
+ def __init__(
152
+ self,
153
+ *args: Any,
154
+ on_error: Callable[[BaseException], None] | None = None,
155
+ **kwargs: Any,
156
+ ) -> None:
157
+ super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownMemberType]
158
+ self._td_on_error = on_error
159
+ self.output_chunks: OutputChunkAggregation | None = None
160
+
161
+ def export(
162
+ self,
163
+ metrics_data: MetricsData,
164
+ timeout_millis: float | None = 10_000,
165
+ **kwargs: Any,
166
+ ) -> MetricExportResult:
167
+ if self.output_chunks is not None:
168
+ metrics_data = _with_output_chunks(metrics_data, self.output_chunks)
169
+ if not _has_data_points(metrics_data):
170
+ return MetricExportResult.SUCCESS
171
+ try:
172
+ result = super().export( # pyright: ignore[reportUnknownMemberType]
173
+ metrics_data, timeout_millis=timeout_millis, **kwargs
174
+ )
175
+ except BaseException as exc:
176
+ report_error(self._td_on_error, "metric export failed", exc)
177
+ return MetricExportResult.FAILURE
178
+ if result is MetricExportResult.FAILURE:
179
+ report_error(
180
+ self._td_on_error,
181
+ "metric export failed",
182
+ RuntimeError("OTLP metric export failed (check the API key and ingest URL)"),
183
+ )
184
+ return result
185
+
186
+
187
+ class MetricsRecorder:
188
+ """Records the auto GenAI histograms from ended spans (called by the span processor)."""
189
+
190
+ def __init__(
191
+ self,
192
+ meter: Meter,
193
+ *,
194
+ on_error: Callable[[BaseException], None] | None = None,
195
+ output_chunks: OutputChunkAggregation | None = None,
196
+ ) -> None:
197
+ self._on_error = on_error
198
+ self._output_chunks = output_chunks
199
+ self._custom_reader_limitation_reported = False
200
+ self._custom_reader_limitation_lock = threading.Lock()
201
+ self._duration = meter.create_histogram(
202
+ "gen_ai.client.operation.duration",
203
+ unit="s",
204
+ description="Duration of GenAI client operations",
205
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
206
+ )
207
+ self._tokens = meter.create_histogram(
208
+ "gen_ai.client.token.usage",
209
+ unit="{token}",
210
+ description="Number of input and output tokens used by GenAI clients",
211
+ explicit_bucket_boundaries_advisory=TOKEN_BUCKETS,
212
+ )
213
+ self._first_chunk = meter.create_histogram(
214
+ "gen_ai.client.operation.time_to_first_chunk",
215
+ unit="s",
216
+ explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
217
+ )
218
+
219
+ def record_span(self, span: ReadableSpan) -> None:
220
+ try:
221
+ attributes = span.attributes or {}
222
+ operation = attributes.get("gen_ai.operation.name")
223
+ if not isinstance(operation, str) or operation not in DURATION_METRIC_OPERATIONS:
224
+ return
225
+ metric_attrs = {key: attributes[key] for key in METRIC_ATTR_KEYS if key in attributes}
226
+ if operation == "chat":
227
+ from ._spans import output_chunk_aggregate
228
+
229
+ chunk_aggregate = output_chunk_aggregate(span)
230
+ if chunk_aggregate is not None:
231
+ if self._output_chunks is not None:
232
+ self._output_chunks.add(metric_attrs, chunk_aggregate)
233
+ else:
234
+ with self._custom_reader_limitation_lock:
235
+ should_report = not self._custom_reader_limitation_reported
236
+ self._custom_reader_limitation_reported = True
237
+ if should_report:
238
+ report_error(
239
+ self._on_error,
240
+ "output chunk interval metric is unavailable with a custom "
241
+ "metric_reader",
242
+ RuntimeError(
243
+ "gen_ai.client.operation.time_per_output_chunk requires the "
244
+ "built-in OTLP metric exporter; ordinary auto-metrics remain "
245
+ "enabled"
246
+ ),
247
+ )
248
+ context = set_span_in_context(NonRecordingSpan(span.context or INVALID_SPAN_CONTEXT))
249
+ if span.end_time is not None and span.start_time is not None:
250
+ duration_s = max(span.end_time - span.start_time, 0) / 1e9
251
+ error_type = attributes.get(ATTR_ERROR_TYPE)
252
+ duration_attrs = (
253
+ {**metric_attrs, ATTR_ERROR_TYPE: error_type}
254
+ if isinstance(error_type, str)
255
+ else metric_attrs
256
+ )
257
+ self._duration.record(duration_s, duration_attrs, context=context)
258
+ first_chunk_seconds = attributes.get(ATTR_TIME_TO_FIRST_CHUNK)
259
+ if (
260
+ operation == "chat"
261
+ and isinstance(first_chunk_seconds, (int, float))
262
+ and not isinstance(first_chunk_seconds, bool)
263
+ and math.isfinite(first_chunk_seconds)
264
+ and first_chunk_seconds >= 0
265
+ ):
266
+ self._first_chunk.record(first_chunk_seconds, metric_attrs, context=context)
267
+ if operation not in TOKEN_METRIC_OPERATIONS:
268
+ return
269
+ input_tokens = attributes.get(_INPUT_TOKENS_ATTR)
270
+ if isinstance(input_tokens, int):
271
+ self._tokens.record(
272
+ input_tokens, {**metric_attrs, "gen_ai.token.type": "input"}, context=context
273
+ )
274
+ output_tokens = attributes.get(_OUTPUT_TOKENS_ATTR)
275
+ if isinstance(output_tokens, int):
276
+ self._tokens.record(
277
+ output_tokens, {**metric_attrs, "gen_ai.token.type": "output"}, context=context
278
+ )
279
+ except BaseException as exc:
280
+ report_error(self._on_error, "failed to record auto-metrics for span", exc)
@@ -1,12 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+ import threading
5
+ import time
3
6
  import traceback
7
+ from bisect import bisect_left
4
8
  from collections.abc import Callable, Mapping, Sequence
5
9
  from contextvars import Token
6
10
  from dataclasses import dataclass
7
11
  from datetime import datetime
8
12
  from typing import Any, Literal, TypedDict
9
- from weakref import WeakKeyDictionary
13
+ from weakref import WeakKeyDictionary, WeakValueDictionary
10
14
 
11
15
  from opentelemetry import context as otel_context
12
16
  from opentelemetry import trace
@@ -42,6 +46,7 @@ from ._semconv import (
42
46
  ATTR_TOOL_CALL_ID,
43
47
  ATTR_TOOL_DESCRIPTION,
44
48
  ATTR_TOOL_NAME,
49
+ DURATION_BUCKETS,
45
50
  METADATA_PREFIX,
46
51
  RESERVED_METADATA_KEYS,
47
52
  SAMPLING_ATTRS,
@@ -86,9 +91,42 @@ class _SpanState:
86
91
  operation: str
87
92
  capture_input: bool
88
93
  capture_output: bool
94
+ output_chunk_last_ms: float | None = None
95
+ output_chunk_count: int = 0
96
+ output_chunk_sum_s: float = 0
97
+ output_chunk_min_s: float | None = None
98
+ output_chunk_max_s: float | None = None
99
+ output_chunk_buckets: list[int] | None = None
89
100
 
90
101
 
91
102
  _SPAN_STATES: WeakKeyDictionary[Span, _SpanState] = WeakKeyDictionary()
103
+ _OUTPUT_CHUNK_STATES: WeakValueDictionary[tuple[int, int], _SpanState] = WeakValueDictionary()
104
+ _OUTPUT_CHUNK_STATES_LOCK = threading.Lock()
105
+
106
+
107
+ def _span_key(span: Any) -> tuple[int, int]:
108
+ context = span.get_span_context() if hasattr(span, "get_span_context") else span.context
109
+ return context.trace_id, context.span_id
110
+
111
+
112
+ def output_chunk_aggregate(span: Any) -> tuple[int, float, float, float, tuple[int, ...]] | None:
113
+ with _OUTPUT_CHUNK_STATES_LOCK:
114
+ state = _OUTPUT_CHUNK_STATES.pop(_span_key(span), None)
115
+ if (
116
+ state is None
117
+ or state.output_chunk_count == 0
118
+ or state.output_chunk_min_s is None
119
+ or state.output_chunk_max_s is None
120
+ or state.output_chunk_buckets is None
121
+ ):
122
+ return None
123
+ return (
124
+ state.output_chunk_count,
125
+ state.output_chunk_sum_s,
126
+ state.output_chunk_min_s,
127
+ state.output_chunk_max_s,
128
+ tuple(state.output_chunk_buckets),
129
+ )
92
130
 
93
131
 
94
132
  def _to_ns(value: TimeInput) -> int | None:
@@ -413,6 +451,45 @@ class SpanHandle:
413
451
  self._client.report("SpanHandle.update failed", exc)
414
452
  return self
415
453
 
454
+ def record_output_chunk(self, timestamp_ms: float | None = None) -> SpanHandle:
455
+ """Record arrival of a non-empty output chunk using a monotonic millisecond timestamp."""
456
+ if not self._recording():
457
+ return self
458
+ assert self._state is not None and self._client is not None
459
+ try:
460
+ now = time.perf_counter() * 1000 if timestamp_ms is None else float(timestamp_ms)
461
+ if not math.isfinite(now):
462
+ return self
463
+ with _OUTPUT_CHUNK_STATES_LOCK:
464
+ if not self._recording():
465
+ return self
466
+ previous = self._state.output_chunk_last_ms
467
+ if previous is not None and now < previous:
468
+ return self
469
+ self._state.output_chunk_last_ms = now
470
+ _OUTPUT_CHUNK_STATES[_span_key(self.span)] = self._state
471
+ if previous is None:
472
+ return self
473
+ interval_s = max(now - previous, 0) / 1000
474
+ self._state.output_chunk_count += 1
475
+ self._state.output_chunk_sum_s += interval_s
476
+ self._state.output_chunk_min_s = (
477
+ interval_s
478
+ if self._state.output_chunk_min_s is None
479
+ else min(self._state.output_chunk_min_s, interval_s)
480
+ )
481
+ self._state.output_chunk_max_s = (
482
+ interval_s
483
+ if self._state.output_chunk_max_s is None
484
+ else max(self._state.output_chunk_max_s, interval_s)
485
+ )
486
+ if self._state.output_chunk_buckets is None:
487
+ self._state.output_chunk_buckets = [0] * (len(DURATION_BUCKETS) + 1)
488
+ self._state.output_chunk_buckets[bisect_left(DURATION_BUCKETS, interval_s)] += 1
489
+ except BaseException as exc:
490
+ self._client.report("SpanHandle.record_output_chunk failed", exc)
491
+ return self
492
+
416
493
  def end(
417
494
  self,
418
495
  *,
@@ -480,7 +557,10 @@ class SpanHandle:
480
557
  attributes=attributes,
481
558
  error=error,
482
559
  )
483
- self._ended = True
560
+ with _OUTPUT_CHUNK_STATES_LOCK:
561
+ if self._ended:
562
+ return
563
+ self._ended = True
484
564
  self.span.end(_to_ns(end_time))
485
565
 
486
566
  def traceparent(self) -> str | None:
@@ -1,120 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from collections.abc import Callable
4
- from typing import Any
5
-
6
- from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
7
- from opentelemetry.metrics import Meter
8
- from opentelemetry.sdk.metrics.export import MetricExportResult, MetricsData
9
- from opentelemetry.sdk.trace import ReadableSpan
10
-
11
- from ._config import report_error
12
- from ._semconv import (
13
- ATTR_ERROR_TYPE,
14
- DURATION_BUCKETS,
15
- DURATION_METRIC_OPERATIONS,
16
- METRIC_ATTR_KEYS,
17
- TOKEN_BUCKETS,
18
- TOKEN_METRIC_OPERATIONS,
19
- USAGE_ATTRS,
20
- )
21
-
22
- _INPUT_TOKENS_ATTR = USAGE_ATTRS["input_tokens"]
23
- _OUTPUT_TOKENS_ATTR = USAGE_ATTRS["output_tokens"]
24
-
25
-
26
- def _has_data_points(metrics_data: MetricsData) -> bool:
27
- for resource_metrics in metrics_data.resource_metrics:
28
- for scope_metrics in resource_metrics.scope_metrics:
29
- for metric in scope_metrics.metrics:
30
- if len(metric.data.data_points) > 0:
31
- return True
32
- return False
33
-
34
-
35
- class GuardedOTLPMetricExporter(OTLPMetricExporter):
36
- """OTLP metric exporter that skips POSTs for collections with zero data points
37
- (mirrors the TS emitter's empty-datapoint guard) and surfaces failures to on_error."""
38
-
39
- def __init__(
40
- self,
41
- *args: Any,
42
- on_error: Callable[[BaseException], None] | None = None,
43
- **kwargs: Any,
44
- ) -> None:
45
- super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownMemberType]
46
- self._td_on_error = on_error
47
-
48
- def export(
49
- self,
50
- metrics_data: MetricsData,
51
- timeout_millis: float | None = 10_000,
52
- **kwargs: Any,
53
- ) -> MetricExportResult:
54
- if not _has_data_points(metrics_data):
55
- return MetricExportResult.SUCCESS
56
- try:
57
- result = super().export( # pyright: ignore[reportUnknownMemberType]
58
- metrics_data, timeout_millis=timeout_millis, **kwargs
59
- )
60
- except BaseException as exc:
61
- report_error(self._td_on_error, "metric export failed", exc)
62
- return MetricExportResult.FAILURE
63
- if result is MetricExportResult.FAILURE:
64
- report_error(
65
- self._td_on_error,
66
- "metric export failed",
67
- RuntimeError("OTLP metric export failed (check the API key and ingest URL)"),
68
- )
69
- return result
70
-
71
-
72
- class MetricsRecorder:
73
- """Records the auto GenAI histograms from ended spans (called by the span processor)."""
74
-
75
- def __init__(
76
- self,
77
- meter: Meter,
78
- *,
79
- on_error: Callable[[BaseException], None] | None = None,
80
- ) -> None:
81
- self._on_error = on_error
82
- self._duration = meter.create_histogram(
83
- "gen_ai.client.operation.duration",
84
- unit="s",
85
- description="Duration of GenAI client operations",
86
- explicit_bucket_boundaries_advisory=DURATION_BUCKETS,
87
- )
88
- self._tokens = meter.create_histogram(
89
- "gen_ai.client.token.usage",
90
- unit="{token}",
91
- description="Number of input and output tokens used by GenAI clients",
92
- explicit_bucket_boundaries_advisory=TOKEN_BUCKETS,
93
- )
94
-
95
- def record_span(self, span: ReadableSpan) -> None:
96
- try:
97
- attributes = span.attributes or {}
98
- operation = attributes.get("gen_ai.operation.name")
99
- if not isinstance(operation, str) or operation not in DURATION_METRIC_OPERATIONS:
100
- return
101
- metric_attrs = {key: attributes[key] for key in METRIC_ATTR_KEYS if key in attributes}
102
- if span.end_time is not None and span.start_time is not None:
103
- duration_s = max(span.end_time - span.start_time, 0) / 1e9
104
- error_type = attributes.get(ATTR_ERROR_TYPE)
105
- duration_attrs = (
106
- {**metric_attrs, ATTR_ERROR_TYPE: error_type}
107
- if isinstance(error_type, str)
108
- else metric_attrs
109
- )
110
- self._duration.record(duration_s, duration_attrs)
111
- if operation not in TOKEN_METRIC_OPERATIONS:
112
- return
113
- input_tokens = attributes.get(_INPUT_TOKENS_ATTR)
114
- if isinstance(input_tokens, int):
115
- self._tokens.record(input_tokens, {**metric_attrs, "gen_ai.token.type": "input"})
116
- output_tokens = attributes.get(_OUTPUT_TOKENS_ATTR)
117
- if isinstance(output_tokens, int):
118
- self._tokens.record(output_tokens, {**metric_attrs, "gen_ai.token.type": "output"})
119
- except BaseException as exc:
120
- report_error(self._on_error, "failed to record auto-metrics for span", exc)
File without changes