telemetry-dev 0.2.1__tar.gz → 0.2.3__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.
@@ -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.1
3
+ Version: 0.2.3
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
@@ -80,9 +81,17 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
80
81
  | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
81
82
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
82
83
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
84
+ | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
85
+ | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Probability for `traceidratio` / `parentbased_traceidratio`. |
83
86
 
84
87
  Explicit `init()` arguments take precedence over environment variables.
85
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
+
86
95
  ## API reference
87
96
 
88
97
  | Name | Description |
@@ -137,11 +146,18 @@ Explicit `init()` arguments take precedence over environment variables.
137
146
  | `mask` | `None` | `Callable[[Any, MaskContext], Any]` redaction hook, runs before JSON serialization on input/output/log messages (`MaskContext.key` is the attribute being written). Not applied to correlation identifiers. |
138
147
  | `max_attribute_length` | `65536` | Per-content-attribute cap; truncated values get an ASCII `...[truncated]` marker appended. |
139
148
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
149
+ | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
140
150
  | `on_error` | `None` | Receives every internal SDK error; the SDK never raises. |
141
151
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
142
152
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
143
153
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
144
154
 
155
+ Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
156
+ The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
157
+ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples sessions with OTel's ratio sampler.
158
+ These classes come from `opentelemetry.sdk.trace.sampling`.
159
+ The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
160
+
145
161
  ## Auto-metrics
146
162
 
147
163
  Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
@@ -57,9 +57,17 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
57
57
  | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
58
58
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
59
59
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
60
+ | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
61
+ | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Probability for `traceidratio` / `parentbased_traceidratio`. |
60
62
 
61
63
  Explicit `init()` arguments take precedence over environment variables.
62
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
+
63
71
  ## API reference
64
72
 
65
73
  | Name | Description |
@@ -114,11 +122,18 @@ Explicit `init()` arguments take precedence over environment variables.
114
122
  | `mask` | `None` | `Callable[[Any, MaskContext], Any]` redaction hook, runs before JSON serialization on input/output/log messages (`MaskContext.key` is the attribute being written). Not applied to correlation identifiers. |
115
123
  | `max_attribute_length` | `65536` | Per-content-attribute cap; truncated values get an ASCII `...[truncated]` marker appended. |
116
124
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
125
+ | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
117
126
  | `on_error` | `None` | Receives every internal SDK error; the SDK never raises. |
118
127
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
119
128
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
120
129
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
121
130
 
131
+ Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
132
+ The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
133
+ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples sessions with OTel's ratio sampler.
134
+ These classes come from `opentelemetry.sdk.trace.sampling`.
135
+ The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
136
+
122
137
  ## Auto-metrics
123
138
 
124
139
  Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
@@ -1,9 +1,10 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.1"
3
+ version = "0.2.3"
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.1"
3
+ version = "0.2.3"
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
 
@@ -26,17 +27,20 @@ from opentelemetry.sdk.metrics.export import (
26
27
  from opentelemetry.sdk.resources import Resource
27
28
  from opentelemetry.sdk.trace import ReadableSpan, SpanLimits, TracerProvider
28
29
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
30
+ from opentelemetry.sdk.trace.sampling import Sampler
29
31
  from opentelemetry.trace import Tracer
30
32
 
31
33
  from ._config import (
32
34
  SDK_VERSION,
33
35
  LogLevelOption,
34
36
  ResolvedConfig,
37
+ SessionMode,
35
38
  configure_logger,
36
39
  logger,
37
40
  report_error,
38
41
  resolve_config,
39
42
  )
43
+ from ._context import SessionSampler
40
44
  from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder
41
45
  from ._processor import ExportMode, StampingSpanProcessor
42
46
  from ._semconv import SCOPE_NAME
@@ -121,6 +125,7 @@ class Client:
121
125
  *,
122
126
  config: ResolvedConfig,
123
127
  enabled: bool,
128
+ session_mode: SessionMode = "explicit",
124
129
  register_global: bool = False,
125
130
  export_mode: ExportMode = "batched",
126
131
  capture_input: bool = True,
@@ -128,6 +133,7 @@ class Client:
128
133
  mask: Mask | None = None,
129
134
  max_attribute_length: int = 65536,
130
135
  span_filter: Callable[[ReadableSpan], bool] | None = None,
136
+ sampler: Sampler | None = None,
131
137
  on_error: Callable[[BaseException], None] | None = None,
132
138
  disable_atexit: bool = False,
133
139
  timeout: float = 10.0,
@@ -142,6 +148,9 @@ class Client:
142
148
  self.mask = mask
143
149
  self.max_attribute_length = max_attribute_length
144
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
+ )
145
154
  self._shutdown = False
146
155
  self._atexit_registered = False
147
156
 
@@ -170,12 +179,14 @@ class Client:
170
179
  # the backstop can use the same cap for raw attributes set outside the funnel.
171
180
  self._tracer_provider = TracerProvider(
172
181
  resource=resource,
182
+ sampler=sampler,
173
183
  shutdown_on_exit=False,
174
184
  span_limits=SpanLimits(
175
185
  max_attribute_length=max_attribute_length,
176
186
  max_span_attribute_length=max_attribute_length,
177
187
  ),
178
188
  )
189
+ self._tracer_provider.sampler = SessionSampler(self._tracer_provider.sampler)
179
190
 
180
191
  # Without an api key (enabled via a test seam), never construct real network
181
192
  # exporters — they would POST to the ingest with a bogus Authorization header.
@@ -311,6 +322,7 @@ def init(
311
322
  environment: str | None = None,
312
323
  service_name: str | None = None,
313
324
  enabled: bool = True,
325
+ session_mode: SessionMode = "explicit",
314
326
  register_global: bool = False,
315
327
  export_mode: ExportMode = "batched",
316
328
  log_level: LogLevelOption = "warn",
@@ -319,6 +331,7 @@ def init(
319
331
  mask: Mask | None = None,
320
332
  max_attribute_length: int = 65536,
321
333
  span_filter: Callable[[ReadableSpan], bool] | None = None,
334
+ sampler: Sampler | None = None,
322
335
  on_error: Callable[[BaseException], None] | None = None,
323
336
  disable_atexit: bool = False,
324
337
  timeout: float = 10.0,
@@ -342,9 +355,12 @@ def init(
342
355
  logger.debug("telemetry-dev disabled: no API key")
343
356
 
344
357
  try:
358
+ if session_mode not in ("explicit", "process"):
359
+ raise ValueError(f"invalid session_mode: {session_mode!r}")
345
360
  client = Client(
346
361
  config=config,
347
362
  enabled=effective_enabled,
363
+ session_mode=session_mode,
348
364
  register_global=register_global,
349
365
  export_mode=export_mode,
350
366
  capture_input=capture_input,
@@ -352,6 +368,7 @@ def init(
352
368
  mask=mask,
353
369
  max_attribute_length=max_attribute_length,
354
370
  span_filter=span_filter,
371
+ sampler=sampler,
355
372
  on_error=on_error,
356
373
  disable_atexit=disable_atexit,
357
374
  timeout=timeout,
@@ -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
 
@@ -1,14 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Generator, Mapping
3
+ import hashlib
4
+ from collections.abc import Generator, Mapping, Sequence
4
5
  from contextlib import contextmanager
5
6
  from typing import Any, cast
6
7
 
7
8
  from opentelemetry import context as otel_context
8
9
  from opentelemetry import trace
9
10
  from opentelemetry.context import Context
10
- from opentelemetry.trace import NonRecordingSpan, SpanContext
11
+ from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult
12
+ from opentelemetry.trace import (
13
+ Link,
14
+ NonRecordingSpan,
15
+ SpanContext,
16
+ SpanKind,
17
+ TraceFlags,
18
+ TraceState,
19
+ )
11
20
  from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
21
+ from opentelemetry.util.types import Attributes
12
22
 
13
23
  from ._config import logger
14
24
  from ._semconv import (
@@ -26,6 +36,41 @@ _PROPAGATOR = TraceContextTextMapPropagator()
26
36
  ParentInput = str | Context | SpanContext | None
27
37
 
28
38
 
39
+ class _SessionParent(NonRecordingSpan):
40
+ def __init__(self, span_context: SpanContext, context: Context) -> None:
41
+ super().__init__(span_context)
42
+ self.parent_context = context
43
+
44
+
45
+ class SessionSampler(Sampler):
46
+ """Pair with with_session_parent to sample roots using the provider's real policy."""
47
+
48
+ def __init__(self, inner: Sampler) -> None:
49
+ self.inner = inner
50
+
51
+ def should_sample(
52
+ self,
53
+ parent_context: Context | None,
54
+ trace_id: int,
55
+ name: str,
56
+ kind: SpanKind | None = None,
57
+ attributes: Attributes = None,
58
+ links: Sequence[Link] | None = None,
59
+ trace_state: TraceState | None = None,
60
+ ) -> SamplingResult:
61
+ parent = trace.get_current_span(parent_context)
62
+ if isinstance(parent, _SessionParent):
63
+ parent_context = trace.set_span_in_context(
64
+ trace.get_current_span(parent.parent_context), parent_context
65
+ )
66
+ return self.inner.should_sample(
67
+ parent_context, trace_id, name, kind, attributes, links, trace_state
68
+ )
69
+
70
+ def get_description(self) -> str:
71
+ return f"SessionSampler{{{self.inner.get_description()}}}"
72
+
73
+
29
74
  def propagated_attributes(context: Context | None = None) -> dict[str, AttributeValue]:
30
75
  value = otel_context.get_value(_PROPAGATED_KEY, context=context)
31
76
  if isinstance(value, Mapping):
@@ -33,6 +78,13 @@ def propagated_attributes(context: Context | None = None) -> dict[str, Attribute
33
78
  return {}
34
79
 
35
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
+
36
88
  @contextmanager
37
89
  def propagate_attributes(
38
90
  *,
@@ -99,3 +151,33 @@ def context_from_parent(parent: ParentInput) -> Context | None:
99
151
  if ambient:
100
152
  ctx = otel_context.set_value(_PROPAGATED_KEY, ambient, context=ctx)
101
153
  return ctx
154
+
155
+
156
+ def session_span_context(api_key: str | None, session_id: str) -> SpanContext:
157
+ """Deterministic remote parent for a session, byte-identical to the TS `sessionSpanContext`:
158
+ trace id = SHA-256(api_key ‖ 0x00 ‖ session_id)[0:16], parent span id = digest[16:24].
159
+ The flags are not a sampling decision; pair with_session_parent with SessionSampler."""
160
+ text = f"{api_key or ''}\0{session_id}"
161
+ # Match TextEncoder: surrogate pairs combine, lone surrogates become U+FFFD.
162
+ text = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace")
163
+ digest = hashlib.sha256(text.encode()).digest()
164
+ return SpanContext(
165
+ trace_id=int.from_bytes(digest[:16], "big"),
166
+ span_id=int.from_bytes(digest[16:24], "big"),
167
+ is_remote=True,
168
+ trace_flags=TraceFlags(TraceFlags.DEFAULT),
169
+ )
170
+
171
+
172
+ def with_session_parent(
173
+ context: Context | None, session_id: str | None, api_key: str | None
174
+ ) -> Context | None:
175
+ """Parent a root under the session; requires SessionSampler on the provider."""
176
+ if not api_key or not session_id:
177
+ return context
178
+ if trace.get_current_span(context).get_span_context().is_valid:
179
+ return context
180
+ base = context if context is not None else otel_context.get_current()
181
+ return trace.set_span_in_context(
182
+ _SessionParent(session_span_context(api_key, session_id), base), base
183
+ )
@@ -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,
@@ -17,7 +17,13 @@ from opentelemetry.util import types as otel_types
17
17
 
18
18
  from ._client import Client, get_client
19
19
  from ._config import logger
20
- from ._context import context_from_parent, traceparent_of
20
+ from ._context import (
21
+ context_from_parent,
22
+ context_with_propagated_attributes,
23
+ propagated_attributes,
24
+ traceparent_of,
25
+ with_session_parent,
26
+ )
21
27
  from ._semconv import (
22
28
  ATTR_AGENT_ID,
23
29
  ATTR_AGENT_NAME,
@@ -30,6 +36,7 @@ from ._semconv import (
30
36
  ATTR_REQUEST_MODEL,
31
37
  ATTR_RESPONSE_ID,
32
38
  ATTR_RESPONSE_MODEL,
39
+ ATTR_SESSION_ID,
33
40
  ATTR_SYSTEM_INSTRUCTIONS,
34
41
  ATTR_TIME_TO_FIRST_CHUNK,
35
42
  ATTR_TOOL_CALL_ID,
@@ -145,14 +152,12 @@ def _usage_attrs(usage: Mapping[str, Any]) -> dict[str, AttributeValue]:
145
152
  return attrs
146
153
 
147
154
 
148
- def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[str, Any]) -> None:
155
+ def _fields_to_attributes(
156
+ client: Client, state: _SpanState, fields: dict[str, Any]
157
+ ) -> dict[str, otel_types.AttributeValue]:
149
158
  """Map the shared snake_case field set onto gen_ai.* attributes; raw `attributes` merge last."""
150
159
  attrs: dict[str, otel_types.AttributeValue] = {}
151
160
 
152
- name = fields.get("name")
153
- if name is not None:
154
- span.update_name(name)
155
-
156
161
  for field, attr in (
157
162
  ("model", ATTR_REQUEST_MODEL),
158
163
  ("provider", ATTR_PROVIDER),
@@ -222,6 +227,14 @@ def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[st
222
227
  if attr is not None:
223
228
  attrs[key] = attr
224
229
 
230
+ return attrs
231
+
232
+
233
+ def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[str, Any]) -> None:
234
+ name = fields.get("name")
235
+ if name is not None:
236
+ span.update_name(name)
237
+ attrs = _fields_to_attributes(client, state, fields)
225
238
  if attrs:
226
239
  span.set_attributes(attrs)
227
240
 
@@ -305,10 +318,17 @@ class SpanHandle:
305
318
  """Handle around an OTel span. Use as a context manager to activate the span in the
306
319
  current context, or keep it detached and call .end() manually."""
307
320
 
308
- def __init__(self, span: Span, client: Client | None, state: _SpanState | None) -> None:
321
+ def __init__(
322
+ self,
323
+ span: Span,
324
+ client: Client | None,
325
+ state: _SpanState | None,
326
+ context: Context | None = None,
327
+ ) -> None:
309
328
  self.span = span
310
329
  self._client = client
311
330
  self._state = state
331
+ self._context = context
312
332
  self._context_token: Token[Context] | None = None
313
333
  self._ended = False
314
334
 
@@ -468,7 +488,9 @@ class SpanHandle:
468
488
 
469
489
  def __enter__(self) -> SpanHandle:
470
490
  if self._client is not None and self._state is not None:
471
- self._context_token = otel_context.attach(trace.set_span_in_context(self.span))
491
+ self._context_token = otel_context.attach(
492
+ trace.set_span_in_context(self.span, self._context)
493
+ )
472
494
  return self
473
495
 
474
496
  def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Literal[False]:
@@ -538,19 +560,6 @@ def start_span(
538
560
  capture_input=client.capture_input if capture_input is None else capture_input,
539
561
  capture_output=client.capture_output if capture_output is None else capture_output,
540
562
  )
541
- initial: dict[str, otel_types.AttributeValue] = {ATTR_OPERATION: operation}
542
- if operation == "execute_tool":
543
- initial[ATTR_TOOL_NAME] = name
544
- elif operation == "invoke_agent":
545
- initial[ATTR_AGENT_NAME] = name
546
- span = client.tracer.start_span(
547
- name,
548
- context=context_from_parent(parent),
549
- attributes=initial,
550
- start_time=_to_ns(start_time),
551
- )
552
- _SPAN_STATES[span] = state
553
- handle = SpanHandle(span, client, state)
554
563
  fields = _collect_fields(
555
564
  input=input,
556
565
  output=output,
@@ -580,8 +589,41 @@ def start_span(
580
589
  metadata=metadata,
581
590
  attributes=attributes,
582
591
  )
583
- if fields:
584
- _apply_fields(client, span, state, fields)
592
+ attrs = _fields_to_attributes(client, state, fields)
593
+ initial: dict[str, otel_types.AttributeValue] = {ATTR_OPERATION: operation}
594
+ if operation == "execute_tool":
595
+ initial[ATTR_TOOL_NAME] = name
596
+ elif operation == "invoke_agent":
597
+ initial[ATTR_AGENT_NAME] = name
598
+ ctx = context_from_parent(parent)
599
+ propagated = propagated_attributes(ctx)
600
+ explicit_session_id = (attributes or {}).get(ATTR_SESSION_ID)
601
+ session_id = (
602
+ explicit_session_id
603
+ if isinstance(explicit_session_id, str)
604
+ else propagated.get(ATTR_SESSION_ID)
605
+ )
606
+ if not isinstance(session_id, str):
607
+ session_id = client._process_session_id # pyright: ignore[reportPrivateUsage]
608
+ if isinstance(session_id, str):
609
+ propagated[ATTR_SESSION_ID] = session_id
610
+ ctx = context_with_propagated_attributes(propagated, ctx)
611
+ ctx = with_session_parent(
612
+ ctx, session_id if isinstance(session_id, str) else None, client.config.api_key
613
+ )
614
+ initial.update(propagated)
615
+ initial.update(attrs)
616
+ span = client.tracer.start_span(
617
+ name,
618
+ context=ctx,
619
+ attributes=initial,
620
+ start_time=_to_ns(start_time),
621
+ )
622
+ _SPAN_STATES[span] = state
623
+ handle = SpanHandle(span, client, state, ctx)
624
+ # The processor stamps propagation on start; explicit fields still win.
625
+ if attrs:
626
+ span.set_attributes(attrs)
585
627
  return handle
586
628
  except BaseException as exc:
587
629
  client.report("start_span failed", exc)