telemetry-dev 0.2.2__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.4/LICENSE +21 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/PKG-INFO +28 -4
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/README.md +25 -2
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/pyproject.toml +3 -2
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/pyproject.toml.orig +3 -2
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_capture.py +2 -27
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_client.py +17 -2
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_config.py +1 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_context.py +7 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_logs.py +16 -4
- telemetry_dev-0.2.4/src/telemetry_dev/_metrics.py +280 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_spans.py +100 -5
- telemetry_dev-0.2.2/src/telemetry_dev/_metrics.py +0 -120
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/__init__.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_observe.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_processor.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_semconv.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_serialize.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/otel.py +0 -0
- {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 telemetry.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,10 +1,11 @@
|
|
|
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
|
|
7
7
|
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
8
9
|
Classifier: Development Status :: 4 - Beta
|
|
9
10
|
Classifier: Intended Audience :: Developers
|
|
10
11
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -18,7 +19,7 @@ Requires-Dist: opentelemetry-sdk>=1.35.0,<2
|
|
|
18
19
|
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.35.0,<2
|
|
19
20
|
Requires-Python: >=3.10
|
|
20
21
|
Project-URL: Homepage, https://telemetry.dev
|
|
21
|
-
Project-URL: Repository, https://github.com/telemetry-dev/
|
|
22
|
+
Project-URL: Repository, https://github.com/telemetry-dev/sdks
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
24
25
|
# telemetry-dev
|
|
@@ -85,6 +86,12 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
85
86
|
|
|
86
87
|
Explicit `init()` arguments take precedence over environment variables.
|
|
87
88
|
|
|
89
|
+
Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
|
|
90
|
+
otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
|
|
91
|
+
initialization. Explicit and propagated session IDs take precedence, and valid parent trace
|
|
92
|
+
context is preserved. The generated session ends when the client is shut down or replaced; a
|
|
93
|
+
later `init()` generates a new ID. It is not persisted across processes.
|
|
94
|
+
|
|
88
95
|
## API reference
|
|
89
96
|
|
|
90
97
|
| Name | Description |
|
|
@@ -92,7 +99,7 @@ Explicit `init()` arguments take precedence over environment variables.
|
|
|
92
99
|
| `init(**options) -> Client` | Initialize the SDK (see options below). Calling again replaces the previous client. |
|
|
93
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. |
|
|
94
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()`. |
|
|
95
|
-
| `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. |
|
|
96
103
|
| `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
|
|
97
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). |
|
|
98
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`). |
|
|
@@ -101,6 +108,18 @@ Explicit `init()` arguments take precedence over environment variables.
|
|
|
101
108
|
| `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
|
|
102
109
|
| `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
|
|
103
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
|
+
|
|
104
123
|
### Span types
|
|
105
124
|
|
|
106
125
|
`type=` maps to `gen_ai.operation.name`:
|
|
@@ -153,12 +172,17 @@ The built-in Python and TypeScript ratio samplers can select different sessions
|
|
|
153
172
|
|
|
154
173
|
## Auto-metrics
|
|
155
174
|
|
|
156
|
-
Ended spans automatically record
|
|
175
|
+
Ended spans automatically record histograms (DELTA temporality, exported every 60s):
|
|
157
176
|
|
|
158
177
|
- `gen_ai.client.operation.duration` (unit `s`) for `chat`, `invoke_agent`, `embeddings`,
|
|
159
178
|
`execute_tool`
|
|
160
179
|
- `gen_ai.client.token.usage` (unit `{token}`, attribute `gen_ai.token.type=input|output`) for
|
|
161
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.
|
|
162
186
|
|
|
163
187
|
Plain spans (`function`) record no metrics. Quiet intervals produce zero metric requests.
|
|
164
188
|
|
|
@@ -62,6 +62,12 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
|
|
|
62
62
|
|
|
63
63
|
Explicit `init()` arguments take precedence over environment variables.
|
|
64
64
|
|
|
65
|
+
Root spans remain independent by default. `telemetry_dev.init(session_mode="process")` groups
|
|
66
|
+
otherwise uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK
|
|
67
|
+
initialization. Explicit and propagated session IDs take precedence, and valid parent trace
|
|
68
|
+
context is preserved. The generated session ends when the client is shut down or replaced; a
|
|
69
|
+
later `init()` generates a new ID. It is not persisted across processes.
|
|
70
|
+
|
|
65
71
|
## API reference
|
|
66
72
|
|
|
67
73
|
| Name | Description |
|
|
@@ -69,7 +75,7 @@ Explicit `init()` arguments take precedence over environment variables.
|
|
|
69
75
|
| `init(**options) -> Client` | Initialize the SDK (see options below). Calling again replaces the previous client. |
|
|
70
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. |
|
|
71
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()`. |
|
|
72
|
-
| `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. |
|
|
73
79
|
| `update_current_span(**fields)` | Apply the update field set to the currently active span (no-op without one). |
|
|
74
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). |
|
|
75
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`). |
|
|
@@ -78,6 +84,18 @@ Explicit `init()` arguments take precedence over environment variables.
|
|
|
78
84
|
| `TelemetrySpanProcessor` / `telemetry_dev.otel.create_telemetry_span_exporter` | Bring-your-own-OTel helpers (below). |
|
|
79
85
|
| `MaskContext`, `Usage`, `SpanHandle`, `Client`, `NOT_GIVEN` | Supporting types. |
|
|
80
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
|
+
|
|
81
99
|
### Span types
|
|
82
100
|
|
|
83
101
|
`type=` maps to `gen_ai.operation.name`:
|
|
@@ -130,12 +148,17 @@ The built-in Python and TypeScript ratio samplers can select different sessions
|
|
|
130
148
|
|
|
131
149
|
## Auto-metrics
|
|
132
150
|
|
|
133
|
-
Ended spans automatically record
|
|
151
|
+
Ended spans automatically record histograms (DELTA temporality, exported every 60s):
|
|
134
152
|
|
|
135
153
|
- `gen_ai.client.operation.duration` (unit `s`) for `chat`, `invoke_agent`, `embeddings`,
|
|
136
154
|
`execute_tool`
|
|
137
155
|
- `gen_ai.client.token.usage` (unit `{token}`, attribute `gen_ai.token.type=input|output`) for
|
|
138
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.
|
|
139
162
|
|
|
140
163
|
Plain spans (`function`) record no metrics. Quiet intervals produce zero metric requests.
|
|
141
164
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "telemetry-dev"
|
|
3
|
-
version = "0.2.
|
|
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"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
7
8
|
requires-python = ">=3.10"
|
|
8
9
|
keywords = [
|
|
9
10
|
"telemetry",
|
|
@@ -34,7 +35,7 @@ name = "telemetry.dev"
|
|
|
34
35
|
|
|
35
36
|
[project.urls]
|
|
36
37
|
Homepage = "https://telemetry.dev"
|
|
37
|
-
Repository = "https://github.com/telemetry-dev/
|
|
38
|
+
Repository = "https://github.com/telemetry-dev/sdks"
|
|
38
39
|
|
|
39
40
|
[dependency-groups]
|
|
40
41
|
dev = [
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "telemetry-dev"
|
|
3
|
-
version = "0.2.
|
|
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"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
7
8
|
requires-python = ">=3.10"
|
|
8
9
|
authors = [{ name = "telemetry.dev" }]
|
|
9
10
|
keywords = ["telemetry", "opentelemetry", "llm", "genai", "tracing", "observability"]
|
|
@@ -25,7 +26,7 @@ dependencies = [
|
|
|
25
26
|
|
|
26
27
|
[project.urls]
|
|
27
28
|
Homepage = "https://telemetry.dev"
|
|
28
|
-
Repository = "https://github.com/telemetry-dev/
|
|
29
|
+
Repository = "https://github.com/telemetry-dev/sdks"
|
|
29
30
|
|
|
30
31
|
[dependency-groups]
|
|
31
32
|
dev = [
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Iterable
|
|
6
|
+
from itertools import chain
|
|
6
7
|
from typing import Final, cast
|
|
7
8
|
|
|
8
9
|
_DEFAULT_MAX_BYTES: Final = 64 * 1024
|
|
@@ -133,33 +134,7 @@ class CaptureBudget:
|
|
|
133
134
|
mapping = cast("dict[object, object]", attributes)
|
|
134
135
|
|
|
135
136
|
if mapping is not None:
|
|
136
|
-
|
|
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
|
|
137
|
+
children = chain.from_iterable(mapping.items())
|
|
163
138
|
|
|
164
139
|
for child in children:
|
|
165
140
|
measured = cls._measure(
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import atexit
|
|
4
4
|
import threading
|
|
5
|
+
import uuid
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
@@ -33,13 +34,14 @@ from ._config import (
|
|
|
33
34
|
SDK_VERSION,
|
|
34
35
|
LogLevelOption,
|
|
35
36
|
ResolvedConfig,
|
|
37
|
+
SessionMode,
|
|
36
38
|
configure_logger,
|
|
37
39
|
logger,
|
|
38
40
|
report_error,
|
|
39
41
|
resolve_config,
|
|
40
42
|
)
|
|
41
43
|
from ._context import SessionSampler
|
|
42
|
-
from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder
|
|
44
|
+
from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder, OutputChunkAggregation
|
|
43
45
|
from ._processor import ExportMode, StampingSpanProcessor
|
|
44
46
|
from ._semconv import SCOPE_NAME
|
|
45
47
|
from ._serialize import Mask, serialize_content
|
|
@@ -123,6 +125,7 @@ class Client:
|
|
|
123
125
|
*,
|
|
124
126
|
config: ResolvedConfig,
|
|
125
127
|
enabled: bool,
|
|
128
|
+
session_mode: SessionMode = "explicit",
|
|
126
129
|
register_global: bool = False,
|
|
127
130
|
export_mode: ExportMode = "batched",
|
|
128
131
|
capture_input: bool = True,
|
|
@@ -145,6 +148,9 @@ class Client:
|
|
|
145
148
|
self.mask = mask
|
|
146
149
|
self.max_attribute_length = max_attribute_length
|
|
147
150
|
self.on_error = on_error
|
|
151
|
+
self._process_session_id: str | None = (
|
|
152
|
+
str(uuid.uuid4()) if enabled and session_mode == "process" else None
|
|
153
|
+
)
|
|
148
154
|
self._shutdown = False
|
|
149
155
|
self._atexit_registered = False
|
|
150
156
|
|
|
@@ -154,6 +160,7 @@ class Client:
|
|
|
154
160
|
self._tracer_provider: TracerProvider | None = None
|
|
155
161
|
self._logger_provider: LoggerProvider | None = None
|
|
156
162
|
self._meter_provider: MeterProvider | None = None
|
|
163
|
+
self._output_chunks: OutputChunkAggregation | None = None
|
|
157
164
|
|
|
158
165
|
if not enabled:
|
|
159
166
|
return
|
|
@@ -185,6 +192,7 @@ class Client:
|
|
|
185
192
|
# Without an api key (enabled via a test seam), never construct real network
|
|
186
193
|
# exporters — they would POST to the ingest with a bogus Authorization header.
|
|
187
194
|
if metric_reader is None and config.api_key is not None:
|
|
195
|
+
self._output_chunks = OutputChunkAggregation()
|
|
188
196
|
metric_exporter = GuardedOTLPMetricExporter(
|
|
189
197
|
endpoint=f"{config.base_url}/v1/metrics",
|
|
190
198
|
headers=headers,
|
|
@@ -193,6 +201,7 @@ class Client:
|
|
|
193
201
|
preferred_temporality={Histogram: AggregationTemporality.DELTA},
|
|
194
202
|
on_error=on_error,
|
|
195
203
|
)
|
|
204
|
+
metric_exporter.output_chunks = self._output_chunks
|
|
196
205
|
metric_reader = PeriodicExportingMetricReader(
|
|
197
206
|
metric_exporter, export_interval_millis=60_000
|
|
198
207
|
)
|
|
@@ -202,7 +211,9 @@ class Client:
|
|
|
202
211
|
metric_readers=[metric_reader], resource=resource, shutdown_on_exit=False
|
|
203
212
|
)
|
|
204
213
|
meter = self._meter_provider.get_meter(SCOPE_NAME, SDK_VERSION)
|
|
205
|
-
metrics_recorder = MetricsRecorder(
|
|
214
|
+
metrics_recorder = MetricsRecorder(
|
|
215
|
+
meter, on_error=on_error, output_chunks=self._output_chunks
|
|
216
|
+
)
|
|
206
217
|
|
|
207
218
|
if span_exporter is None:
|
|
208
219
|
span_exporter = _ReportingSpanExporter(
|
|
@@ -316,6 +327,7 @@ def init(
|
|
|
316
327
|
environment: str | None = None,
|
|
317
328
|
service_name: str | None = None,
|
|
318
329
|
enabled: bool = True,
|
|
330
|
+
session_mode: SessionMode = "explicit",
|
|
319
331
|
register_global: bool = False,
|
|
320
332
|
export_mode: ExportMode = "batched",
|
|
321
333
|
log_level: LogLevelOption = "warn",
|
|
@@ -348,9 +360,12 @@ def init(
|
|
|
348
360
|
logger.debug("telemetry-dev disabled: no API key")
|
|
349
361
|
|
|
350
362
|
try:
|
|
363
|
+
if session_mode not in ("explicit", "process"):
|
|
364
|
+
raise ValueError(f"invalid session_mode: {session_mode!r}")
|
|
351
365
|
client = Client(
|
|
352
366
|
config=config,
|
|
353
367
|
enabled=effective_enabled,
|
|
368
|
+
session_mode=session_mode,
|
|
354
369
|
register_global=register_global,
|
|
355
370
|
export_mode=export_mode,
|
|
356
371
|
capture_input=capture_input,
|
|
@@ -78,6 +78,13 @@ def propagated_attributes(context: Context | None = None) -> dict[str, Attribute
|
|
|
78
78
|
return {}
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
def context_with_propagated_attributes(
|
|
82
|
+
attributes: Mapping[str, AttributeValue], context: Context | None = None
|
|
83
|
+
) -> Context:
|
|
84
|
+
base = context if context is not None else otel_context.get_current()
|
|
85
|
+
return otel_context.set_value(_PROPAGATED_KEY, attributes, context=base)
|
|
86
|
+
|
|
87
|
+
|
|
81
88
|
@contextmanager
|
|
82
89
|
def propagate_attributes(
|
|
83
90
|
*,
|
|
@@ -7,8 +7,8 @@ from opentelemetry._logs import SeverityNumber
|
|
|
7
7
|
|
|
8
8
|
from ._client import get_client
|
|
9
9
|
from ._config import logger
|
|
10
|
-
from ._context import propagated_attributes
|
|
11
|
-
from ._semconv import SEVERITY, LogLevel
|
|
10
|
+
from ._context import context_with_propagated_attributes, propagated_attributes
|
|
11
|
+
from ._semconv import ATTR_SESSION_ID, SEVERITY, LogLevel
|
|
12
12
|
from ._serialize import AttributeValue, coerce_attr_value
|
|
13
13
|
|
|
14
14
|
|
|
@@ -41,9 +41,21 @@ def log(
|
|
|
41
41
|
)
|
|
42
42
|
if attr is not None:
|
|
43
43
|
attrs[key] = attr
|
|
44
|
+
propagated = propagated_attributes()
|
|
45
|
+
process_session_id = client._process_session_id # pyright: ignore[reportPrivateUsage]
|
|
46
|
+
explicit_session = attributes is not None and ATTR_SESSION_ID in attributes
|
|
47
|
+
if (
|
|
48
|
+
not explicit_session
|
|
49
|
+
and ATTR_SESSION_ID not in propagated
|
|
50
|
+
and process_session_id is not None
|
|
51
|
+
):
|
|
52
|
+
propagated[ATTR_SESSION_ID] = process_session_id
|
|
53
|
+
context = otel_context.get_current()
|
|
54
|
+
if propagated:
|
|
55
|
+
context = context_with_propagated_attributes(propagated, context)
|
|
44
56
|
# Propagated correlation attrs win on key collisions, matching the span-processor
|
|
45
57
|
# stamping model and the TypeScript SDK.
|
|
46
|
-
for key, value in
|
|
58
|
+
for key, value in propagated.items():
|
|
47
59
|
attr = coerce_attr_value(
|
|
48
60
|
value,
|
|
49
61
|
max_len=client.max_attribute_length,
|
|
@@ -54,7 +66,7 @@ def log(
|
|
|
54
66
|
attrs[key] = attr
|
|
55
67
|
body = client.serialize(message, "log.message") or ""
|
|
56
68
|
client.otel_logger.emit(
|
|
57
|
-
context=
|
|
69
|
+
context=context,
|
|
58
70
|
severity_number=SeverityNumber(severity),
|
|
59
71
|
severity_text=normalized.upper(),
|
|
60
72
|
body=body,
|
|
@@ -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
|
|
@@ -19,6 +23,7 @@ from ._client import Client, get_client
|
|
|
19
23
|
from ._config import logger
|
|
20
24
|
from ._context import (
|
|
21
25
|
context_from_parent,
|
|
26
|
+
context_with_propagated_attributes,
|
|
22
27
|
propagated_attributes,
|
|
23
28
|
traceparent_of,
|
|
24
29
|
with_session_parent,
|
|
@@ -41,6 +46,7 @@ from ._semconv import (
|
|
|
41
46
|
ATTR_TOOL_CALL_ID,
|
|
42
47
|
ATTR_TOOL_DESCRIPTION,
|
|
43
48
|
ATTR_TOOL_NAME,
|
|
49
|
+
DURATION_BUCKETS,
|
|
44
50
|
METADATA_PREFIX,
|
|
45
51
|
RESERVED_METADATA_KEYS,
|
|
46
52
|
SAMPLING_ATTRS,
|
|
@@ -85,9 +91,42 @@ class _SpanState:
|
|
|
85
91
|
operation: str
|
|
86
92
|
capture_input: bool
|
|
87
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
|
|
88
100
|
|
|
89
101
|
|
|
90
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
|
+
)
|
|
91
130
|
|
|
92
131
|
|
|
93
132
|
def _to_ns(value: TimeInput) -> int | None:
|
|
@@ -317,10 +356,17 @@ class SpanHandle:
|
|
|
317
356
|
"""Handle around an OTel span. Use as a context manager to activate the span in the
|
|
318
357
|
current context, or keep it detached and call .end() manually."""
|
|
319
358
|
|
|
320
|
-
def __init__(
|
|
359
|
+
def __init__(
|
|
360
|
+
self,
|
|
361
|
+
span: Span,
|
|
362
|
+
client: Client | None,
|
|
363
|
+
state: _SpanState | None,
|
|
364
|
+
context: Context | None = None,
|
|
365
|
+
) -> None:
|
|
321
366
|
self.span = span
|
|
322
367
|
self._client = client
|
|
323
368
|
self._state = state
|
|
369
|
+
self._context = context
|
|
324
370
|
self._context_token: Token[Context] | None = None
|
|
325
371
|
self._ended = False
|
|
326
372
|
|
|
@@ -405,6 +451,45 @@ class SpanHandle:
|
|
|
405
451
|
self._client.report("SpanHandle.update failed", exc)
|
|
406
452
|
return self
|
|
407
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
|
+
|
|
408
493
|
def end(
|
|
409
494
|
self,
|
|
410
495
|
*,
|
|
@@ -472,7 +557,10 @@ class SpanHandle:
|
|
|
472
557
|
attributes=attributes,
|
|
473
558
|
error=error,
|
|
474
559
|
)
|
|
475
|
-
|
|
560
|
+
with _OUTPUT_CHUNK_STATES_LOCK:
|
|
561
|
+
if self._ended:
|
|
562
|
+
return
|
|
563
|
+
self._ended = True
|
|
476
564
|
self.span.end(_to_ns(end_time))
|
|
477
565
|
|
|
478
566
|
def traceparent(self) -> str | None:
|
|
@@ -480,7 +568,9 @@ class SpanHandle:
|
|
|
480
568
|
|
|
481
569
|
def __enter__(self) -> SpanHandle:
|
|
482
570
|
if self._client is not None and self._state is not None:
|
|
483
|
-
self._context_token = otel_context.attach(
|
|
571
|
+
self._context_token = otel_context.attach(
|
|
572
|
+
trace.set_span_in_context(self.span, self._context)
|
|
573
|
+
)
|
|
484
574
|
return self
|
|
485
575
|
|
|
486
576
|
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Literal[False]:
|
|
@@ -593,6 +683,11 @@ def start_span(
|
|
|
593
683
|
if isinstance(explicit_session_id, str)
|
|
594
684
|
else propagated.get(ATTR_SESSION_ID)
|
|
595
685
|
)
|
|
686
|
+
if not isinstance(session_id, str):
|
|
687
|
+
session_id = client._process_session_id # pyright: ignore[reportPrivateUsage]
|
|
688
|
+
if isinstance(session_id, str):
|
|
689
|
+
propagated[ATTR_SESSION_ID] = session_id
|
|
690
|
+
ctx = context_with_propagated_attributes(propagated, ctx)
|
|
596
691
|
ctx = with_session_parent(
|
|
597
692
|
ctx, session_id if isinstance(session_id, str) else None, client.config.api_key
|
|
598
693
|
)
|
|
@@ -605,7 +700,7 @@ def start_span(
|
|
|
605
700
|
start_time=_to_ns(start_time),
|
|
606
701
|
)
|
|
607
702
|
_SPAN_STATES[span] = state
|
|
608
|
-
handle = SpanHandle(span, client, state)
|
|
703
|
+
handle = SpanHandle(span, client, state, ctx)
|
|
609
704
|
# The processor stamps propagation on start; explicit fields still win.
|
|
610
705
|
if attrs:
|
|
611
706
|
span.set_attributes(attrs)
|
|
@@ -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
|