telemetry-dev 0.2.1__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telemetry-dev
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -80,6 +80,8 @@ routed to the `on_error` hook / `telemetry_dev` logger — never raised into you
80
80
  | `TELEMETRY_DEV_BASE_URL` | `https://ingest.telemetry.dev` | Ingest base URL (trailing slashes stripped). |
81
81
  | `TELEMETRY_DEV_ENVIRONMENT` | `production` | Deployment environment label. |
82
82
  | `OTEL_SERVICE_NAME` | `unknown_service` | Service name on every trace. |
83
+ | `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | OTel trace sampling policy, which includes session roots. |
84
+ | `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Probability for `traceidratio` / `parentbased_traceidratio`. |
83
85
 
84
86
  Explicit `init()` arguments take precedence over environment variables.
85
87
 
@@ -137,11 +139,18 @@ Explicit `init()` arguments take precedence over environment variables.
137
139
  | `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
140
  | `max_attribute_length` | `65536` | Per-content-attribute cap; truncated values get an ASCII `...[truncated]` marker appended. |
139
141
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
142
+ | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
140
143
  | `on_error` | `None` | Receives every internal SDK error; the SDK never raises. |
141
144
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
142
145
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
143
146
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
144
147
 
148
+ Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
149
+ The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
150
+ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples sessions with OTel's ratio sampler.
151
+ These classes come from `opentelemetry.sdk.trace.sampling`.
152
+ The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
153
+
145
154
  ## Auto-metrics
146
155
 
147
156
  Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
@@ -57,6 +57,8 @@ 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
 
@@ -114,11 +116,18 @@ Explicit `init()` arguments take precedence over environment variables.
114
116
  | `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
117
  | `max_attribute_length` | `65536` | Per-content-attribute cap; truncated values get an ASCII `...[truncated]` marker appended. |
116
118
  | `span_filter` | `None` | Export predicate `Callable[[ReadableSpan], bool]`. |
119
+ | `sampler` | OTel environment configuration | OTel `Sampler` that overrides environment settings. Session roots use its root policy. Real parents keep their sampling decisions. |
117
120
  | `on_error` | `None` | Receives every internal SDK error; the SDK never raises. |
118
121
  | `disable_atexit` | `False` | Skip the automatic atexit shutdown. |
119
122
  | `timeout` | `10.0` | OTLP HTTP timeout in seconds. |
120
123
  | `span_exporter`, `log_exporter`, `metric_reader` | `None` | Test seams / offline mode; any of them enables the client without an API key. |
121
124
 
125
+ Session roots share trace IDs from the API key and session ID. The hash matches the TypeScript SDK.
126
+ The SDK uses the configured OTel sampling policy, not the synthetic parent's flags.
127
+ For example, `init(sampler=ParentBased(TraceIdRatioBased(0.1)))` samples sessions with OTel's ratio sampler.
128
+ These classes come from `opentelemetry.sdk.trace.sampling`.
129
+ The built-in Python and TypeScript ratio samplers can select different sessions because the OTel algorithms differ.
130
+
122
131
  ## Auto-metrics
123
132
 
124
133
  Ended spans automatically record two histograms (DELTA temporality, exported every 60s):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "telemetry-dev"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "telemetry.dev SDK for Python — OpenTelemetry-native GenAI tracing, logs, and metrics"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -26,6 +26,7 @@ from opentelemetry.sdk.metrics.export import (
26
26
  from opentelemetry.sdk.resources import Resource
27
27
  from opentelemetry.sdk.trace import ReadableSpan, SpanLimits, TracerProvider
28
28
  from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
29
+ from opentelemetry.sdk.trace.sampling import Sampler
29
30
  from opentelemetry.trace import Tracer
30
31
 
31
32
  from ._config import (
@@ -37,6 +38,7 @@ from ._config import (
37
38
  report_error,
38
39
  resolve_config,
39
40
  )
41
+ from ._context import SessionSampler
40
42
  from ._metrics import GuardedOTLPMetricExporter, MetricsRecorder
41
43
  from ._processor import ExportMode, StampingSpanProcessor
42
44
  from ._semconv import SCOPE_NAME
@@ -128,6 +130,7 @@ class Client:
128
130
  mask: Mask | None = None,
129
131
  max_attribute_length: int = 65536,
130
132
  span_filter: Callable[[ReadableSpan], bool] | None = None,
133
+ sampler: Sampler | None = None,
131
134
  on_error: Callable[[BaseException], None] | None = None,
132
135
  disable_atexit: bool = False,
133
136
  timeout: float = 10.0,
@@ -170,12 +173,14 @@ class Client:
170
173
  # the backstop can use the same cap for raw attributes set outside the funnel.
171
174
  self._tracer_provider = TracerProvider(
172
175
  resource=resource,
176
+ sampler=sampler,
173
177
  shutdown_on_exit=False,
174
178
  span_limits=SpanLimits(
175
179
  max_attribute_length=max_attribute_length,
176
180
  max_span_attribute_length=max_attribute_length,
177
181
  ),
178
182
  )
183
+ self._tracer_provider.sampler = SessionSampler(self._tracer_provider.sampler)
179
184
 
180
185
  # Without an api key (enabled via a test seam), never construct real network
181
186
  # exporters — they would POST to the ingest with a bogus Authorization header.
@@ -319,6 +324,7 @@ def init(
319
324
  mask: Mask | None = None,
320
325
  max_attribute_length: int = 65536,
321
326
  span_filter: Callable[[ReadableSpan], bool] | None = None,
327
+ sampler: Sampler | None = None,
322
328
  on_error: Callable[[BaseException], None] | None = None,
323
329
  disable_atexit: bool = False,
324
330
  timeout: float = 10.0,
@@ -352,6 +358,7 @@ def init(
352
358
  mask=mask,
353
359
  max_attribute_length=max_attribute_length,
354
360
  span_filter=span_filter,
361
+ sampler=sampler,
355
362
  on_error=on_error,
356
363
  disable_atexit=disable_atexit,
357
364
  timeout=timeout,
@@ -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):
@@ -99,3 +144,33 @@ def context_from_parent(parent: ParentInput) -> Context | None:
99
144
  if ambient:
100
145
  ctx = otel_context.set_value(_PROPAGATED_KEY, ambient, context=ctx)
101
146
  return ctx
147
+
148
+
149
+ def session_span_context(api_key: str | None, session_id: str) -> SpanContext:
150
+ """Deterministic remote parent for a session, byte-identical to the TS `sessionSpanContext`:
151
+ trace id = SHA-256(api_key ‖ 0x00 ‖ session_id)[0:16], parent span id = digest[16:24].
152
+ The flags are not a sampling decision; pair with_session_parent with SessionSampler."""
153
+ text = f"{api_key or ''}\0{session_id}"
154
+ # Match TextEncoder: surrogate pairs combine, lone surrogates become U+FFFD.
155
+ text = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace")
156
+ digest = hashlib.sha256(text.encode()).digest()
157
+ return SpanContext(
158
+ trace_id=int.from_bytes(digest[:16], "big"),
159
+ span_id=int.from_bytes(digest[16:24], "big"),
160
+ is_remote=True,
161
+ trace_flags=TraceFlags(TraceFlags.DEFAULT),
162
+ )
163
+
164
+
165
+ def with_session_parent(
166
+ context: Context | None, session_id: str | None, api_key: str | None
167
+ ) -> Context | None:
168
+ """Parent a root under the session; requires SessionSampler on the provider."""
169
+ if not api_key or not session_id:
170
+ return context
171
+ if trace.get_current_span(context).get_span_context().is_valid:
172
+ return context
173
+ base = context if context is not None else otel_context.get_current()
174
+ return trace.set_span_in_context(
175
+ _SessionParent(session_span_context(api_key, session_id), base), base
176
+ )
@@ -17,7 +17,12 @@ 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
+ propagated_attributes,
23
+ traceparent_of,
24
+ with_session_parent,
25
+ )
21
26
  from ._semconv import (
22
27
  ATTR_AGENT_ID,
23
28
  ATTR_AGENT_NAME,
@@ -30,6 +35,7 @@ from ._semconv import (
30
35
  ATTR_REQUEST_MODEL,
31
36
  ATTR_RESPONSE_ID,
32
37
  ATTR_RESPONSE_MODEL,
38
+ ATTR_SESSION_ID,
33
39
  ATTR_SYSTEM_INSTRUCTIONS,
34
40
  ATTR_TIME_TO_FIRST_CHUNK,
35
41
  ATTR_TOOL_CALL_ID,
@@ -145,14 +151,12 @@ def _usage_attrs(usage: Mapping[str, Any]) -> dict[str, AttributeValue]:
145
151
  return attrs
146
152
 
147
153
 
148
- def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[str, Any]) -> None:
154
+ def _fields_to_attributes(
155
+ client: Client, state: _SpanState, fields: dict[str, Any]
156
+ ) -> dict[str, otel_types.AttributeValue]:
149
157
  """Map the shared snake_case field set onto gen_ai.* attributes; raw `attributes` merge last."""
150
158
  attrs: dict[str, otel_types.AttributeValue] = {}
151
159
 
152
- name = fields.get("name")
153
- if name is not None:
154
- span.update_name(name)
155
-
156
160
  for field, attr in (
157
161
  ("model", ATTR_REQUEST_MODEL),
158
162
  ("provider", ATTR_PROVIDER),
@@ -222,6 +226,14 @@ def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[st
222
226
  if attr is not None:
223
227
  attrs[key] = attr
224
228
 
229
+ return attrs
230
+
231
+
232
+ def _apply_fields(client: Client, span: Span, state: _SpanState, fields: dict[str, Any]) -> None:
233
+ name = fields.get("name")
234
+ if name is not None:
235
+ span.update_name(name)
236
+ attrs = _fields_to_attributes(client, state, fields)
225
237
  if attrs:
226
238
  span.set_attributes(attrs)
227
239
 
@@ -538,19 +550,6 @@ def start_span(
538
550
  capture_input=client.capture_input if capture_input is None else capture_input,
539
551
  capture_output=client.capture_output if capture_output is None else capture_output,
540
552
  )
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
553
  fields = _collect_fields(
555
554
  input=input,
556
555
  output=output,
@@ -580,8 +579,36 @@ def start_span(
580
579
  metadata=metadata,
581
580
  attributes=attributes,
582
581
  )
583
- if fields:
584
- _apply_fields(client, span, state, fields)
582
+ attrs = _fields_to_attributes(client, state, fields)
583
+ initial: dict[str, otel_types.AttributeValue] = {ATTR_OPERATION: operation}
584
+ if operation == "execute_tool":
585
+ initial[ATTR_TOOL_NAME] = name
586
+ elif operation == "invoke_agent":
587
+ initial[ATTR_AGENT_NAME] = name
588
+ ctx = context_from_parent(parent)
589
+ propagated = propagated_attributes(ctx)
590
+ explicit_session_id = (attributes or {}).get(ATTR_SESSION_ID)
591
+ session_id = (
592
+ explicit_session_id
593
+ if isinstance(explicit_session_id, str)
594
+ else propagated.get(ATTR_SESSION_ID)
595
+ )
596
+ ctx = with_session_parent(
597
+ ctx, session_id if isinstance(session_id, str) else None, client.config.api_key
598
+ )
599
+ initial.update(propagated)
600
+ initial.update(attrs)
601
+ span = client.tracer.start_span(
602
+ name,
603
+ context=ctx,
604
+ attributes=initial,
605
+ start_time=_to_ns(start_time),
606
+ )
607
+ _SPAN_STATES[span] = state
608
+ handle = SpanHandle(span, client, state)
609
+ # The processor stamps propagation on start; explicit fields still win.
610
+ if attrs:
611
+ span.set_attributes(attrs)
585
612
  return handle
586
613
  except BaseException as exc:
587
614
  client.report("start_span failed", exc)