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.
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/PKG-INFO +20 -3
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/README.md +19 -2
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/pyproject.toml +1 -1
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/pyproject.toml.orig +1 -1
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_client.py +7 -2
- telemetry_dev-0.2.4/src/telemetry_dev/_metrics.py +280 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_spans.py +82 -2
- telemetry_dev-0.2.3/src/telemetry_dev/_metrics.py +0 -120
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/LICENSE +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_capture.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_config.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_context.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_logs.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_semconv.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/_serialize.py +0 -0
- {telemetry_dev-0.2.3 → telemetry_dev-0.2.4}/src/telemetry_dev/otel.py +0 -0
- {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
|
+
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|