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.
Files changed (20) hide show
  1. telemetry_dev-0.2.4/LICENSE +21 -0
  2. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/PKG-INFO +28 -4
  3. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/README.md +25 -2
  4. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/pyproject.toml +3 -2
  5. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/pyproject.toml.orig +3 -2
  6. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_capture.py +2 -27
  7. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_client.py +17 -2
  8. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_config.py +1 -0
  9. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_context.py +7 -0
  10. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_logs.py +16 -4
  11. telemetry_dev-0.2.4/src/telemetry_dev/_metrics.py +280 -0
  12. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_spans.py +100 -5
  13. telemetry_dev-0.2.2/src/telemetry_dev/_metrics.py +0 -120
  14. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/__init__.py +0 -0
  15. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_observe.py +0 -0
  16. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_processor.py +0 -0
  17. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_semconv.py +0 -0
  18. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/_serialize.py +0 -0
  19. {telemetry_dev-0.2.2 → telemetry_dev-0.2.4}/src/telemetry_dev/otel.py +0 -0
  20. {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.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/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 (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. |
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 two histograms (DELTA temporality, exported every 60s):
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 (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. |
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 two histograms (DELTA temporality, exported every 60s):
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.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/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.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/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
- for key, child in mapping.items():
137
- measured = cls._measure(
138
- key,
139
- remaining_bytes=remaining_bytes - byte_count,
140
- remaining_items=remaining_items - item_count,
141
- depth=depth + 1,
142
- seen=seen,
143
- )
144
- if measured is None:
145
- return None
146
- child_bytes, child_items = measured
147
- byte_count += child_bytes
148
- item_count += child_items
149
-
150
- measured = cls._measure(
151
- child,
152
- remaining_bytes=remaining_bytes - byte_count,
153
- remaining_items=remaining_items - item_count,
154
- depth=depth + 1,
155
- seen=seen,
156
- )
157
- if measured is None:
158
- return None
159
- child_bytes, child_items = measured
160
- byte_count += child_bytes
161
- item_count += child_items
162
- return byte_count, item_count
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(meter, on_error=on_error)
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,
@@ -22,6 +22,7 @@ def _resolve_version() -> str:
22
22
  SDK_VERSION = _resolve_version()
23
23
 
24
24
  LogLevelOption = Literal["debug", "info", "warn", "error", "silent"]
25
+ SessionMode = Literal["explicit", "process"]
25
26
 
26
27
  logger = logging.getLogger("telemetry_dev")
27
28
 
@@ -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 propagated_attributes().items():
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=otel_context.get_current(),
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__(self, span: Span, client: Client | None, state: _SpanState | None) -> None:
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
- self._ended = True
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(trace.set_span_in_context(self.span))
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)